diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 681d3292..11d45b46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,7 @@ jobs: - run: npm run check:workspaces - run: npm run typecheck - run: npm test + + e2e: + name: E2E + uses: ./.github/workflows/e2e.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 661a32c3..a3566a6a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,8 +10,13 @@ permissions: contents: read jobs: + e2e: + name: E2E + uses: ./.github/workflows/e2e.yml + deploy: name: Build & Deploy + needs: e2e runs-on: ubuntu-latest steps: diff --git a/.github/workflows/e2e-update-screenshots.yml b/.github/workflows/e2e-update-screenshots.yml new file mode 100644 index 00000000..17f12ec8 --- /dev/null +++ b/.github/workflows/e2e-update-screenshots.yml @@ -0,0 +1,70 @@ +name: E2E — update screenshots + +# Regenerates the e2e screenshot baselines in the same Playwright image that +# compares them, and commits them to the PR branch. +# +# Trigger: add the `update-screenshots` label to a PR (the label is removed +# again when the run ends). Only baselines that changed or are missing are +# rewritten. Review the new images in the PR diff before merging. +# +# A push made with GITHUB_TOKEN does not start other workflows: re-run the +# CI checks on the PR afterwards (or push any commit). + +on: + pull_request: + types: [labeled] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: e2e-update-screenshots-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + update: + name: Update screenshot baselines + if: github.event.label.name == 'update-screenshots' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 20 + container: + image: mcr.microsoft.com/playwright:v1.63.0-noble + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - name: Build Studio and Hub + run: npm run build:web && npm run build:hub + - name: Regenerate baselines + # Failures other than screenshot diffs still fail the run below; the + # baselines are written either way. + run: npm run e2e -w @radical/e2e -- --update-snapshots=changed --retries=0 || true + - name: Verify + run: npm run e2e -w @radical/e2e -- --retries=0 + - name: Commit + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add apps/e2e/tests + if git diff --cached --quiet; then + echo "Screenshots are up to date." + else + git commit -m "test(e2e): update screenshot baselines" + git push + fi + - name: Remove the label + if: always() + env: + GH_TOKEN: ${{ github.token }} + run: | + curl -sS -X DELETE \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels/update-screenshots" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..72eb9722 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,42 @@ +name: E2E + +# End-to-end regression suite (apps/e2e): Playwright against the production +# web builds of Studio and Hub, including screenshot comparison. +# +# Called by ci.yml and deploy.yml. Runs inside the Playwright image so fonts +# and rendering match the committed screenshot baselines; keep the image tag +# equal to the @playwright/test version in apps/e2e/package.json. + +on: + workflow_call: + +permissions: + contents: read + +jobs: + e2e: + name: E2E (Playwright) + runs-on: ubuntu-latest + timeout-minutes: 20 + container: + image: mcr.microsoft.com/playwright:v1.63.0-noble + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - name: Build Studio and Hub + run: npm run build:web && npm run build:hub + - name: Run the suite + run: npm run e2e -w @radical/e2e + - name: Upload report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: e2e-report + path: | + apps/e2e/playwright-report/ + apps/e2e/test-results/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 22b1b687..6faa9588 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,8 @@ infra/override.tf.json # Note: infra/.terraform.lock.hcl IS committed (pins provider versions) # Note: packages/hub-catalogue/catalogue/**/*.radical IS committed — it is the canonical # concept catalogue served at hub.radical.tools/hub/ (index.json is generated) + +# E2E (Playwright) run output; baselines under apps/e2e/tests/__screenshots__ are committed +apps/e2e/test-results/ +apps/e2e/playwright-report/ +apps/e2e/blob-report/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5aeb1125..43a1b443 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,7 @@ cd radical.tools npm install npm run dev # Electron app with hot-reload npm test # Run tests +npm run e2e # End-to-end suite (Playwright), see apps/e2e/README.md npm run typecheck # Type-check ``` @@ -40,6 +41,7 @@ apps/ tests/ — Vitest unit tests vscode/ — VS Code extension hub/ — Architecture Hub read-only viewer + e2e/ — End-to-end regression suite (Playwright) mcp/ — MCP server (empty for now) web/ — Marketing site (radical.tools) packages/ @@ -66,7 +68,7 @@ packages/ ## Daily workflow (for maintainers) The `main` branch is protected — direct pushes are rejected. All changes go through a PR. -CI (`Type-check & Test`) must pass before merging. +CI (`Type-check & Test` and `E2E`) must pass before merging. Recommended git aliases (set up once globally): diff --git a/README.md b/README.md index b1efaecc..7a59e048 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ apps/ hub/ Architecture Hub (hub.radical.tools): read-only viewer, built on @radical/ui vscode/ VS Code extension; bundles the Studio web build as its webview + e2e/ End-to-end regression suite (Playwright) for the web builds + of Studio and Hub, with screenshot baselines mcp/ MCP server for radical models (empty for now) web/ Marketing site and manual (radical.tools) packages/ @@ -170,8 +172,14 @@ npm test # Watch mode npm run test:watch + +# End-to-end regression suite (Playwright) against the web builds +npm run e2e ``` +See [apps/e2e/README.md](apps/e2e/README.md) for the e2e suite and its +screenshot baselines. + ## Contributing Contributions are welcome: code, docs, bug reports, and new patterns for the [Architecture Hub](https://hub.radical.tools). Start with [CONTRIBUTING.md](CONTRIBUTING.md) and look for issues labelled [`good first issue`](https://github.com/radicaltools/radical.tools/labels/good%20first%20issue). diff --git a/apps/e2e/README.md b/apps/e2e/README.md new file mode 100644 index 00000000..a94294da --- /dev/null +++ b/apps/e2e/README.md @@ -0,0 +1,73 @@ +# @radical/e2e + +End-to-end regression suite for the web builds of Studio and Hub, written +with [Playwright](https://playwright.dev). It drives the production bundles +(`vite preview` of each app's `out/`), so it checks what gets deployed. + +## Running + +From the repo root: + +```bash +npm run e2e # build Studio + Hub, run the suite +npm run e2e -w @radical/e2e # run it against the existing builds +npm run e2e -w @radical/e2e -- -g "undo" # one test by name +npm run e2e:ui -w @radical/e2e # Playwright UI mode +npm run e2e:report -w @radical/e2e # open the last HTML report +``` + +The first time, install Chromium: `npx playwright install chromium`. + +## What it covers + +| Spec | | +|---|---| +| `studio/boot` | Welcome screen, the Fintech sample, a new empty model | +| `studio/editing` | Palette drop, rename, Alt-drag connect, forbidden relation, undo/redo, delete, reload | +| `studio/views` | Static, nested, dynamic and table views; sample treemap, matrix, wiki; Viewer and Presenter perspectives | +| `studio/layout` | Smart Layout: no overlapping siblings, children inside their parent, same result on every run | +| `studio/presentation` | Playing slides, keyboard navigation, slide deep links | +| `studio/export` | PNG and SVG export produce real files | +| `studio/known-issues` | Open bugs, marked `test.fail` (see below) | +| `hub/hub` | Landing page, categories, search, concept canvas / wiki / table, `.radical` download | + +Every test also fails on an uncaught exception or a `console.error`, and +blocks network requests that leave `localhost`. Hub's requests to +`hub.radical.tools` are answered from this build's `out/hub/`, so the suite +tests the catalogue that is about to be deployed. + +Tests start from a known document instead of clicking one together: see +`fixtures/bookstore.radical` and `Studio.seed()` in `support/fixtures.ts`. + +## Screenshots + +Visual checks use `toHaveScreenshot`. Baselines live in +`tests/**/__screenshots__/` and come **only from Linux**, from the +`mcr.microsoft.com/playwright` image that CI runs in: fonts and anti-aliasing +differ between systems, so a baseline made on macOS would never match CI. On +macOS and Windows the screenshot assertions are skipped and everything else +still runs (`E2E_VISUAL=1` forces them, for local experiments only). + +Screenshot tests call `studio.freezeTime()` first. The page clock (timers, +`requestAnimationFrame`) then only moves when the test advances it, so +animations and the live layout render the same frame on every run. + +**Updating baselines** after an intended visual change: add the +`update-screenshots` label to the PR. The `E2E — update screenshots` workflow +regenerates the baselines that changed, commits them to the branch and removes +the label. Check the images in the PR diff, then re-run the CI checks (a bot +push does not start them). + +## Known issues + +`tests/studio/known-issues.spec.ts` holds tests for bugs that are not fixed +yet. Each one asserts the correct behaviour and is marked `test.fail`, so it +passes while the bug is there. When a fix lands, Playwright reports "expected +to fail, but passed": delete the `test.fail` line and the test becomes a +regression guard. + +## Upgrading Playwright + +`@playwright/test` is pinned to an exact version. Bump it together with the +image tag in `.github/workflows/e2e.yml` and `e2e-update-screenshots.yml`, +then regenerate the baselines. diff --git a/apps/e2e/fixtures/bookstore.radical b/apps/e2e/fixtures/bookstore.radical new file mode 100644 index 00000000..0d1e223b --- /dev/null +++ b/apps/e2e/fixtures/bookstore.radical @@ -0,0 +1,67 @@ +{ + "nodes": [ + { "id": "customer", "type": "person", "label": "Customer", + "description": "Buys books online", "x": 355, "y": 0, + "width": 150, "height": 170, "collapsed": false }, + { "id": "bookstore", "type": "system", "label": "Bookstore", + "description": "Online book shop", "x": 0, "y": 270, + "width": 860, "height": 350, "collapsed": false }, + { "id": "web", "type": "webapp", "label": "Web App", "technology": "React", + "description": "Catalogue and checkout UI", "parentId": "bookstore", + "x": 30, "y": 120, "width": 210, "height": 140, "collapsed": false }, + { "id": "api", "type": "container", "label": "API", "technology": "Node.js", + "description": "Orders and catalogue API", "parentId": "bookstore", + "x": 290, "y": 120, "width": 300, "height": 200, "collapsed": false }, + { "id": "db", "type": "database", "label": "Orders DB", "technology": "PostgreSQL", + "description": "Orders and stock", "parentId": "bookstore", + "x": 640, "y": 155, "width": 190, "height": 130, "collapsed": false }, + { "id": "payments", "type": "system", "label": "Payment Provider", "external": true, + "description": "Card payments", "x": 960, "y": 355, + "width": 300, "height": 180, "collapsed": false } + ], + "relations": [ + { "id": "r1", "sourceId": "customer", "targetId": "web", "label": "browses and orders", "technology": "HTTPS" }, + { "id": "r2", "sourceId": "web", "targetId": "api", "label": "calls", "technology": "JSON/HTTPS" }, + { "id": "r3", "sourceId": "api", "targetId": "db", "label": "reads/writes", "technology": "SQL" }, + { "id": "r4", "sourceId": "api", "targetId": "payments", "label": "charges card", "technology": "REST" } + ], + "sequences": [ + { "id": "seq-order", "name": "Place Order", "relationIds": ["r1", "r2", "r3", "r4"], + "stepDescriptions": ["submits the order", "posts the order", "stores the order", "charges the card"] } + ], + "views": [ + { "id": "v-context", "name": "System Context", "kind": "static", + "nodeIds": ["customer", "bookstore", "payments"], + "positions": { + "customer": { "x": 355, "y": 0, "width": 150, "height": 170 }, + "bookstore": { "x": 250, "y": 270, "width": 360, "height": 260 }, + "payments": { "x": 710, "y": 310, "width": 300, "height": 180 } + }, + "hiddenRelationIds": [] }, + { "id": "v-containers", "name": "Containers", "kind": "static", + "nodeIds": ["customer", "bookstore", "web", "api", "db", "payments"], + "positions": { + "customer": { "x": 355, "y": 0, "width": 150, "height": 170 }, + "bookstore": { "x": 0, "y": 270, "width": 860, "height": 350 }, + "web": { "x": 30, "y": 120, "width": 210, "height": 140 }, + "api": { "x": 290, "y": 120, "width": 300, "height": 200 }, + "db": { "x": 640, "y": 155, "width": 190, "height": 130 }, + "payments": { "x": 960, "y": 355, "width": 300, "height": 180 } + }, + "hiddenRelationIds": [] }, + { "id": "v-order", "name": "Place Order Flow", "kind": "dynamic", + "nodeIds": ["customer", "web", "api", "db", "payments"], + "sequenceId": "seq-order", "positions": {}, "hiddenRelationIds": [] }, + { "id": "v-table", "name": "Element Table", "kind": "table", + "nodeIds": [], "positions": {}, "hiddenRelationIds": [] } + ], + "snapshots": [], + "presentations": [ + { "id": "pres-main", "name": "Main", "slides": [ + { "id": "slide-1", "name": "Context", "snapshotId": null, "viewId": "v-context", + "viewport": { "x": 0, "y": 0, "zoom": 1 } }, + { "id": "slide-2", "name": "Containers", "snapshotId": null, "viewId": "v-containers", + "viewport": { "x": 0, "y": 0, "zoom": 1 } } + ] } + ] +} diff --git a/apps/e2e/package.json b/apps/e2e/package.json new file mode 100644 index 00000000..1291bd0f --- /dev/null +++ b/apps/e2e/package.json @@ -0,0 +1,20 @@ +{ + "name": "@radical/e2e", + "version": "0.0.0", + "private": true, + "description": "End-to-end regression suite (Playwright) for the web builds of Studio and Hub.", + "type": "module", + "scripts": { + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "e2e:report": "playwright show-report", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@radical/hub": "*", + "@types/node": "^20.19.39", + "radical-model": "*", + "typescript": "^5.3.3" + } +} diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts new file mode 100644 index 00000000..4de44f91 --- /dev/null +++ b/apps/e2e/playwright.config.ts @@ -0,0 +1,88 @@ +/** + * End-to-end regression suite for the web builds of Studio and Hub. + * + * The suite drives the production bundles (`vite preview` of out/), not the + * dev servers, so it checks what is deployed. Build them first: + * + * npm run e2e (root: builds Studio + Hub, then runs this suite) + * + * Screenshot baselines are Linux-only: they are generated and compared in CI, + * inside the Playwright Docker image, where fonts and rasterisation are fixed. + * On other platforms screenshot assertions are skipped (the functional checks + * still run); set E2E_VISUAL=1 to force them. See README.md. + */ +import { defineConfig, devices } from '@playwright/test' +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' + +const require = createRequire(import.meta.url) +const studioDir = dirname(require.resolve('radical-model/package.json')) +const hubDir = dirname(require.resolve('@radical/hub/package.json')) + +export const STUDIO_PORT = 4310 +export const HUB_PORT = 4311 + +for (const [app, out] of [['Studio', join(studioDir, 'out/renderer/index.html')], ['Hub', join(hubDir, 'out/index.html')]]) { + if (!existsSync(out)) throw new Error(`${app} web build not found (${out}). Run \`npm run build:web && npm run build:hub\` first, or \`npm run e2e\` from the repo root.`) +} + +const CI = !!process.env.CI +const visual = process.platform === 'linux' || !!process.env.E2E_VISUAL + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: CI, + retries: CI ? 2 : 0, + workers: CI ? 2 : undefined, + reporter: CI ? [['github'], ['html', { open: 'never' }]] : [['list'], ['html', { open: 'never' }]], + timeout: 60_000, + + ignoreSnapshots: !visual, + // One baseline per test and name, no platform suffix: baselines only ever + // come from the Linux image. + snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', + expect: { + toHaveScreenshot: { + animations: 'disabled', + caret: 'hide', + scale: 'css', + // Canvas edges are anti-aliased; tolerate a sliver of noise, never a + // moved node or a missing label. + maxDiffPixelRatio: 0.005, + }, + }, + + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + locale: 'en-US', + timezoneId: 'UTC', + colorScheme: 'dark', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + projects: [ + { name: 'studio', testDir: './tests/studio', use: { baseURL: `http://localhost:${STUDIO_PORT}` } }, + { name: 'hub', testDir: './tests/hub', use: { baseURL: `http://localhost:${HUB_PORT}` } }, + ], + + webServer: [ + { + command: `npm run preview:web -- --port ${STUDIO_PORT} --strictPort`, + cwd: studioDir, + url: `http://localhost:${STUDIO_PORT}/`, + reuseExistingServer: !CI, + }, + { + command: `npm run preview -- --port ${HUB_PORT} --strictPort`, + cwd: hubDir, + url: `http://localhost:${HUB_PORT}/`, + reuseExistingServer: !CI, + }, + ], +}) diff --git a/apps/e2e/support/fixtures.ts b/apps/e2e/support/fixtures.ts new file mode 100644 index 00000000..5523b694 --- /dev/null +++ b/apps/e2e/support/fixtures.ts @@ -0,0 +1,261 @@ +import { test as base, expect, type Browser, type Locator, type Page } from '@playwright/test' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { layoutViolations, type Box } from './geometry' + +/** Id of the seeded localStorage document (`radical-doc:` in Studio). */ +export const DOC_ID = 'e2e-doc' + +export function fixture(name: string): string { + return readFileSync(fileURLToPath(new URL(`../fixtures/${name}.radical`, import.meta.url)), 'utf8') +} + +type Mode = 'designer' | 'viewer' | 'presenter' | 'metamodel' + +/** The slice of a saved `.radical` document the tests read back. */ +export interface StoredDoc { + nodes: { id: string; label: string; parentId?: string }[] + relations: { id: string; sourceId: string; targetId: string }[] + views?: { id: string; nodeIds: string[]; positions: Record }[] +} + +/** Studio driven through its real UI; state reads go through localStorage. */ +export class Studio { + private frozen = false + + constructor(readonly page: Page) {} + + /** + * Stop the page clock (timers, requestAnimationFrame, Date) before the app + * loads; time then moves only through `advance()`. Screenshot tests use + * this so animations and the live layout render the same frame on every + * run. Call before the first navigation. + */ + async freezeTime(): Promise { + await this.page.clock.install({ time: new Date('2026-01-01T09:00:00Z') }) + await this.page.clock.pauseAt(new Date('2026-01-01T09:00:01Z')) + this.frozen = true + } + + /** Move a frozen clock forward (no-op on a real clock). */ + async advance(ms: number): Promise { + if (this.frozen) await this.page.clock.runFor(ms) + } + + /** + * Wait for something that depends on the network (real time) and on the + * app's timers (page clock). On a frozen clock this steps one frame at a + * time, so whatever renders next starts from the same frame on every run. + */ + async until(target: Locator, timeout = 15_000): Promise { + if (this.frozen) { + const end = Date.now() + timeout + while (!(await target.first().isVisible()) && Date.now() < end) { + await this.advance(16) + await this.page.waitForTimeout(10) + } + } + await expect(target.first()).toBeVisible({ timeout }) + } + + get nodes(): Locator { return this.page.locator('.react-flow__node') } + get edges(): Locator { return this.page.locator('.react-flow__edge') } + get pane(): Locator { return this.page.locator('.react-flow__pane') } + get canvas(): Locator { return this.page.locator('.canvas-area') } + + node(id: string): Locator { return this.page.getByTestId(`rf__node-${id}`) } + + /** A canvas node by its exact visible label. */ + nodeByLabel(label: string): Locator { + return this.nodes.filter({ has: this.page.getByText(label, { exact: true }) }) + } + + /** + * Seed a fixture as the active document before the app boots. Only on the + * first load of the context, so a reload shows what the app saved. + */ + async seed(name = 'bookstore'): Promise { + await this.seedDocument(fixture(name)) + } + + /** Seed raw `.radical` JSON (e.g. a document saved by another page). */ + async seedDocument(json: string): Promise { + await this.page.addInitScript(([id, data]) => { + if (localStorage.getItem('radical-docs-index')) return + localStorage.setItem('radical-docs-index', JSON.stringify({ + docs: [{ id, name: 'E2E', source: 'ls', lastModified: 0 }], + activeId: id, + })) + localStorage.setItem(`radical-doc:${id}`, data) + }, [DOC_ID, json] as const) + } + + /** Open a view of the seeded document through a deep link. */ + async open(view: string, mode: Mode = 'designer', docId = DOC_ID): Promise { + await this.page.goto(`/#/d/${encodeURIComponent(`ls:${docId}`)}/m/${mode}/v/${view}`) + await this.ready() + } + + /** Open the bundled Fintech sample from the welcome screen, then `view`. */ + async openSample(view = 'view-ctx'): Promise { + await this.page.goto('/') + await this.page.getByRole('button', { name: /Explore the sample/ }).click() + await this.advance(1000) + await expect(this.page).toHaveURL(/\/v\/view-ctx$/) + await this.ready() + if (view !== 'view-ctx') await this.open(view, 'designer', this.activeDocId()) + } + + /** The app has booted (no welcome splash, no crash) and the canvas is still. */ + async ready(): Promise { + await expect(this.page.locator('.app-layout')).toBeVisible() + await this.settle() + await expect(this.page.getByText('Runtime Error')).toHaveCount(0) + } + + /** + * Wait until the canvas camera stops moving (auto-fit and view switches + * animate the viewport in JS, which `animations: 'disabled'` cannot stop). + */ + async settle(): Promise { + if (this.frozen) { + await this.advance(3000) + await this.page.evaluate(() => document.fonts.ready) + return + } + if (!(await this.canvas.count())) return + const viewport = this.page.locator('.react-flow__viewport') + let prev = '' + let stable = 0 + for (let i = 0; i < 60 && stable < 3; i++) { + const cur = await viewport.getAttribute('style') ?? '' + stable = cur === prev ? stable + 1 : 0 + prev = cur + await this.page.waitForTimeout(100) + } + await this.page.evaluate(() => document.fonts.ready) + } + + async smartLayout(): Promise { + await this.page.getByRole('button', { name: 'Smart Layout', exact: true }).click() + // Done when the ranking report appears, or when Smart Layout says it + // kept the current layout (or found nothing), which has no report. + const done = this.page.getByTitle(/^Why this layout\?/).or(this.page.getByText(/^Smart layout: /)) + if (this.frozen) { + for (let i = 0; i < 400 && !(await done.first().isVisible()); i++) await this.advance(100) + } + await expect(done.first()).toBeVisible({ timeout: 30_000 }) + await this.settle() + } + + /** Every rendered node's canvas position and size, by node id. */ + async positions(): Promise> { + return this.nodes.evaluateAll((els) => Object.fromEntries(els.map((el) => { + const e = el as HTMLElement + return [e.dataset.id!, `${e.style.transform} ${e.offsetWidth}x${e.offsetHeight}`] + }))) + } + + /** Alt-drag from one node to another: Studio's connect gesture. */ + async connect(from: Locator, to: Locator): Promise { + await this.settle() // a moving camera would drop the gesture off target + const a = (await from.boundingBox())! + const b = (await to.boundingBox())! + await this.page.keyboard.down('Alt') + await this.page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) + await this.page.mouse.down() + await this.page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 8 }) + await this.page.mouse.up() + await this.page.keyboard.up('Alt') + } + + /** Drag an element type from the Elements palette onto the canvas. */ + async addFromPalette(typeLabel: string, at: { x: number; y: number }): Promise { + await this.page.locator('.palette-item', { hasText: typeLabel }).first() + .dragTo(this.pane, { targetPosition: at }) + } + + /** The document as the app last saved it to localStorage (raw JSON). */ + async storedJson(docId = DOC_ID): Promise { + const json = await this.page.evaluate((key) => localStorage.getItem(key), `radical-doc:${docId}`) + if (!json) throw new Error(`no saved document ${docId}`) + return json + } + + /** The document as the app last saved it to localStorage. */ + async storedDoc(docId = DOC_ID): Promise { + return JSON.parse(await this.storedJson(docId)) + } + + /** Every rendered node's screen box, by node id. */ + async boxes(): Promise> { + return this.nodes.evaluateAll((els) => Object.fromEntries(els.map((el) => { + const r = el.getBoundingClientRect() + return [(el as HTMLElement).dataset.id!, { x: r.x, y: r.y, width: r.width, height: r.height }] + }))) + } + + /** Overlapping siblings and escaped children on the current canvas. */ + async layoutViolations(docId = DOC_ID): Promise { + const doc = await this.storedDoc(docId) + const parentOf = Object.fromEntries(doc.nodes.map((n) => [n.id, n.parentId])) + return layoutViolations(await this.boxes(), parentOf) + } + + /** Id of the active document (the sample gets a fresh uuid per context). */ + activeDocId(): string { + const m = this.page.url().match(/#\/d\/([^/]+)/) + return m ? decodeURIComponent(m[1]).replace(/^ls:/, '') : DOC_ID + } +} + +/** Console messages that are expected and harmless. Keep this list short. */ +const ALLOWED_CONSOLE_ERRORS: RegExp[] = [ + // External requests are blocked on purpose (see below). + /net::ERR_FAILED/, +] + +/** + * Block requests beyond the app itself (tests stay hermetic and web fonts + * cannot change a screenshot) and collect uncaught exceptions and console + * errors, which is often where a regression shows up first. + */ +async function guard(page: Page): Promise { + const problems: string[] = [] + page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)) + page.on('console', (m) => { + if (m.type() === 'error' && !ALLOWED_CONSOLE_ERRORS.some((re) => re.test(m.text()))) { + problems.push(`console.error: ${m.text()}`) + } + }) + await page.route(/^https?:\/\/(?!localhost[:/])/, (route) => route.abort()) + return problems +} + +/** + * A Studio in its own browser context (own localStorage), set up like the + * `studio` fixture. Check `problems` before `close()`. + */ +export async function isolatedStudio(browser: Browser): Promise<{ studio: Studio; problems: string[]; close: () => Promise }> { + const { viewport, deviceScaleFactor, locale, timezoneId, colorScheme, baseURL, userAgent } = base.info().project.use + const context = await browser.newContext({ viewport, deviceScaleFactor, locale, timezoneId, colorScheme, baseURL, userAgent }) + const page = await context.newPage() + const problems = await guard(page) + return { studio: new Studio(page), problems, close: () => context.close() } +} + +export const test = base.extend<{ studio: Studio; guard: void }>({ + guard: [async ({ page }, use, testInfo) => { + const problems = await guard(page) + await use() + if (testInfo.status === testInfo.expectedStatus) { + expect(problems, 'uncaught errors / console errors during the test').toEqual([]) + } + }, { auto: true }], + + studio: async ({ page }, use) => { + await use(new Studio(page)) + }, +}) + +export { expect } diff --git a/apps/e2e/support/geometry.ts b/apps/e2e/support/geometry.ts new file mode 100644 index 00000000..124cab53 --- /dev/null +++ b/apps/e2e/support/geometry.ts @@ -0,0 +1,51 @@ +// Layout invariants checked on the rendered canvas (screen-space boxes, so +// they hold at any zoom). A layout that breaks one of these is visibly wrong +// whatever its score: nodes on top of each other, or children spilling out +// of the boundary that is supposed to contain them. + +export interface Box { x: number; y: number; width: number; height: number } + +/** Sub-pixel rounding in transforms; anything above this is a real overlap. */ +const EPS = 1 + +function intersects(a: Box, b: Box): boolean { + return a.x + a.width - EPS > b.x && b.x + b.width - EPS > a.x && + a.y + a.height - EPS > b.y && b.y + b.height - EPS > a.y +} + +function contains(outer: Box, inner: Box): boolean { + return inner.x >= outer.x - EPS && inner.y >= outer.y - EPS && + inner.x + inner.width <= outer.x + outer.width + EPS && + inner.y + inner.height <= outer.y + outer.height + EPS +} + +/** + * Pairs of sibling nodes (same parent, both rendered) whose boxes overlap, + * and nodes that are not fully inside their rendered parent. + */ +export function layoutViolations( + boxes: Record, + parentOf: Record, +): string[] { + const ids = Object.keys(boxes) + const out: string[] = [] + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const a = ids[i], b = ids[j] + if (visibleParent(a, boxes, parentOf) !== visibleParent(b, boxes, parentOf)) continue + if (intersects(boxes[a], boxes[b])) out.push(`overlap: ${a} ↔ ${b}`) + } + } + for (const id of ids) { + const p = visibleParent(id, boxes, parentOf) + if (p && !contains(boxes[p], boxes[id])) out.push(`outside parent: ${id} ⊄ ${p}`) + } + return out +} + +/** Nearest ancestor that is rendered in this view (views may skip levels). */ +function visibleParent(id: string, boxes: Record, parentOf: Record): string | undefined { + let p = parentOf[id] + while (p && !boxes[p]) p = parentOf[p] + return p +} diff --git a/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/category-blueprints.png b/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/category-blueprints.png new file mode 100644 index 00000000..8398ebb5 Binary files /dev/null and b/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/category-blueprints.png differ diff --git a/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/concept-canvas.png b/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/concept-canvas.png new file mode 100644 index 00000000..43fb9f16 Binary files /dev/null and b/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/concept-canvas.png differ diff --git a/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/concept-table.png b/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/concept-table.png new file mode 100644 index 00000000..01b2e98f Binary files /dev/null and b/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/concept-table.png differ diff --git a/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/concept-wiki.png b/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/concept-wiki.png new file mode 100644 index 00000000..d0d1348a Binary files /dev/null and b/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/concept-wiki.png differ diff --git a/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/landing.png b/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/landing.png new file mode 100644 index 00000000..f5b7ddfe Binary files /dev/null and b/apps/e2e/tests/hub/__screenshots__/hub.spec.ts/landing.png differ diff --git a/apps/e2e/tests/hub/hub.spec.ts b/apps/e2e/tests/hub/hub.spec.ts new file mode 100644 index 00000000..562363dd --- /dev/null +++ b/apps/e2e/tests/hub/hub.spec.ts @@ -0,0 +1,103 @@ +import { readFile } from 'node:fs/promises' +import type { Page } from '@playwright/test' +import { test, expect } from '../../support/fixtures' + +// Hub (hub.radical.tools): the concept catalogue and its read-only viewer. +// Expected counts come from the catalogue this build publishes, so adding a +// concept does not break the suite. + +interface Concept { id: string; category: string; name: string } + +const CONCEPT = 'bp-fintech-ledger-platform' +const CONCEPT_NAME = 'Fintech Ledger & Payments Platform' + +async function catalogue(page: Page): Promise { + const res = await page.request.get('hub/index.json') + expect(res.ok()).toBe(true) + return res.json() +} + +test.beforeEach(async ({ page, baseURL, studio }) => { + // A production Hub build reads the catalogue from hub.radical.tools. Serve + // those requests from this build's own out/hub/, as JSON the way the CDN + // does, so the suite tests the catalogue that is about to be deployed. + await page.route('https://hub.radical.tools/hub/**', async (route) => { + const local = new URL(new URL(route.request().url()).pathname.slice(1), baseURL) + const response = await route.fetch({ url: local.href }) + await route.fulfill({ response, headers: { ...response.headers(), 'content-type': 'application/json' } }) + }) + await studio.freezeTime() +}) + +test('landing page', async ({ page, studio }) => { + const all = await catalogue(page) + await page.goto('/') + await studio.until(page.getByRole('button', { name: 'Browse the catalogue' })) + for (const [label, category] of [['Patterns', 'pattern'], ['ADRs', 'adr'], ['Blueprints', 'blueprint']]) { + const count = all.filter((c) => c.category === category).length + await expect(page.getByRole('button', { name: new RegExp(`^${label}\\s*${count}\\b`) })).toBeVisible() + } + await studio.settle() + await expect(page).toHaveScreenshot('landing.png') +}) + +test('browse a category and search', async ({ page, studio }) => { + const all = await catalogue(page) + const blueprints = all.filter((c) => c.category === 'blueprint') + await page.goto('/') + await studio.until(page.getByRole('button', { name: /^Blueprints/ })) + await page.getByRole('button', { name: /^Blueprints/ }).click() + await studio.until(page.getByText(`${blueprints.length} of ${all.length}`)) + await expect(page).toHaveURL(/#\/cat\/blueprint/) + for (const c of blueprints) await expect(page.getByText(c.name, { exact: true })).toBeVisible() + await studio.settle() + await expect(page).toHaveScreenshot('category-blueprints.png') + + await page.getByPlaceholder('Search concepts…').fill('ledger') + await studio.advance(500) + await expect(page.getByText(CONCEPT_NAME, { exact: true })).toBeVisible() + await expect(page.getByText('AI Support Assistant Platform', { exact: true })).toHaveCount(0) +}) + +test('open a concept from the catalogue', async ({ page, studio }) => { + await page.goto('/#/browse') + await studio.until(page.getByText(CONCEPT_NAME, { exact: true })) + await page.getByText(CONCEPT_NAME, { exact: true }).click() + await studio.until(studio.nodes) + await expect(page).toHaveURL(new RegExp(`#/c/${CONCEPT}`)) +}) + +for (const view of ['canvas', 'wiki', 'table'] as const) { + test(`concept ${view} view`, async ({ page, studio }) => { + const concept = await (await page.request.get(`hub/blueprint/${CONCEPT}.radical`)).json() + await page.goto(`/#/c/${CONCEPT}/v/${view}`) + await studio.until(page.getByText(concept.hub.name, { exact: true })) + if (view === 'canvas') { + await studio.until(studio.nodes) + await expect(studio.nodes).toHaveCount(concept.nodes.length) + } + if (view === 'table') await expect(page.locator('tbody tr')).toHaveCount(concept.nodes.length) + await studio.settle() + await expect(page).toHaveScreenshot(`concept-${view}.png`) + }) +} + +test('download a concept as a .radical file', async ({ page, studio }) => { + await page.goto(`/#/c/${CONCEPT}/v/canvas`) + await studio.until(studio.nodes) + const download = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download' }).click() + await studio.advance(500) + const file = await download + expect(file.suggestedFilename()).toMatch(/\.radical$/) + const doc = JSON.parse(await readFile(await file.path(), 'utf8')) + expect(doc.hub.id).toBe(CONCEPT) + expect(doc.nodes.length).toBeGreaterThan(0) + expect(doc.relations.length).toBeGreaterThan(0) +}) + +test('an unknown concept link falls back to the catalogue', async ({ page, studio }) => { + await page.goto('/#/c/no-such-concept') + await studio.until(page.getByPlaceholder('Search concepts…')) + await expect(studio.nodes).toHaveCount(0) +}) diff --git a/apps/e2e/tests/studio/__screenshots__/boot.spec.ts/sample-context.png b/apps/e2e/tests/studio/__screenshots__/boot.spec.ts/sample-context.png new file mode 100644 index 00000000..e25fe2d4 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/boot.spec.ts/sample-context.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/boot.spec.ts/welcome.png b/apps/e2e/tests/studio/__screenshots__/boot.spec.ts/welcome.png new file mode 100644 index 00000000..2e04b695 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/boot.spec.ts/welcome.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-bookstore-containers.png b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-bookstore-containers.png new file mode 100644 index 00000000..9d3e9bf4 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-bookstore-containers.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-canvas.png b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-canvas.png new file mode 100644 index 00000000..32acba39 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-canvas.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-core.png b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-core.png new file mode 100644 index 00000000..5258ae38 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-core.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-ctx.png b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-ctx.png new file mode 100644 index 00000000..5acc2f43 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-ctx.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-payments.png b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-payments.png new file mode 100644 index 00000000..ff58f9d5 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-payments.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-screens.png b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-screens.png new file mode 100644 index 00000000..b601b830 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-screens.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-trace.png b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-trace.png new file mode 100644 index 00000000..f3ebd737 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/layout.spec.ts/smart-layout-sample-view-trace.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/presentation.spec.ts/slide-1.png b/apps/e2e/tests/studio/__screenshots__/presentation.spec.ts/slide-1.png new file mode 100644 index 00000000..dbca955d Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/presentation.spec.ts/slide-1.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/presentation.spec.ts/slide-2.png b/apps/e2e/tests/studio/__screenshots__/presentation.spec.ts/slide-2.png new file mode 100644 index 00000000..33d50f6c Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/presentation.spec.ts/slide-2.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-containers.png b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-containers.png new file mode 100644 index 00000000..fc61ac9f Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-containers.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-context.png b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-context.png new file mode 100644 index 00000000..aaf42bb1 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-context.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-order-flow.png b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-order-flow.png new file mode 100644 index 00000000..385c09bb Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-order-flow.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-table.png b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-table.png new file mode 100644 index 00000000..7bddebc7 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/bookstore-table.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-core.png b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-core.png new file mode 100644 index 00000000..4b22f15a Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-core.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-matrix.png b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-matrix.png new file mode 100644 index 00000000..996804f9 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-matrix.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-screens.png b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-screens.png new file mode 100644 index 00000000..43abbfdf Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-screens.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-treemap.png b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-treemap.png new file mode 100644 index 00000000..b6fc57a7 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-treemap.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-wiki.png b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-wiki.png new file mode 100644 index 00000000..fca74811 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/sample-view-wiki.png differ diff --git a/apps/e2e/tests/studio/__screenshots__/views.spec.ts/viewer-containers.png b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/viewer-containers.png new file mode 100644 index 00000000..c2bc45f4 Binary files /dev/null and b/apps/e2e/tests/studio/__screenshots__/views.spec.ts/viewer-containers.png differ diff --git a/apps/e2e/tests/studio/boot.spec.ts b/apps/e2e/tests/studio/boot.spec.ts new file mode 100644 index 00000000..e5931615 --- /dev/null +++ b/apps/e2e/tests/studio/boot.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '../../support/fixtures' + +test.describe('first visit', () => { + test('shows the welcome screen', async ({ page, studio }) => { + await studio.freezeTime() + await page.goto('/') + await studio.settle() + await expect(page.getByRole('button', { name: /Explore the sample/ })).toBeVisible() + await expect(page.getByRole('button', { name: 'New model' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Open file…' })).toBeVisible() + // The URL stays clean until the user picks a model. + expect(new URL(page.url()).hash).toBe('') + await expect(page).toHaveScreenshot('welcome.png') + }) + + test('the sample opens on its System Context view', async ({ page, studio }) => { + await studio.freezeTime() + await studio.openSample() + await expect(page.getByText('Structure: System Context')).toBeVisible() + await expect(studio.nodes).toHaveCount(8) + await expect(studio.edges).toHaveCount(7) + await expect(studio.nodeByLabel('Core Banking Platform')).toBeVisible() + await expect(page).toHaveScreenshot('sample-context.png') + }) + + test('a new model starts empty', async ({ page, studio }) => { + await page.goto('/') + await page.getByRole('button', { name: 'New model' }).click() + await studio.ready() + await expect(studio.nodes).toHaveCount(0) + await expect(page.getByPlaceholder(/Search 0 nodes/)).toBeVisible() + }) +}) diff --git a/apps/e2e/tests/studio/editing.spec.ts b/apps/e2e/tests/studio/editing.spec.ts new file mode 100644 index 00000000..ff04473c --- /dev/null +++ b/apps/e2e/tests/studio/editing.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '../../support/fixtures' + +// Editing on the flat System Context view of the bookstore fixture +// (Customer, Bookstore, Payment Provider; 2 relations shown). +test.beforeEach(async ({ studio }) => { + await studio.seed() + await studio.open('v-context') + await expect(studio.nodes).toHaveCount(3) + await expect(studio.edges).toHaveCount(2) +}) + +test('add an element from the palette', async ({ studio }) => { + await studio.addFromPalette('Software System', { x: 150, y: 750 }) + await expect(studio.nodes).toHaveCount(4) + await expect(studio.nodeByLabel('System')).toBeVisible() + await expect.poll(async () => (await studio.storedDoc()).nodes.length).toBe(7) +}) + +test('rename an element in the properties panel', async ({ page, studio }) => { + await studio.node('payments').click() + // The panel's