From 0d7bd6d84f2ae69850730f25a73f30925bfd16cf Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Tue, 29 Sep 2026 12:40:45 +0200 Subject: [PATCH 1/4] test(e2e): add a Playwright regression suite for Studio and Hub New workspace apps/e2e drives the production web builds (vite preview of out/) through their real UI: boot and sample, editing, every view kind, perspectives, presentations, export, Smart Layout invariants and Hub. Screenshot baselines are Linux-only (Playwright image) and taken on a frozen page clock so animations and the live layout render the same frame on every run. Each test fails on uncaught exceptions or console errors and blocks requests that leave localhost; Hub's catalogue requests are served from the build's own out/hub/. known-issues.spec.ts records bugs found while writing the suite as test.fail, so they turn red once fixed. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 5 + apps/e2e/README.md | 73 ++++++ apps/e2e/fixtures/bookstore.radical | 67 ++++++ apps/e2e/package.json | 20 ++ apps/e2e/playwright.config.ts | 88 +++++++ apps/e2e/support/fixtures.ts | 261 +++++++++++++++++++++ apps/e2e/support/geometry.ts | 51 ++++ apps/e2e/tests/hub/hub.spec.ts | 103 ++++++++ apps/e2e/tests/studio/boot.spec.ts | 33 +++ apps/e2e/tests/studio/editing.spec.ts | 73 ++++++ apps/e2e/tests/studio/export.spec.ts | 34 +++ apps/e2e/tests/studio/known-issues.spec.ts | 42 ++++ apps/e2e/tests/studio/layout.spec.ts | 68 ++++++ apps/e2e/tests/studio/presentation.spec.ts | 42 ++++ apps/e2e/tests/studio/views.spec.ts | 98 ++++++++ apps/e2e/tsconfig.json | 9 + apps/studio/package.json | 1 + package-lock.json | 60 +++++ package.json | 1 + tools/check-workspaces.mjs | 4 + 20 files changed, 1133 insertions(+) create mode 100644 apps/e2e/README.md create mode 100644 apps/e2e/fixtures/bookstore.radical create mode 100644 apps/e2e/package.json create mode 100644 apps/e2e/playwright.config.ts create mode 100644 apps/e2e/support/fixtures.ts create mode 100644 apps/e2e/support/geometry.ts create mode 100644 apps/e2e/tests/hub/hub.spec.ts create mode 100644 apps/e2e/tests/studio/boot.spec.ts create mode 100644 apps/e2e/tests/studio/editing.spec.ts create mode 100644 apps/e2e/tests/studio/export.spec.ts create mode 100644 apps/e2e/tests/studio/known-issues.spec.ts create mode 100644 apps/e2e/tests/studio/layout.spec.ts create mode 100644 apps/e2e/tests/studio/presentation.spec.ts create mode 100644 apps/e2e/tests/studio/views.spec.ts create mode 100644 apps/e2e/tsconfig.json 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/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/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/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