diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 00000000..a8d29355 --- /dev/null +++ b/.env.test.example @@ -0,0 +1,42 @@ +# GraphDone test-pipeline configuration — LOCAL ONLY. +# +# Copy this file to `.env.test.local` (which is gitignored) and fill in your +# own values. NEVER put real hostnames, IPs, or keys in this committed example +# or anywhere else in the repo — the local VLM boxes (GPU workstations) must +# stay out of version control. The test harness auto-loads `.env.test.local`. +# +# cp .env.test.example .env.test.local # then edit .env.test.local + +# --- Local Vision-Language-Model (VLM) endpoints --------------------------- +# Comma-separated base URLs of your local VLM server(s). Requests are +# round-robined across them so visual evaluation is spread over every GPU. +# Leave blank to skip all VLM-driven suites (they no-op cleanly in CI). +# Example shape (use your OWN hosts in .env.test.local, never here): +# VLM_ENDPOINTS=http://:,http://: +VLM_ENDPOINTS= + +# Model id/tag to request (e.g. a llava / qwen2-vl / llama-3.2-vision build). +VLM_MODEL= + +# Optional bearer key for OpenAI-compatible servers that require one. +VLM_API_KEY= + +# Wire protocol: auto (default) | openai | ollama. +# auto — probe each endpoint: /v1/models => OpenAI-compatible, else Ollama. +# openai — POST /v1/chat/completions (vLLM, LM Studio, llama.cpp, Ollama compat) +# ollama — POST /api/chat with an images[] array +VLM_PROTOCOL=auto + +# Max concurrent VLM requests across all endpoints (default 3). +VLM_MAX_CONCURRENCY=3 + +# Per-request timeout in ms — VLMs can be slow on large images (default 120000). +VLM_TIMEOUT_MS=120000 + +# --- Large-scale performance sweep ----------------------------------------- +# Node counts to sweep, comma-separated. Leave blank to use the built-in +# default (small in CI, large locally). Example: 50,200,500,1000,2000 +SCALE_SWEEP_SIZES= + +# Quality tiers to sweep per size (subset of LOW,MEDIUM,HIGH,ULTRA). +SCALE_SWEEP_QUALITIES=HIGH,ULTRA diff --git a/docs/SYSTEMS.md b/docs/SYSTEMS.md index 42cdc71e..129e5774 100644 --- a/docs/SYSTEMS.md +++ b/docs/SYSTEMS.md @@ -19,6 +19,8 @@ | Lint | `npm run lint` | 0 errors (warnings allowed) | | Build | `npm run build` | Production build succeeds | | Showcase report | `TEST_URL=http://localhost:3127 npm run report:showcase` | Records .webm video + screenshots of every mode at all 5 resolutions → `test-artifacts/showcase/index.html` (also an every-PR CI artifact). | +| Large-scale perf sweep | `TEST_URL=http://localhost:3127 npm run test:perf:scale` | Seeds graphs of increasing size (50→2000+ nodes) and records `window.__graphPerf` (settle, tick, fps, drift, query p95) across size × quality → `test-artifacts/scale-sweep/index.html`. Report-only; sizes/qualities via `.env.test.local`. See [docs/testing/local-vlm-and-scale.md](./testing/local-vlm-and-scale.md). | +| Local VLM visual review | `TEST_URL=http://localhost:3127 npm run test:vlm` | A locally-hosted vision model judges captured states from 4 perspectives (visual defects, new-user clarity, accessibility, living-graph aliveness) → `test-artifacts/vlm/index.html`. **Skips unless `VLM_ENDPOINTS` is set in the gitignored `.env.test.local`** (CI can't reach local GPUs). Report-only. | **Why THE GATE exists:** a real incident — orphaned `Edge` records made the edges query 500 and the UI showed "Error" with zero edges, while every unit diff --git a/docs/testing/local-vlm-and-scale.md b/docs/testing/local-vlm-and-scale.md new file mode 100644 index 00000000..320a5c04 --- /dev/null +++ b/docs/testing/local-vlm-and-scale.md @@ -0,0 +1,113 @@ +# Local VLM visual review & large-scale performance sweeps + +Two heavier, report-only suites that exercise GraphDone from realistic user +perspectives and at scale. Both are **opt-in and run locally** (or on a +self-hosted runner) because the vision models live on your own GPU boxes — +their hostnames must never enter the repo. + +## TL;DR + +```bash +cp .env.test.example .env.test.local # gitignored — put your real values here +# edit .env.test.local: VLM_ENDPOINTS, VLM_MODEL, (optional) sweep sizes + +./start dev # or have the stack running on :3127 + +TEST_URL=http://localhost:3127 npm run test:perf:scale # → test-artifacts/scale-sweep/index.html +TEST_URL=http://localhost:3127 npm run test:vlm # → test-artifacts/vlm/index.html +``` + +If `VLM_ENDPOINTS` is unset, `test:vlm` **skips cleanly** — so CI and other +developers are never blocked by hardware they don't have. + +## Keeping hostnames out of the repo + +- **Never** commit hostnames, IPs, or keys. The GPU boxes (e.g. an RTX 4090 + workstation and Grace-Blackwell nodes) are referenced only by env vars. +- `.env.test.local` is gitignored (see `.gitignore`). It is the *only* place + your real endpoints live. +- `.env.test.example` is committed and documents the variable **names** with + placeholder hosts (`http://:`). Copy it to `.env.test.local` + and fill in the rest. +- The harness auto-loads `.env.test.local` via `tests/helpers/testEnv.ts`. + +```bash +# .env.test.local (NOT committed) +VLM_ENDPOINTS=http://:,http://:,http://: +VLM_MODEL= +VLM_PROTOCOL=auto # auto | openai | ollama +VLM_MAX_CONCURRENCY=3 +``` + +Multiple endpoints are **round-robined**, so visual evaluation spreads across +every GPU you list. + +## VLM protocol support + +`tests/helpers/vlm.ts` is protocol-agnostic and auto-detects per endpoint: + +| Protocol | Detected via | Request | +|----------|--------------|---------| +| OpenAI-compatible | `GET /v1/models` | `POST /v1/chat/completions` with an `image_url` data URI (vLLM, LM Studio, llama.cpp server, Ollama's `/v1` shim) | +| Ollama native | `GET /api/tags` | `POST /api/chat` with a base64 `images[]` array | + +Force one with `VLM_PROTOCOL=openai` or `ollama`. Each model call asks for a +strict JSON verdict `{pass, score, issues[], summary}`, parsed leniently. + +### Personas + +Each captured screenshot is judged from several perspectives (see `PERSONAS` +in `tests/helpers/vlm.ts`): + +- **Visual defects** — overlapping/cut-off nodes, unreadable labels, broken + layout, missing edges, error chrome. +- **New-user clarity** — is the screen legible and inviting to a newcomer? +- **Accessibility** — contrast, text size, color-only signals, target size. +- **Living-graph aliveness** — do glow/breathe/flow status cues read clearly? + +Report-only: a **FLAG** is the model's subjective concern, surfaced for a human +to look at — it never fails the build. The suite *does* assert the model +answered, so a broken client is still caught. + +## Large-scale perf sweep + +`tests/perf/scale-sweep.spec.ts` seeds real graphs (via the GraphQL API, the +same path a human/AI uses) of increasing size, loads each at one or more +quality tiers, and records the in-app `window.__graphPerf` readings plus load +time, settle time and query latency. + +```bash +# .env.test.local +SCALE_SWEEP_SIZES=50,200,500,1000,2000 # blank => small in CI, large locally +SCALE_SWEEP_QUALITIES=HIGH,ULTRA +``` + +Metrics per (size, quality): + +- **Reliable (measured directly from the browser, captured at every size):** + rendered node/edge counts, initial load ms, graph-scoped query p95, and + **interaction FPS** — real rendered frames/sec while a node is dragged + (counted via `requestAnimationFrame`, so it needs no app instrumentation and + reflects how janky the graph feels under interaction at scale). +- **Best-effort bonus (from the app's `window.__graphPerf`, which only + publishes ~every 2s while the sim ticks):** settle ms (to `alpha ≤ 0.02`), + avg/p95 sim tick ms, layout drift (`rmsFromSavedPx`). These can be blank for + graphs that settle instantly — `interactionFps` is the headline signal. + +A FRESH graph is seeded per (size, quality) so each measurement starts from an +unsettled layout (otherwise the second quality loads the first run's settled, +pinned positions and the sim never ticks). Output: +`test-artifacts/scale-sweep/index.html` — a table plus inline SVG charts of how +each metric scales, with the `@perf` budgets drawn for reference. + +Report-only; the only hard assertion is that a seeded graph actually renders. +Each seeded graph is deleted afterward (edges first, then nodes, then graph). + +## CI + +GitHub-hosted runners can't reach your local GPUs, so neither suite gates +merges there. To gate on them, register a **self-hosted runner** on a machine +that can reach the endpoints, give it the `.env.test.local`, and add a workflow +job (manual-dispatch or nightly) that runs `npm run test:perf:scale` / +`npm run test:vlm`. The scale sweep alone (no VLM) is safe to run on any runner +with the dev stack and a small `SCALE_SWEEP_SIZES`. diff --git a/package-lock.json b/package-lock.json index 41026237..5be30855 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1367,7 +1367,6 @@ }, "node_modules/@babel/runtime": { "version": "7.28.4", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3373,11 +3372,28 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/express": { "version": "4.17.23", "license": "MIT", @@ -3410,6 +3426,15 @@ "version": "7946.0.16", "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "license": "MIT" @@ -3432,13 +3457,21 @@ "version": "4.0.2", "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "license": "MIT" }, "node_modules/@types/ms": { "version": "2.1.0", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -3525,9 +3558,14 @@ "@types/passport": "*" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", - "devOptional": true, "license": "MIT" }, "node_modules/@types/qs": { @@ -3540,7 +3578,6 @@ }, "node_modules/@types/react": { "version": "18.3.24", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3555,6 +3592,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/semver": { "version": "7.7.1", "dev": true, @@ -3584,6 +3631,12 @@ "@types/node": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.8", "license": "MIT" @@ -3827,7 +3880,6 @@ }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", - "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { @@ -4470,6 +4522,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "license": "MIT" @@ -4837,6 +4899,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "4.5.0", "dev": true, @@ -4868,6 +4940,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "1.0.3", "dev": true, @@ -4958,6 +5070,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "7.2.0", "license": "MIT", @@ -5092,7 +5214,6 @@ }, "node_modules/csstype": { "version": "3.1.3", - "devOptional": true, "license": "MIT" }, "node_modules/d3": { @@ -5529,6 +5650,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "license": "MIT", @@ -5653,6 +5787,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "license": "MIT", @@ -5668,6 +5811,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "license": "Apache-2.0" @@ -6322,6 +6478,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "dev": true, @@ -6497,6 +6663,12 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -6561,6 +6733,19 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "funding": [ @@ -6718,6 +6903,14 @@ "node": ">= 6" } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "license": "MIT", @@ -7208,6 +7401,91 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "license": "BSD-3-Clause", @@ -7231,6 +7509,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "license": "BSD-2-Clause", @@ -7379,6 +7667,12 @@ "version": "1.3.8", "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "dev": true, @@ -7413,6 +7707,30 @@ "node": ">= 0.10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arguments": { "version": "1.2.0", "dev": true, @@ -7555,6 +7873,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "license": "MIT", @@ -7610,6 +7938,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-lambda": { "version": "1.0.1", "license": "MIT", @@ -7674,6 +8012,18 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "dev": true, @@ -8189,6 +8539,16 @@ "version": "4.0.0", "license": "Apache-2.0" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "license": "MIT", @@ -8207,6 +8567,20 @@ "get-func-name": "^2.0.1" } }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "7.18.3", "license": "ISC", @@ -8334,6 +8708,16 @@ "node": ">=10" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", @@ -8341,44 +8725,889 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.0.30", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/media-typer": { - "version": "0.3.0", + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/merge-descriptors": { - "version": "1.0.3", + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", + "engines": { + "node": ">=12" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/methods": { + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { "version": "1.1.2", "license": "MIT", - "engines": { - "node": ">= 0.6" + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "license": "MIT", @@ -9056,6 +10285,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "dev": true, @@ -9567,6 +10821,15 @@ "dev": true, "license": "MIT" }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/promise-inflight": { "version": "1.0.1", "license": "ISC", @@ -9601,6 +10864,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "license": "MIT", @@ -9761,6 +11034,33 @@ "version": "16.13.1", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "dev": true, @@ -9797,6 +11097,26 @@ "react-dom": ">=16.8" } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "license": "MIT", @@ -9859,6 +11179,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "dev": true, @@ -9894,6 +11230,72 @@ } } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "dev": true, @@ -10476,6 +11878,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/sqlite3": { "version": "5.1.7", "hasInstallScript": true, @@ -10658,6 +12070,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "license": "MIT", @@ -10740,6 +12166,24 @@ ], "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.0", "license": "MIT", @@ -11070,6 +12514,26 @@ "node": ">=18" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "dev": true, @@ -11393,6 +12857,25 @@ "version": "6.21.0", "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unique-filename": { "version": "1.1.1", "license": "ISC", @@ -11409,6 +12892,74 @@ "imurmurhash": "^0.1.4" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "0.2.0", "dev": true, @@ -11513,6 +13064,34 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.20", "dev": true, @@ -12076,6 +13655,16 @@ } } }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "packages/core": { "name": "@graphdone/core", "version": "0.3.1-alpha", @@ -13394,7 +14983,10 @@ "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.20.0", + "react-syntax-highlighter": "^16.1.1", + "remark-gfm": "^4.0.1", "tailwindcss": "^3.3.0", "zustand": "^4.4.0" }, @@ -13405,6 +14997,7 @@ "@testing-library/react": "^14.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^8.39.1", "@typescript-eslint/parser": "^8.39.1", "@vitejs/plugin-react": "^4.1.0", diff --git a/package.json b/package.json index 3c442d84..f363cb8b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,11 @@ "test:smoke": "playwright test tests/e2e/user-smoke.spec.ts --reporter=line", "report:showcase": "playwright test --project=showcase && node tests/generate-showcase-report.mjs", "test:perf": "playwright test --project=perf --reporter=line", + "test:perf:scale": "playwright test --project=perf-scale --reporter=line && node tests/generate-perf-report.mjs", + "report:perf": "node tests/generate-perf-report.mjs", + "test:vlm": "playwright test --project=vlm --reporter=line && node tests/generate-vlm-report.mjs", + "test:geometry": "playwright test --project=geometry --reporter=line", + "report:vlm": "node tests/generate-vlm-report.mjs", "perf:bundle": "node tests/perf/check-bundle-size.mjs" }, "devDependencies": { diff --git a/packages/server/package.json b/packages/server/package.json index 27ebdce3..67600201 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -15,7 +15,8 @@ "clean": "rm -rf dist coverage", "db:seed": "tsx src/scripts/seed.ts", "create-admin": "tsx src/scripts/create-admin.ts", - "create-welcome-graphs": "tsx src/scripts/create-welcome-graphs.ts" + "create-welcome-graphs": "tsx src/scripts/create-welcome-graphs.ts", + "create-hierarchy-demo": "tsx src/scripts/create-hierarchy-demo.ts" }, "dependencies": { "@apollo/server": "^4.9.0", diff --git a/packages/server/src/schema/neo4j-schema.ts b/packages/server/src/schema/neo4j-schema.ts index 9fd47e16..f05642ce 100644 --- a/packages/server/src/schema/neo4j-schema.ts +++ b/packages/server/src/schema/neo4j-schema.ts @@ -313,6 +313,8 @@ export const typeDefs = gql` workItems: [WorkItem!]! @relationship(type: "BELONGS_TO", direction: IN) subgraphs: [Graph!]! @relationship(type: "PARENT_OF", direction: OUT) parentGraph: Graph @relationship(type: "PARENT_OF", direction: IN) + # WorkItems (sheet symbols) that drill into this graph + drillSources: [WorkItem!]! @relationship(type: "DRILLS_INTO", direction: IN) } # WorkItem entity - represents work items in the graph @@ -333,12 +335,17 @@ export const typeDefs = gql` dueDate: DateTime tags: [String!] metadata: String # JSON as string - + # If set, this node is a "sheet symbol" that drills into another graph + # (Altium-style hierarchy). Denormalized id for cheap client-side branching. + subgraphId: String + createdAt: DateTime! @timestamp(operations: [CREATE]) updatedAt: DateTime! @timestamp # Relationships owner: User @relationship(type: "OWNS", direction: IN) + # The sub-graph this node drills into (sheet symbol -> sub-sheet) + subgraph: Graph @relationship(type: "DRILLS_INTO", direction: OUT) assignedTo: User @relationship(type: "ASSIGNED_TO", direction: IN) graph: Graph @relationship(type: "BELONGS_TO", direction: OUT) dependencies: [WorkItem!]! @relationship(type: "DEPENDS_ON", direction: OUT) diff --git a/packages/server/src/scripts/create-hierarchy-demo.ts b/packages/server/src/scripts/create-hierarchy-demo.ts new file mode 100644 index 00000000..a2bdd691 --- /dev/null +++ b/packages/server/src/scripts/create-hierarchy-demo.ts @@ -0,0 +1,34 @@ +import { driver } from '../db.js'; +import { createHierarchyDemo, hierarchyDemoExists, deleteHierarchyDemo } from '../services/hierarchyDemo.js'; + +async function ensureHierarchyDemo() { + const force = process.argv.includes('--force'); + console.log(`🏗️ Ensuring hierarchical "graphs of graphs" demo exists${force ? ' (force reseed)' : ''}...\n`); + try { + if (force && (await hierarchyDemoExists(driver))) { + await deleteHierarchyDemo(driver); + } + if (!force && (await hierarchyDemoExists(driver))) { + console.log('⏭️ Hierarchy demo already exists - skipping creation\n'); + } else { + const r = await createHierarchyDemo(driver); + console.log('\n========================================'); + console.log(' Hierarchy Demo Created'); + console.log('========================================'); + console.log(`• Graphs: ${r.graphs} (1 overview + sub-graphs)`); + console.log(`• Work items: ${r.nodes}`); + console.log(`• Edges: ${r.edges}`); + console.log('• Shared: Yes (guest + all users, read-only)'); + console.log('• Open "System Overview" and click a node to drill in'); + console.log('========================================\n'); + } + } catch (error: any) { + console.error('❌ Failed to create hierarchy demo:', error); + await driver.close(); + process.exit(1); + } + await driver.close(); + process.exit(0); +} + +ensureHierarchyDemo(); diff --git a/packages/server/src/services/hierarchyDemo.ts b/packages/server/src/services/hierarchyDemo.ts new file mode 100644 index 00000000..16f634d4 --- /dev/null +++ b/packages/server/src/services/hierarchyDemo.ts @@ -0,0 +1,276 @@ +import { Driver } from 'neo4j-driver'; + +/** + * Guest-visible demo of an Altium-style HIERARCHY of graphs ("graphs of + * graphs"). A top OVERVIEW graph holds sheet-symbol nodes; each drills into a + * sub-graph (via the WorkItem.subgraph / DRILLS_INTO relationship). The + * hierarchy is the level-of-detail strategy — any single view renders one graph + * (a few dozen sheets at the overview, up to ~1000 in the perf showcase + * sub-graph), never all ~2600 nodes at once. + * + * Everything is createdBy:'system' + isShared:true so the GUEST account sees it. + * Idempotent and NON-destructive: it only creates if the overview graph is + * absent (unlike scripts/seed.ts which wipes the DB). Edges are canonical Edge + * nodes with both EDGE_SOURCE and EDGE_TARGET (no orphan edges). + */ + +export const OVERVIEW_GRAPH_ID = 'overview-graph-shared'; + +const NODE_TYPES = ['TASK', 'FEATURE', 'BUG', 'MILESTONE', 'OUTCOME', 'IDEA']; +const STATUSES = ['NOT_STARTED', 'PROPOSED', 'PLANNED', 'IN_PROGRESS', 'BLOCKED', 'COMPLETED']; +const EDGE_TYPES = ['DEPENDS_ON', 'BLOCKS', 'ENABLES', 'RELATES_TO']; + +// Subsystems = sub-graphs. One large "Compute Core" (~1000 nodes) is the +// high-performance / LOD showcase; the rest are varied mid-size graphs. +interface Subsystem { key: string; name: string; size: number; } +const SUBSYSTEMS: Subsystem[] = [ + { key: 'compute', name: 'Compute Core', size: 1000 }, + { key: 'power', name: 'Power Management', size: 90 }, + { key: 'clocking', name: 'Clocking & PLL', size: 110 }, + { key: 'memctl', name: 'Memory Controller', size: 150 }, + { key: 'ddrphy', name: 'DDR PHY', size: 130 }, + { key: 'pcie', name: 'PCIe Root Complex', size: 160 }, + { key: 'usb', name: 'USB Subsystem', size: 120 }, + { key: 'ethernet', name: 'Ethernet MAC', size: 140 }, + { key: 'display', name: 'Display Pipeline', size: 170 }, + { key: 'audio', name: 'Audio Codec', size: 90 }, + { key: 'security', name: 'Security Enclave', size: 100 }, + { key: 'thermal', name: 'Thermal & Sensors', size: 110 }, + { key: 'ioexp', name: 'I/O Expander', size: 95 }, + { key: 'firmware', name: 'Firmware & Boot', size: 130 }, + { key: 'telemetry', name: 'Telemetry & Logging', size: 120 }, + { key: 'fabric', name: 'Interconnect Fabric', size: 180 }, +]; + +interface NodeRow { id: string; type: string; title: string; description: string; status: string; priority: number; x: number; y: number; } +interface EdgeRow { id: string; s: string; t: string; type: string; weight: number; } + +/** Grid positions centered on the origin so nodes load pinned in a real layout. */ +function gridPositions(n: number, spacing: number): Array<{ x: number; y: number }> { + const cols = Math.ceil(Math.sqrt(n)); + const half = (cols * spacing) / 2; + return Array.from({ length: n }, (_, i) => ({ + x: (i % cols) * spacing - half, + y: Math.floor(i / cols) * spacing - half, + })); +} + +/** A connected sub-graph: backbone chain + deterministic forward links (~1.4x). */ +function buildSubgraph(graphId: string, size: number): { nodes: NodeRow[]; edges: EdgeRow[] } { + // Spacing must exceed the node-card collision diameter (~224px) so the seeded + // layout is non-overlapping on load — a clean starting state with no physics + // needed. (140 produced "garbage piles".) + const pos = gridPositions(size, 260); + const nodes: NodeRow[] = Array.from({ length: size }, (_, i) => { + const type = NODE_TYPES[(i * 7) % NODE_TYPES.length]; + return { + id: `${graphId}-n${i}`, + type, + title: `${type} ${i}`, + description: '', + status: STATUSES[i % STATUSES.length], + priority: ((i * 37) % 100) / 100, + x: pos[i].x, + y: pos[i].y, + }; + }); + + const edges: EdgeRow[] = []; + const link = (a: string, b: string, t: string) => + edges.push({ id: `${graphId}-e${edges.length}`, s: a, t: b, type: t, weight: 0.5 + (edges.length % 5) / 10 }); + for (let i = 0; i + 1 < size; i++) link(nodes[i].id, nodes[i + 1].id, 'DEPENDS_ON'); + let extra = Math.round(size * 1.4) - edges.length; + for (let i = 0; i < size && extra > 0; i++) { + const jump = 2 + ((i * 5) % Math.max(2, Math.floor(size / 4))); + const j = i + jump; + if (j < size) { link(nodes[i].id, nodes[j].id, EDGE_TYPES[i % EDGE_TYPES.length]); extra--; } + } + return { nodes, edges }; +} + +function chunk(arr: T[], n: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n)); + return out; +} + +/** Demo graph ids (overview + every sub-graph) — for a clean force-reseed. */ +function demoGraphIds(): string[] { + return [OVERVIEW_GRAPH_ID, ...SUBSYSTEMS.map((s) => `subgraph-${s.key}-shared`)]; +} + +/** Tear down the demo (edges → work items → graphs, in that order so we never + * leave orphan edges). Used by the --force reseed path. */ +export async function deleteHierarchyDemo(driver: Driver): Promise { + const session = driver.session(); + const ids = demoGraphIds(); + try { + await session.run( + `UNWIND $ids AS gid + MATCH (g:Graph {id: gid})<-[:BELONGS_TO]-(w:WorkItem) + OPTIONAL MATCH (w)<-[:EDGE_SOURCE|EDGE_TARGET]-(e:Edge) + DETACH DELETE e`, + { ids } + ); + await session.run( + `UNWIND $ids AS gid + MATCH (g:Graph {id: gid})<-[:BELONGS_TO]-(w:WorkItem) + DETACH DELETE w`, + { ids } + ); + await session.run(`UNWIND $ids AS gid MATCH (g:Graph {id: gid}) DETACH DELETE g`, { ids }); + console.log(`🗑️ Removed previous hierarchy demo (${ids.length} graphs)`); + } finally { + await session.close(); + } +} + +export async function hierarchyDemoExists(driver: Driver): Promise { + const session = driver.session(); + try { + const r = await session.run(`MATCH (g:Graph {id: $id}) RETURN count(g) > 0 AS exists`, { id: OVERVIEW_GRAPH_ID }); + return r.records[0]?.get('exists') ?? false; + } finally { + await session.close(); + } +} + +async function createGraphNode(session: any, params: { + id: string; name: string; description: string; type: string; depth: number; path: string[]; parentGraphId: string | null; nodeCount: number; edgeCount: number; tags: string[]; +}) { + await session.run( + `CREATE (g:Graph { + id: $id, name: $name, description: $description, type: $type, status: 'ACTIVE', + teamId: null, createdBy: 'system', tags: $tags, defaultRole: 'VIEWER', + parentGraphId: $parentGraphId, depth: $depth, path: $path, isShared: true, + nodeCount: $nodeCount, edgeCount: $edgeCount, contributorCount: 0, + lastActivity: datetime(), settings: '{}', + permissions: '{"public":"read","authenticated":"read"}', + shareSettings: '{"public":true,"readOnly":true}', + createdAt: datetime(), updatedAt: datetime() + })`, + params + ); +} + +async function insertNodesAndEdges(session: any, graphId: string, nodes: NodeRow[], edges: EdgeRow[]) { + for (const batch of chunk(nodes, 500)) { + await session.run( + `MATCH (g:Graph {id: $graphId}) + UNWIND $nodes AS n + CREATE (w:WorkItem { + id: n.id, type: n.type, title: n.title, description: n.description, status: n.status, + positionX: toFloat(n.x), positionY: toFloat(n.y), positionZ: 0.0, + radius: 1.0, theta: 0.0, phi: 0.0, priority: toFloat(n.priority), priorityComp: 0.0, + tags: [], metadata: '{}', createdAt: datetime(), updatedAt: datetime() + }) + CREATE (w)-[:BELONGS_TO]->(g)`, + { graphId, nodes: batch } + ); + } + for (const batch of chunk(edges, 700)) { + await session.run( + `UNWIND $edges AS ed + MATCH (s:WorkItem {id: ed.s}), (t:WorkItem {id: ed.t}) + CREATE (e:Edge { id: ed.id, type: ed.type, weight: toFloat(ed.weight), metadata: '{}', createdAt: datetime() }) + CREATE (e)-[:EDGE_SOURCE]->(s) + CREATE (e)-[:EDGE_TARGET]->(t)`, + { edges: batch } + ); + } +} + +export async function createHierarchyDemo(driver: Driver): Promise<{ graphs: number; nodes: number; edges: number }> { + const session = driver.session(); + let totalNodes = 0; + let totalEdges = 0; + try { + console.log('🏗️ Building hierarchical "graphs of graphs" demo...'); + + // 1) Build + populate each sub-graph. + for (const sub of SUBSYSTEMS) { + const subId = `subgraph-${sub.key}-shared`; + const { nodes, edges } = buildSubgraph(subId, sub.size); + await createGraphNode(session, { + id: subId, + name: sub.name, + description: `${sub.name} — a sub-sheet of the System Overview (${sub.size} work items).`, + type: 'SUBGRAPH', + depth: 1, + path: [OVERVIEW_GRAPH_ID], + parentGraphId: OVERVIEW_GRAPH_ID, + nodeCount: nodes.length, + edgeCount: edges.length, + tags: ['demo', 'subgraph', sub.key], + }); + await insertNodesAndEdges(session, subId, nodes, edges); + totalNodes += nodes.length; + totalEdges += edges.length; + console.log(` • ${sub.name}: ${nodes.length} nodes / ${edges.length} edges`); + } + + // 2) Overview graph: one sheet symbol per subsystem. + const sheetPos = gridPositions(SUBSYSTEMS.length, 320); + const sheets = SUBSYSTEMS.map((sub, i) => ({ + id: `${OVERVIEW_GRAPH_ID}-sheet-${sub.key}`, + subgraphId: `subgraph-${sub.key}-shared`, + title: sub.name, + description: `Drill in to open the ${sub.name} sub-graph (${sub.size} work items).`, + x: sheetPos[i].x, + y: sheetPos[i].y, + })); + // Inter-sheet "wires": backbone chain + a few cross links (both endpoints in overview). + const sheetEdges: EdgeRow[] = []; + const wire = (a: string, b: string, t: string) => + sheetEdges.push({ id: `${OVERVIEW_GRAPH_ID}-w${sheetEdges.length}`, s: a, t: b, type: t, weight: 0.8 }); + for (let i = 0; i + 1 < sheets.length; i++) wire(sheets[i].id, sheets[i + 1].id, 'DEPENDS_ON'); + for (let i = 0; i + 3 < sheets.length; i += 3) wire(sheets[i].id, sheets[i + 3].id, 'RELATES_TO'); + + await createGraphNode(session, { + id: OVERVIEW_GRAPH_ID, + name: 'System Overview', + description: 'Top-level overview — each node is a sub-sheet. Click a node to drill into its sub-graph (Altium-style hierarchy).', + type: 'PROJECT', + depth: 0, + path: [], + parentGraphId: null, + nodeCount: sheets.length, + edgeCount: sheetEdges.length, + tags: ['demo', 'overview', 'hierarchy'], + }); + + // Sheet WorkItems with subgraphId + DRILLS_INTO to their sub-graph. + await session.run( + `MATCH (g:Graph {id: $overviewId}) + UNWIND $sheets AS sh + MATCH (sub:Graph {id: sh.subgraphId}) + CREATE (w:WorkItem { + id: sh.id, type: 'OUTCOME', title: sh.title, description: sh.description, status: 'IN_PROGRESS', + positionX: toFloat(sh.x), positionY: toFloat(sh.y), positionZ: 0.0, + radius: 1.0, theta: 0.0, phi: 0.0, priority: 0.8, priorityComp: 0.0, + tags: ['sheet'], metadata: '{}', subgraphId: sh.subgraphId, + createdAt: datetime(), updatedAt: datetime() + }) + CREATE (w)-[:BELONGS_TO]->(g) + CREATE (w)-[:DRILLS_INTO]->(sub) + CREATE (g)-[:PARENT_OF]->(sub)`, + { overviewId: OVERVIEW_GRAPH_ID, sheets } + ); + // Inter-sheet wires. + await session.run( + `UNWIND $edges AS ed + MATCH (s:WorkItem {id: ed.s}), (t:WorkItem {id: ed.t}) + CREATE (e:Edge { id: ed.id, type: ed.type, weight: toFloat(ed.weight), metadata: '{}', createdAt: datetime() }) + CREATE (e)-[:EDGE_SOURCE]->(s) + CREATE (e)-[:EDGE_TARGET]->(t)`, + { edges: sheetEdges } + ); + totalNodes += sheets.length; + totalEdges += sheetEdges.length; + + console.log(`✅ Hierarchy demo: ${SUBSYSTEMS.length + 1} graphs, ${totalNodes} work items, ${totalEdges} edges`); + return { graphs: SUBSYSTEMS.length + 1, nodes: totalNodes, edges: totalEdges }; + } finally { + await session.close(); + } +} diff --git a/packages/server/src/services/onboarding-template.test.ts b/packages/server/src/services/onboarding-template.test.ts new file mode 100644 index 00000000..9c3e1a73 --- /dev/null +++ b/packages/server/src/services/onboarding-template.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { WELCOME_NODES, WELCOME_EDGES } from './onboarding.js'; + +/** + * The Welcome graph is the first thing every new user sees, so it must model + * the layout rules the app enforces everywhere else: + * - every node is "placed" (pinned) so the seed positions are authoritative + * and don't drift (a node at exactly (0,0) is treated as unplaced/unpinned); + * - simple template relationships are ORTHOGONAL — every edge is purely + * horizontal or vertical (the user's stated preference for simple graphs); + * - connected nodes sit far enough apart for the edge label to fit with only + * a small margin (the min-edge-length rule), not crammed together. + */ + +// Card footprint (≈ getNodeDimensions) + the min-edge math used by the renderer. +const CARD_W = 170; +const CARD_H = 106; +const LABEL_W = 90; // a typical relationship label ("Depends On") width +const LABEL_MARGIN = 14; + +// Per-axis half-extent the card covers along a horizontal / vertical edge. +const reachH = CARD_W / 2; // 85 +const reachV = CARD_H / 2; // 53 + +describe('Welcome onboarding template', () => { + it('places every node (no node pinned at the unplaced origin)', () => { + for (const n of WELCOME_NODES) { + const atOrigin = n.positionX === 0 && n.positionY === 0; + expect(atOrigin, `"${n.title}" sits at (0,0) and would be unpinned/drift`).toBe(false); + } + }); + + it('draws every edge orthogonally (horizontal or vertical)', () => { + for (const e of WELCOME_EDGES) { + const s = WELCOME_NODES[e.sourceIndex]; + const t = WELCOME_NODES[e.targetIndex]; + const dx = Math.abs(s.positionX - t.positionX); + const dy = Math.abs(s.positionY - t.positionY); + const orthogonal = dx < 1 || dy < 1; + expect( + orthogonal, + `edge ${e.sourceIndex}->${e.targetIndex} ("${s.title}"->"${t.title}") is diagonal: dx=${dx} dy=${dy}` + ).toBe(true); + } + }); + + it('spaces connected nodes so the edge label fits with a small margin', () => { + for (const e of WELCOME_EDGES) { + const s = WELCOME_NODES[e.sourceIndex]; + const t = WELCOME_NODES[e.targetIndex]; + const dx = Math.abs(s.positionX - t.positionX); + const dy = Math.abs(s.positionY - t.positionY); + const horizontal = dy < 1; + const centerDist = horizontal ? dx : dy; + const reach = horizontal ? reachH : reachV; + const gap = centerDist - 2 * reach; + expect( + gap, + `edge ${e.sourceIndex}->${e.targetIndex} gap ${gap.toFixed(0)}px < label ${LABEL_W}+${LABEL_MARGIN}` + ).toBeGreaterThanOrEqual(LABEL_W + LABEL_MARGIN); + } + }); + + it('keeps the graph connected (every node touched by an edge)', () => { + const touched = new Set(); + for (const e of WELCOME_EDGES) { + touched.add(e.sourceIndex); + touched.add(e.targetIndex); + } + for (let i = 0; i < WELCOME_NODES.length; i++) { + expect(touched.has(i), `"${WELCOME_NODES[i].title}" has no edges`).toBe(true); + } + }); + + it('routes no edge straight through a non-endpoint node', () => { + for (const e of WELCOME_EDGES) { + const s = WELCOME_NODES[e.sourceIndex]; + const t = WELCOME_NODES[e.targetIndex]; + const horizontal = Math.abs(s.positionY - t.positionY) < 1; + for (let i = 0; i < WELCOME_NODES.length; i++) { + if (i === e.sourceIndex || i === e.targetIndex) continue; + const n = WELCOME_NODES[i]; + const onLine = horizontal + ? Math.abs(n.positionY - s.positionY) < 1 && + n.positionX > Math.min(s.positionX, t.positionX) && + n.positionX < Math.max(s.positionX, t.positionX) + : Math.abs(n.positionX - s.positionX) < 1 && + n.positionY > Math.min(s.positionY, t.positionY) && + n.positionY < Math.max(s.positionY, t.positionY); + expect(onLine, `edge ${e.sourceIndex}->${e.targetIndex} passes through "${n.title}"`).toBe(false); + } + } + }); +}); diff --git a/packages/server/src/services/onboarding.ts b/packages/server/src/services/onboarding.ts index 2cf843ed..99ddbec2 100644 --- a/packages/server/src/services/onboarding.ts +++ b/packages/server/src/services/onboarding.ts @@ -25,7 +25,7 @@ export async function sharedWelcomeGraphExists(driver: Driver): Promise } } -interface OnboardingNode { +export interface OnboardingNode { title: string; description: string; type: string; @@ -35,13 +35,13 @@ interface OnboardingNode { positionZ: number; } -interface OnboardingEdge { +export interface OnboardingEdge { sourceIndex: number; targetIndex: number; type: string; } -const WELCOME_NODES: OnboardingNode[] = [ +export const WELCOME_NODES: OnboardingNode[] = [ { title: 'Welcome to GraphDone!', description: `# Welcome to GraphDone! 🎉 @@ -62,8 +62,8 @@ GraphDone is a graph-native project management system that reimagines how work f This is your workspace - feel free to edit, delete, or reorganize these tutorial nodes as you learn!`, type: 'DOCUMENTATION', status: 'COMPLETED', - positionX: 0, - positionY: 0, + positionX: -180, + positionY: -240, positionZ: 0 }, { @@ -80,8 +80,8 @@ Each work item has: Try creating a work item right now!`, type: 'TASK', status: 'NOT_STARTED', - positionX: -200, - positionY: 150, + positionX: -180, + positionY: 0, positionZ: 0 }, { @@ -102,8 +102,8 @@ To create a dependency: Dependencies determine priority - the more dependents a node has, the higher its priority becomes.`, type: 'TASK', status: 'NOT_STARTED', - positionX: 200, - positionY: 150, + positionX: 180, + positionY: 0, positionZ: 0 }, { @@ -125,8 +125,8 @@ Dependencies determine priority - the more dependents a node has, the higher its The graph is alive - it reorganizes as you add dependencies and complete work!`, type: 'DOCUMENTATION', status: 'COMPLETED', - positionX: 0, - positionY: 300, + positionX: 180, + positionY: 240, positionZ: 0 }, { @@ -144,8 +144,8 @@ Switch between views using the mode buttons at the top center of the screen. Each view presents the same underlying graph data in different ways - choose what works best for your current task!`, type: 'DOCUMENTATION', status: 'COMPLETED', - positionX: -200, - positionY: -150, + positionX: -180, + positionY: 240, positionZ: 0 }, { @@ -170,21 +170,19 @@ Remember: In GraphDone, work flows through natural dependencies, not artificial You can always come back to this Welcome graph for reference, or delete it when you're ready.`, type: 'MILESTONE', status: 'NOT_STARTED', - positionX: 200, - positionY: -150, + positionX: 180, + positionY: 480, positionZ: 0 } ]; -const WELCOME_EDGES: OnboardingEdge[] = [ - { sourceIndex: 1, targetIndex: 0, type: 'DEPENDS_ON' }, - { sourceIndex: 2, targetIndex: 0, type: 'DEPENDS_ON' }, - { sourceIndex: 3, targetIndex: 0, type: 'RELATES_TO' }, - { sourceIndex: 4, targetIndex: 0, type: 'RELATES_TO' }, - { sourceIndex: 5, targetIndex: 1, type: 'DEPENDS_ON' }, - { sourceIndex: 5, targetIndex: 2, type: 'DEPENDS_ON' }, - { sourceIndex: 5, targetIndex: 3, type: 'DEPENDS_ON' }, - { sourceIndex: 5, targetIndex: 4, type: 'DEPENDS_ON' } +export const WELCOME_EDGES: OnboardingEdge[] = [ + { sourceIndex: 1, targetIndex: 0, type: 'RELATES_TO' }, + { sourceIndex: 2, targetIndex: 1, type: 'DEPENDS_ON' }, + { sourceIndex: 3, targetIndex: 2, type: 'DEPENDS_ON' }, + { sourceIndex: 4, targetIndex: 1, type: 'RELATES_TO' }, + { sourceIndex: 4, targetIndex: 3, type: 'RELATES_TO' }, + { sourceIndex: 5, targetIndex: 3, type: 'DEPENDS_ON' } ]; export async function createSharedWelcomeGraph(driver: Driver): Promise { diff --git a/packages/web/package.json b/packages/web/package.json index bfb7084e..77a85d78 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -25,7 +25,10 @@ "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.20.0", + "react-syntax-highlighter": "^16.1.1", + "remark-gfm": "^4.0.1", "tailwindcss": "^3.3.0", "zustand": "^4.4.0" }, @@ -36,6 +39,7 @@ "@testing-library/react": "^14.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^8.39.1", "@typescript-eslint/parser": "^8.39.1", "@vitejs/plugin-react": "^4.1.0", diff --git a/packages/web/src/components/CodeCaptcha.tsx b/packages/web/src/components/CodeCaptcha.tsx index 861adc87..b02dd7f8 100644 --- a/packages/web/src/components/CodeCaptcha.tsx +++ b/packages/web/src/components/CodeCaptcha.tsx @@ -418,8 +418,12 @@ export function CodeCaptcha({ {/* Math Problem or Canvas Code Image */}
{currentStyle === 'math' ? ( -
-

What is:

+ // The "What is:" label floats at the top so the NUMBER is the + // vertically-centered element — otherwise the label pushed the + // number below the panel center and the lone refresh button + // (panel-centered) sat above it. +
+ What is:

{mathProblem} = ?

diff --git a/packages/web/src/components/GraphSelector.tsx b/packages/web/src/components/GraphSelector.tsx index 8bad8734..978f485a 100644 --- a/packages/web/src/components/GraphSelector.tsx +++ b/packages/web/src/components/GraphSelector.tsx @@ -16,7 +16,16 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph }: Gra const { currentGraph, graphHierarchy, selectGraph } = useGraph(); const { currentTeam, currentUser } = useAuth(); const [isOpen, setIsOpen] = useState(false); - const [expandedFolders, setExpandedFolders] = useState>(new Set(['team', 'personal'])); + const [expandedFolders, setExpandedFolders] = useState>(new Set(['team', 'personal', 'system'])); + // Which parent graphs are expanded to show their sub-graphs (the hierarchy). + const [expandedGraphs, setExpandedGraphs] = useState>(new Set()); + const toggleGraphExpand = (id: string) => { + setExpandedGraphs((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; const [buttonPosition, setButtonPosition] = useState<{ top: number; left: number; width: number } | null>(null); const [hoveredTooltip, setHoveredTooltip] = useState<{ message: string; x: number; y: number } | null>(null); const dropdownRef = useRef(null); @@ -310,7 +319,17 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph }: Gra {isExpanded && (
{graphs.map((graph) => ( -
+
+
+ {graph.children && graph.children.length > 0 && ( + + )}
+ {graph.children && graph.children.length > 0 && expandedGraphs.has(graph.id) && ( +
+ {graph.children.map((child: any) => ( + + ))} +
+ )} +
))}
)} diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 23ac750a..26435cd8 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -46,6 +46,7 @@ import { mergeSimulationNodes, mergeSimulationEdges } from '../lib/graphDataMerg import { edgeLabelPlacement, clearSegment, slideTFromPointer, chooseLabelT } from '../lib/edgeLabelLayout'; import { PerfMeter, DriftMeter } from '../lib/perfMeter'; import { DEFAULT_PHYSICS, collisionRadius, linkDistance, linkMaxDistance, linkStrength } from '../lib/physicsConfig'; +import { edgeBorderEndpoints, minEdgeLength, clampToMinNeighbors } from '../lib/edgeGeometry'; import { spawnCelebration } from '../lib/celebration'; import { buildNeighborhood } from '../lib/graphAdjacency'; import { UndoStack } from '../lib/undoStack'; @@ -58,6 +59,25 @@ const LOD_THRESHOLDS = { CLOSE: 1.0, }; +// Above this node count a graph is "dense": the continuous living-graph effects +// (breathing/ache/flow animations + per-node drop-shadow halos) are gated off +// via data-dense regardless of the quality tier, because repainting that many +// filtered layers each frame collapses FPS. Below it, the full aesthetic stays. +const DENSE_GRAPH_NODE_THRESHOLD = 150; + +// Below this zoom scale on a dense graph, per-node detail (text, icons, status/ +// priority bars) is unreadable, so it is hidden outright (data-simplify) — each +// node renders as just its colored card. This is the dominant win for the +// whole-graph view, where every element is on screen and painted each frame. +const SIMPLIFY_SCALE = 0.45; + +// Below this (much smaller) scale a dense graph is in "dot mode": edges are +// sub-pixel hairlines and nodes are tiny, so edges are hidden entirely and their +// per-tick positioning skipped. This roughly halves the painted element count +// (edges are ~half of what's left after simplify), which is the only lever that +// helps the paint-bound whole-graph pan/zoom. Edges return when you zoom past it. +const DOT_SCALE = 0.2; + // Utility functions const getSmoothedOpacity = (scale: number, threshold: number, fadeRange: number = 0.2) => { if (scale >= threshold + fadeRange) return 1; @@ -84,12 +104,27 @@ interface DragState { interface InteractiveGraphVisualizationProps { onResetLayout?: () => void; + /** Notifies the host (Workspace) which node is selected, so a docked + * inspector can show its contents/diagram. Fires null on deselect. */ + onNodeSelected?: (node: WorkItem | null) => void; } -export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGraphVisualizationProps = {}) { +export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: InteractiveGraphVisualizationProps = {}) { const svgRef = useRef(null); const containerRef = useRef(null); - const { currentGraph, availableGraphs } = useGraph(); + const { currentGraph, availableGraphs, descendInto } = useGraph(); + // descendInto from context isn't memoized; hold the latest in a ref so the + // D3-bound node click handler can call it without re-binding every render. + const descendIntoRef = useRef(descendInto); + // Mirrors isSimplified for the d3 tick closure (which captures stale render + // values otherwise). Lets updateEdgePositions skip hidden arrow/label work. + const simplifiedRef = useRef(false); + // Dot mode (extreme zoom-out): edges are hidden, so skip their per-tick work. + const dotModeRef = useRef(false); + descendIntoRef.current = descendInto; + // The inline-rename overlay tracks its node live (drag/tick/zoom) via rAF, + // because its position derives from currentTransform which only updates on zoom. + const inlineEditRef = useRef(null); const { currentUser } = useAuth(); const { showSuccess, showError } = useNotifications(); const navigate = useNavigate(); @@ -276,6 +311,12 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const [showUpdateGraphModal, setShowUpdateGraphModal] = useState(false); const [showDeleteGraphModal, setShowDeleteGraphModal] = useState(false); const [selectedNode, setSelectedNode] = useState(null); + // Lift selection to the host (Workspace) for the docked inspector. One effect + // captures every path that changes selectedNode (node click, edit icon, + // background-click deselect) without instrumenting each call site. + useEffect(() => { + onNodeSelected?.(selectedNode); + }, [selectedNode, onNodeSelected]); const lastSelectedNodeRef = useRef(null); // Track last selected node for centering const [selectedEdge, setSelectedEdge] = useState(null); const [createNodePosition, setCreateNodePosition] = useState<{ x: number; y: number; z: number } | undefined>(undefined); @@ -958,7 +999,13 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Close menus when clicking outside or pressing ESC useEffect(() => { - const handleClickOutside = () => { + const handleClickOutside = (event: MouseEvent) => { + // Clicks inside the docked inspector (a sibling tree) must not deselect + // the node — otherwise its own Card/Contents/Diagram controls close it. + const target = event.target as Element | null; + if (target && target.closest('[data-testid="node-inspector"]')) { + return; + } setNodeMenu(prev => ({ ...prev, visible: false })); setEdgeMenu(prev => ({ ...prev, visible: false })); setEditingEdge(null); // Close inline edge editor @@ -1036,6 +1083,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap setIsConnecting(false); setConnectionSource(null); } else { + // A plain click SELECTS the node (opens the inspector). Descending into a + // sheet node's sub-graph is an explicit action — the descend glyph (⤢) on + // the card or the inspector's "Open" — so clicking never navigates you + // away unexpectedly (the user loses context otherwise). // Handle node selection with 2-item ring buffer setSelectedNodes(prev => { const newSet = new Set(prev); @@ -1197,14 +1248,17 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap let x = item.positionX; let y = item.positionY; - // If node has never been positioned (0,0) and has no connections, place it on periphery - if (!isPlaced && !hasConnections) { - const angle = (index / validatedNodes.length) * 2 * Math.PI; - const radius = Math.min(window.innerWidth, window.innerHeight) * 0.4; // Place on outer ring - const centerX = 0; // Start from center - const centerY = 0; - x = centerX + Math.cos(angle) * radius; - y = centerY + Math.sin(angle) * radius; + // Unplaced (never-positioned) nodes start on a CLEAN grid spread sized to + // the node count, spacing > collision diameter (~224) so there are no + // initial overlaps. Physics then REFINES this (links pull connected nodes + // together, collision holds the gap) and settles fast & clean — far + // better than exploding a pile at the origin. Jitter breaks symmetry. + if (!isPlaced) { + const cols = Math.max(1, Math.ceil(Math.sqrt(validatedNodes.length))); + const spacing = 260; + const half = (cols * spacing) / 2; + x = (index % cols) * spacing - half + ((index * 13) % 23) - 11; + y = Math.floor(index / cols) * spacing - half + ((index * 7) % 19) - 9; } const node = { @@ -1719,8 +1773,14 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap }); }); - // Gentle restart to settle any property changes - simulation.alpha(0.1).restart(); + // Physics is one-shot: it settles a graph, then stays idle. A routine + // data poll must NOT reheat a fully-placed (frozen) graph — that caused + // perpetual drift. Only nudge the sim if there are still-unsettled + // (unpinned / never-placed) nodes that actually need to find a spot. + const hasUnpinned = (simulation.nodes() as any[]).some((n: any) => n.fx == null || n.fy == null); + if (hasUnpinned) { + simulation.alpha(0.1).restart(); + } console.log('[Graph Debug] Simulation data and DOM elements updated'); }, [nodes, validatedEdges, getNodeDimensions]); @@ -1766,6 +1826,7 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Surgical update - only clear data elements, preserve core structure existingMainGroup.selectAll('.nodes-group').remove(); existingMainGroup.selectAll('.edges-group').remove(); + existingMainGroup.selectAll('.arrows-group').remove(); existingMainGroup.selectAll('.edge-labels-group').remove(); existingMainGroup.selectAll('.node-labels-container').remove(); d3.select(containerRef.current).selectAll('.node-labels-container').remove(); @@ -1917,7 +1978,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // never-placed nodes are seeded near center and left free to flow. // (This block used to null every node's fx/fy unconditionally, which is // why arrangements never survived a reload — the real drift bug.) - nodes.forEach((node: any) => { + const spreadCols = Math.max(1, Math.ceil(Math.sqrt(nodes.length))); + const spreadSpacing = 260; // > collision diameter so the spread has no overlaps + const spreadHalf = (spreadCols * spreadSpacing) / 2; + nodes.forEach((node: any, i: number) => { node.userPreferredPosition = null; node.userPreferenceVector = null; const placed = !(((node.positionX ?? 0) === 0) && ((node.positionY ?? 0) === 0)); @@ -1931,8 +1995,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap node.userPinned = false; node.fx = null; node.fy = null; - if (!node.x) node.x = centerX + (Math.random() - 0.5) * 100; - if (!node.y) node.y = centerY + (Math.random() - 0.5) * 100; + // Clean grid spread (not a random pile) so physics refines from a + // non-overlapping start. + if (!node.x) node.x = (i % spreadCols) * spreadSpacing - spreadHalf + ((i * 13) % 23) - 11; + if (!node.y) node.y = Math.floor(i / spreadCols) * spreadSpacing - spreadHalf + ((i * 7) % 19) - 9; } }); @@ -1969,13 +2035,53 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // physicsConfig.ts — see that file (and the debug console's drift metrics) // to reason about / tune why nodes settle and drift the way they do. const phys = DEFAULT_PHYSICS; + + // Hard minimum-edge-length constraint: connected nodes may never sit closer + // than their edge label needs to display (d._minLen, cached by the link + // distance accessor = halfDiag(src)+halfDiag(tgt)+labelW+pad). Position-based + // like forceCollide, so it's a real floor, not just a spring preference. It + // respects pinned nodes (fx set): a free node yields, two pinned nodes hold. + const minEdgeForce = () => { + for (const e of validatedEdges as any[]) { + const s = e.source, t = e.target; + if (!s || !t || typeof s.x !== 'number' || typeof t.x !== 'number') continue; + const min = e._minLen || 0; + if (min <= 0) continue; + let dx = t.x - s.x, dy = t.y - s.y; + let dist = Math.hypot(dx, dy); + if (dist === 0) { dx = 1; dy = 0; dist = 1; } // arbitrary separation dir + if (dist >= min) continue; + const corr = ((min - dist) / dist) * 0.5; // ease toward the floor + const ox = dx * corr, oy = dy * corr; + const sFixed = s.fx != null, tFixed = t.fx != null; + if (sFixed && tFixed) continue; + if (sFixed) { t.x += ox * 2; t.y += oy * 2; } + else if (tFixed) { s.x -= ox * 2; s.y -= oy * 2; } + else { s.x -= ox; s.y -= oy; t.x += ox; t.y += oy; } + } + }; + simulation .force('link', d3.forceLink(validatedEdges) .id((d: any) => d.id) .distance((d: any) => { - const currentDistance = Math.hypot(d.target.x - d.source.x, d.target.y - d.source.y); + const currentDistance = Math.hypot((d.target.x || 0) - (d.source.x || 0), (d.target.y || 0) - (d.source.y || 0)); const maxDistance = linkMaxDistance(width, height, phys); - return currentDistance > maxDistance ? maxDistance : linkDistance(width, height, phys); + const preferred = currentDistance > maxDistance ? maxDistance : linkDistance(width, height, phys); + // Floor: never pull connected nodes closer than their edge label + // needs to display — the label width sets a minimum edge length so + // it always fits in the border-to-border gap (edgeGeometry.minEdgeLength). + const label = getRelationshipConfig(d.type as RelationshipType)?.label || ''; + // Slightly generous estimate of the rendered label box (10px/600 text + // + icon + padding) so the gap never UNDER-shoots the real label. + const labelW = label.length * 7 + 34; + // Pass the edge direction so the minimum is just the per-angle border + // reach + label + a small margin (not an oversized half-diagonal buffer). + const dx = (d.target.x || 0) - (d.source.x || 0); + const dy = (d.target.y || 0) - (d.source.y || 0); + const minLen = minEdgeLength(getNodeDimensions(d.source), getNodeDimensions(d.target), labelW, dx, dy); + d._minLen = minLen; // cached for the hard min-edge constraint below + return Math.max(preferred, minLen); }) .strength((d: any) => { const currentDistance = Math.hypot(d.target.x - d.source.x, d.target.y - d.source.y); @@ -1995,6 +2101,7 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap .strength(phys.collision.strength) .iterations(phys.collision.iterations) ) + .force('minEdge', minEdgeForce) .force('hierarchy', d3.forceLink() .id((d: any) => d.id) .links(createHierarchicalLinks(nodes)) @@ -2191,6 +2298,7 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const connectedNode = edge.source.id === d.id ? edge.target : edge.source; return { node: connectedNode, + edge, // keep the edge so the drag clamp can read its _minLen wasFixed: connectedNode.fx !== null || connectedNode.fy !== null }; }); @@ -2209,12 +2317,23 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Threshold for switching from cluster movement to edge stretching const stretchThreshold = 80; // pixels - - if (dragDistance < stretchThreshold) { + const clustering = dragDistance < stretchThreshold; + + // Drag-time hard clamp: the dragged node may not get closer than the + // edge-label minimum to a connected neighbor that ISN'T moving with it + // (cluster-co-moving free neighbors keep their distance automatically, + // so they're excluded). This is the interactive twin of the minEdge + // force, which only governs the auto-layout. + const clampNeighbors = (d._connectedNodes || []) + .filter((c: any) => !(clustering && !c.wasFixed)) + .map((c: any) => ({ x: c.node.x || 0, y: c.node.y || 0, minLen: c.edge?._minLen || 0 })); + const tgt = clampToMinNeighbors({ x: event.x, y: event.y }, clampNeighbors); + + if (clustering) { // Cluster movement - move connected nodes together - const deltaX = event.x - d.x; - const deltaY = event.y - d.y; - + const deltaX = tgt.x - d.x; + const deltaY = tgt.y - d.y; + d._connectedNodes.forEach(({ node, wasFixed }: { node: any, wasFixed: boolean }) => { if (!wasFixed) { // Only move if not already fixed by user previously node.fx = (node.fx || node.x) + deltaX; @@ -2232,10 +2351,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap } }); } - - // Move the dragged node - d.fx = event.x; - d.fy = event.y; + + // Move the dragged node to the clamped target + d.fx = tgt.x; + d.fy = tgt.y; d.x = d.fx; d.y = d.fy; }) @@ -2334,6 +2453,24 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Monopoly-style rectangular nodes with colored title bars // getNodeDimensions is now defined outside and shared with updateVisualizationData + // Sheet-symbol "stack" — offset rects BEHIND the card imply this node opens + // a whole sub-graph (Altium-style hierarchical sheet). Rendered first so the + // main card sits on top. Only for nodes that drill into a sub-graph. + [10, 5].forEach((off) => { + nodeElements.filter((d: WorkItem) => !!d.subgraphId).append('rect') + .attr('class', 'node-subgraph-stack') + .attr('x', (d: WorkItem) => -getNodeDimensions(d).width / 2 + off) + .attr('y', (d: WorkItem) => -getNodeDimensions(d).height / 2 + off) + .attr('width', (d: WorkItem) => getNodeDimensions(d).width) + .attr('height', (d: WorkItem) => getNodeDimensions(d).height) + .attr('rx', 8) + .attr('fill', '#1f2937') + .attr('stroke', '#6366f1') + .attr('stroke-width', 1.5) + .style('opacity', 0.45) + .style('pointer-events', 'none'); + }); + // Main node rectangle (dark theme background) nodeElements.append('rect') .attr('class', (d: WorkItem) => { @@ -2378,6 +2515,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap if (d.status === 'COMPLETED' || d.status === 'Completed' || d.status === 'Done' || d.status === 'DONE') { return '#4b5563'; } + // Sheet symbols (drill into a sub-graph) get an indigo accent border. + if (d.subgraphId) { + return '#818cf8'; + } // In-progress work breathes with its type color (LIVE-1) if (isActiveStatus(d.status)) { return getTypeConfig(d.type as WorkItemType).hexColor; @@ -2393,6 +2534,9 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap if (selectedNode && selectedNode.id === d.id) { return 3; } + if (d.subgraphId) { + return 2.5; // Sheet symbol — emphasize it's a container + } return 1.5; }) .style('stroke-opacity', (d: WorkItem) => { @@ -2628,6 +2772,72 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap setIsConnecting(true); }); + // Sheet-symbol affordances: a "descend" glyph (bottom-right) + a child + // count line, only for nodes that drill into a sub-graph. + const sheetNodes = nodeElements.filter((d: WorkItem) => !!d.subgraphId); + + const descendIcon = sheetNodes.append('g') + .attr('class', 'node-descend-icon') + .attr('transform', (d: WorkItem) => { + const x = getNodeDimensions(d).width / 2 - iconSize / 2 - 8; + const y = getNodeDimensions(d).height / 2 - iconSize / 2 - 6; + return `translate(${x}, ${y}) scale(${1 / (currentTransform?.k || 1)})`; + }) + .style('cursor', 'pointer') + .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.FAR ? 0.9 : 0) + .style('pointer-events', 'all'); + descendIcon.append('rect') + .attr('class', 'descend-bg') + .attr('x', -iconSize / 2) + .attr('y', -iconSize / 2) + .attr('width', iconSize) + .attr('height', iconSize) + .attr('rx', 4) + .attr('fill', 'rgba(99, 102, 241, 0.9)') + .attr('stroke', 'rgba(255, 255, 255, 0.85)') + .attr('stroke-width', 1); + descendIcon.append('text') + .attr('x', 0) + .attr('y', 0) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .style('font-size', `${iconSize * 0.95}px`) + .style('font-weight', 'bold') + .style('fill', '#ffffff') + .style('pointer-events', 'none') + .text('⤢'); + descendIcon + .on('mouseenter', function() { + d3.select(this).select('.descend-bg').transition().duration(150) + .attr('fill', 'rgba(129, 140, 248, 1)'); + }) + .on('mouseleave', function() { + d3.select(this).select('.descend-bg').transition().duration(150) + .attr('fill', 'rgba(99, 102, 241, 0.9)'); + }) + .on('click', (event: MouseEvent, d: WorkItem) => { + event.stopPropagation(); + event.preventDefault(); + if (d.subgraphId) descendIntoRef.current(d.subgraphId); + }); + + // Child-graph count line (LOD-gated like the description text). + sheetNodes.append('text') + .attr('class', 'node-subgraph-count') + .attr('x', 0) + .attr('y', (d: WorkItem) => getNodeDimensions(d).height / 2 - 10) + .attr('text-anchor', 'middle') + .style('font-size', '9px') + .style('font-weight', '600') + .style('fill', '#a5b4fc') + .style('pointer-events', 'none') + .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.CLOSE ? 1 : 0) + .text((d: WorkItem) => { + const n = d.subgraph?.nodeCount ?? 0; + const e = d.subgraph?.edgeCount ?? 0; + return `▸ ${n} nodes · ${e} edges`; + }); + // Node title section - with text wrapping nodeElements.each(function(d: WorkItem) { const nodeGroup = d3.select(this); @@ -3369,28 +3579,52 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap }); let labelAvoidCounter = 0; - const updateEdgePositions = () => { + const updateEdgePositions = (forceAvoid = false) => { + // Dot mode (extreme zoom-out): all edges/arrows/labels are hidden, so there + // is nothing to position — skip the whole 1400-edge per-tick pass. + if (dotModeRef.current && !forceAvoid) { + return; + } + // Border-to-border anchors: the edge starts/ends where the center line + // crosses each card's border, not at the buried center. Computed once per + // edge per tick (shared datum) so line, hitbox and arrow agree. The anchor + // slides around the border as the nodes move — shortest border path. + linkElements.each(function (d: any) { + d._ep = edgeBorderEndpoints( + { x: d.source.x || 0, y: d.source.y || 0 }, getNodeDimensions(d.source), + { x: d.target.x || 0, y: d.target.y || 0 }, getNodeDimensions(d.target) + ); + }); + // Update visible edge positions linkElements - .attr('x1', (d: any) => d.source.x) - .attr('y1', (d: any) => d.source.y) - .attr('x2', (d: any) => d.target.x) - .attr('y2', (d: any) => d.target.y); - - // Update clickable edge positions + .attr('x1', (d: any) => d._ep.x1) + .attr('y1', (d: any) => d._ep.y1) + .attr('x2', (d: any) => d._ep.x2) + .attr('y2', (d: any) => d._ep.y2); + + // Update clickable edge positions clickableEdges - .attr('x1', (d: any) => d.source.x) - .attr('y1', (d: any) => d.source.y) - .attr('x2', (d: any) => d.target.x) - .attr('y2', (d: any) => d.target.y); - - // Update arrow positions + .attr('x1', (d: any) => d._ep.x1) + .attr('y1', (d: any) => d._ep.y1) + .attr('x2', (d: any) => d._ep.x2) + .attr('y2', (d: any) => d._ep.y2); + + // Simplified (dense + zoomed out): arrows and edge labels are hidden + // (data-simplify CSS), so skip their per-tick positioning entirely — at + // 1400 edges that arrow transform + label placement pass is the bulk of + // the remaining per-tick cost in the whole-graph view. forceAvoid (the + // one-shot settle pass) still runs so labels are correct when you zoom in. + if (simplifiedRef.current && !forceAvoid) { + return; + } + + // Arrow sits at the TARGET border, pointing into the node. arrowElements .attr('transform', (d: any) => { - const midX = (d.source.x + d.target.x) / 2; - const midY = (d.source.y + d.target.y) / 2; - const angle = Math.atan2(d.target.y - d.source.y, d.target.x - d.source.x) * 180 / Math.PI; - return `translate(${midX},${midY}) rotate(${angle})`; + const ep = d._ep; + const angle = Math.atan2(ep.y2 - ep.y1, ep.x2 - ep.x1) * 180 / Math.PI; + return `translate(${ep.x2},${ep.y2}) rotate(${angle})`; }); // Edge labels: auto-centered in the clear span between the two node @@ -3398,7 +3632,9 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // slidable by the user (d.labelT persists because the data merge keeps // edge object identity stable; d.labelTUser pins a manual slide). labelAvoidCounter++; - const runAvoidance = simulation.alpha() < 0.1 && labelAvoidCounter % 15 === 0; + // forceAvoid lets a one-shot caller (layout settle / pinned graphs that + // don't tick) run a full label de-overlap pass on demand. + const runAvoidance = forceAvoid || (simulation.alpha() < 0.1 && labelAvoidCounter % 15 === 0); const obstacles = runAvoidance ? (simulation.nodes() as any[]).map((n: any) => { const dims = getNodeDimensions(n); @@ -3463,8 +3699,63 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const perfMeter = new PerfMeter(240); const driftMeter = new DriftMeter(); let lastPerfReport = 0; + + // Viewport culling (large graphs only). At 1000 nodes the dominant frame + // cost is the browser repainting every on-screen SVG element each time a + // position changes; off-screen elements cost just as much to paint. We hide + // node groups (and edges with both ends hidden) outside the viewport so they + // are neither painted nor laid out. Geometry-only, generous margin, recomputed + // on a throttle during simulation ticks AND on every pan/zoom (the sim is a + // one-shot, so when it has stopped the zoom handler is the only thing that can + // reveal nodes panned back into view). + const cullEnabled = nodes.length > 200; + const CULL_MARGIN_PX = 300; + // Culling only pays off when enough of the graph is actually off-screen, i.e. + // when zoomed IN. At the whole-graph "fit" view every node is visible, so a + // cull pass would be pure overhead (it even slowed zoom). Below this scale we + // skip culling and, if we had culled, reveal everything once. + const CULL_MIN_SCALE = 0.5; + let cullCounter = 0; + let cullActive = false; + const clearCull = () => { + nodeElements.style('display', null); + linkElements.style('display', null); + clickableEdges.style('display', null); + arrowElements.style('display', null); + edgeLabelGroups.style('display', null); + cullActive = false; + }; + const applyViewportCull = () => { + const svgEl = svg.node(); + if (!svgEl) return; + const t = d3.zoomTransform(svgEl); + if (t.k < CULL_MIN_SCALE) { + if (cullActive) clearCull(); + return; + } + cullActive = true; + const minGX = (-CULL_MARGIN_PX - t.x) / t.k; + const maxGX = (width + CULL_MARGIN_PX - t.x) / t.k; + const minGY = (-CULL_MARGIN_PX - t.y) / t.k; + const maxGY = (height + CULL_MARGIN_PX - t.y) / t.k; + nodeElements.style('display', (d: any) => { + const x = d.x ?? 0; + const y = d.y ?? 0; + const visible = x >= minGX && x <= maxGX && y >= minGY && y <= maxGY; + d._culled = !visible; + return visible ? null : 'none'; + }); + const edgeDisplay = (d: any) => (d.source?._culled && d.target?._culled ? 'none' : null); + linkElements.style('display', edgeDisplay); + clickableEdges.style('display', edgeDisplay); + arrowElements.style('display', edgeDisplay); + edgeLabelGroups.style('display', edgeDisplay); + }; + simulation.on('tick', () => { const tickStart = performance.now(); + cullCounter++; + if (cullEnabled && cullCounter % 5 === 0) applyViewportCull(); // 1) Nodes first nodeElements @@ -3495,8 +3786,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap } // Update mini-map with current node positions (live simulation objects — - // the React-state nodes are different objects since the identity merge) - if ((window as any).updateMiniMapPositions) { + // the React-state nodes are different objects since the identity merge). + // Throttled: rebuilding a full positions dict for every node on every tick + // was pure overhead at scale; the minimap doesn't need 60 Hz updates. + if (cullCounter % 8 === 0 && (window as any).updateMiniMapPositions) { const simNodesForMap = simulation.nodes() as any[]; if (simNodesForMap.length > 0) { const positions: {[key: string]: {x: number, y: number}} = {}; @@ -3546,12 +3839,17 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Update zoom with LOD updates zoom.on('zoom', (event) => { g.attr('transform', event.transform); - setCurrentTransform({ - x: event.transform.x, - y: event.transform.y, - scale: event.transform.k + setCurrentTransform({ + x: event.transform.x, + y: event.transform.y, + scale: event.transform.k }); - + + // Re-cull on pan/zoom. The one-shot sim is usually stopped during pan, so + // this is the only thing that reveals nodes panned back into view (and + // hides ones panned out) — and it keeps paint bounded while panning. + if (cullEnabled) applyViewportCull(); + // Update mini-map viewport if ((window as any).updateMiniMapViewport) { const viewportUpdate = { @@ -3598,14 +3896,27 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // unpinned (new / never-placed) nodes to lay out; an already-arranged // graph loads pinned and stays put (snapshot-authoritative). const hasUnpinnedNodes = (simulation.nodes() as any[]).some((n: any) => n.fx == null || n.fy == null); + if (hasUnpinnedNodes) { + // Mark the start of a one-shot layout so we can report how long it took + // the physics to settle (a metric for studying the behavior). + layoutStartRef.current = performance.now(); + lastSettleMsRef.current = null; + } simulation .alpha(hasUnpinnedNodes ? DEFAULT_PHYSICS.alpha.loadEnergy : 0) .alphaDecay(0.015) .restart(); - // When the layout settles, persist it so the arrangement is durable - // across reloads (covers physics-laid-out graphs the user never dragged). - simulation.on('end.persist', () => persistAllPositions()); + // When the layout settles: record settle time, persist the arrangement so + // it's durable across reloads, run a final edge-label de-overlap pass, and + // center the camera. After this the simulation is idle (one-shot physics). + simulation.on('end.persist', () => { + if (layoutStartRef.current != null && lastSettleMsRef.current == null) { + lastSettleMsRef.current = Math.round(performance.now() - layoutStartRef.current); + } + persistAllPositions(); + runLabelAvoidanceRef.current?.(); + }); // Add method to restart collision detection (simulation as any).restartCollisions = () => { @@ -3614,8 +3925,19 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap simulation.alphaTarget(0); }, 2000); }; + + // Expose a one-shot edge-label de-overlap pass + run one shortly after init. + // Fully-pinned graphs don't tick, so without this their labels would stay at + // the default midpoint and could overlap → clean starting positions need it. + runLabelAvoidanceRef.current = () => updateEdgePositions(true); + setTimeout(() => updateEdgePositions(true), 500); }, [nodes, validatedEdges, handleNodeClick, initializeEmptyVisualization]); // Include handleNodeClick to get fresh connection state + // One-shot layout instrumentation + the forced label-avoidance hook. + const layoutStartRef = useRef(null); + const lastSettleMsRef = useRef(null); + const runLabelAvoidanceRef = useRef<(() => void) | null>(null); + // Store simulation reference for resize handling const simulationRef = useRef | null>(null); const zoomBehaviorRef = useRef | null>(null); @@ -3683,8 +4005,21 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const t = d3.zoomIdentity.translate(w / 2 - graphX * k, h / 2 - graphY * k).scale(k); svg.transition().duration(350).call(zoomBehaviorRef.current.transform as any, t); }; + // Mini-map wheel/pinch → zoom the main view to a target scale, centered on + // the gesture's graph point. Clamped to the same scaleExtent as the main + // zoom; applied via the shared zoom behavior so state + handlers stay in sync. + (window as any).miniMapZoom = (graphX: number, graphY: number, targetK: number) => { + if (!svgRef.current || !containerRef.current || !zoomBehaviorRef.current) return; + const svg = d3.select(svgRef.current); + const k = Math.max(0.1, Math.min(4, targetK)); + const w = containerRef.current.clientWidth; + const h = containerRef.current.clientHeight; + const t = d3.zoomIdentity.translate(w / 2 - graphX * k, h / 2 - graphY * k).scale(k); + svg.transition().duration(120).call(zoomBehaviorRef.current.transform as any, t); + }; return () => { delete (window as any).miniMapNavigate; + delete (window as any).miniMapZoom; }; }, []); @@ -3776,17 +4111,21 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // authoritative snapshot. const resetLayout = useCallback(() => { layoutReflowingRef.current = true; + // Unpin + mark unplaced; initializeVisualization will lay the unplaced + // nodes out on a CLEAN spread grid (see getUnplacedSpread) so physics + // REFINES a non-overlapping start instead of trying to explode a pile. nodes.forEach((node: any) => { node.userPinned = false; node.userPreferredPosition = null; node.userPreferenceVector = null; node.fx = null; node.fy = null; - // Treat as unplaced so init/merge won't re-pin to the old spot node.positionX = 0; node.positionY = 0; node.targetX = null; node.targetY = null; + node.x = 0; + node.y = 0; }); initializeVisualization(); setTimeout(() => { @@ -3796,51 +4135,119 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap }, 2500); }, [nodes, initializeVisualization, fitViewToNodes, persistAllPositions]); - // Auto-fit view when component first mounts with nodes - using stable dependency + // Comprehensive layout metrics for studying the physics behaviour skeptically: + // is the simulation actually idle (not silently reheating), do node cards + // overlap, do edge labels overlap, how long did the last layout take to + // settle, plus the live drift sample. Exposed for the diagnostic + console. + useEffect(() => { + (window as any).__organizeGraph = () => resetLayout(); + (window as any).__layoutMetrics = () => { + const sim = simulationRef.current; + if (!sim) return null; + const ns = sim.nodes() as any[]; + // TRUE visual overlap = the node CARD rectangles intersect (AABB). The + // collision radius is the half-diagonal, which over-counts side-by-side + // cards that don't actually overlap — this metric measures the real pile. + let overlapPairs = 0; + let maxOverlap = 0; + let proximityPairs = 0; // closer than collision radius (soft crowding) + const dims = ns.map((n) => getNodeDimensions(n)); + for (let i = 0; i < ns.length; i++) { + const a = ns[i]; + const da = dims[i]; + const ra = collisionRadius(da); + for (let j = i + 1; j < ns.length; j++) { + const b = ns[j]; + const db = dims[j]; + const dx = Math.abs((a.x || 0) - (b.x || 0)); + const dy = Math.abs((a.y || 0) - (b.y || 0)); + const ox = (da.width + db.width) / 2 - dx; + const oy = (da.height + db.height) / 2 - dy; + if (ox > 0 && oy > 0) { overlapPairs++; if (Math.min(ox, oy) > maxOverlap) maxOverlap = Math.min(ox, oy); } + if (Math.hypot(dx, dy) < ra + collisionRadius(db)) proximityPairs++; + } + } + const labelRects = Array.from(document.querySelectorAll('.graph-container svg .edge-label-group')) + .map((g) => (g as SVGGElement).getBoundingClientRect()) + .filter((r) => r.width > 0 && r.height > 0); + let labelOverlaps = 0; + for (let i = 0; i < labelRects.length; i++) { + for (let j = i + 1; j < labelRects.length; j++) { + const a = labelRects[i]; + const b = labelRects[j]; + if (a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom) labelOverlaps++; + } + } + const alpha = sim.alpha(); + // The sim stops ticking once alpha drops past alphaMin (~0.001); at that + // point nodes are frozen. (The drift field below is sampled in the tick + // loop, so it goes STALE after the sim stops — use atRest, not drift, to + // judge "has it stopped moving".) + const atRest = alpha <= 0.0015; + return { + simRunning: !atRest, + atRest, + alpha: Math.round(alpha * 10000) / 10000, + lastSettleMs: lastSettleMsRef.current, + nodeCount: ns.length, + pinnedCount: ns.filter((n: any) => n.fx != null).length, + edgeCount: validatedEdges.length, + overlappingNodePairs: overlapPairs, + maxNodeOverlapPx: Math.round(maxOverlap), + proximityPairs, + labelCount: labelRects.length, + overlappingLabelPairs: labelOverlaps, + drift: (window as any).__graphPerf?.spatial ?? null, + }; + }; + return () => { + delete (window as any).__layoutMetrics; + delete (window as any).__organizeGraph; + }; + }, [getNodeDimensions, validatedEdges, resetLayout]); + + // Center the camera on the graph whenever it loads or CHANGES (login, graph + // switch, drill-in / ascend). Keyed on the graph id — the old effect keyed on + // hasNodes only and restored one global transform, so it never recentered on + // a graph change. We wait briefly for the one-shot layout to settle, then fit. const hasNodes = nodes.length > 0; + const isDenseGraph = nodes.length > DENSE_GRAPH_NODE_THRESHOLD; + const isSimplified = isDenseGraph && (currentTransform?.scale ?? 1) < SIMPLIFY_SCALE; + const isDotMode = isDenseGraph && (currentTransform?.scale ?? 1) < DOT_SCALE; + simplifiedRef.current = isSimplified; + dotModeRef.current = isDotMode; + + // Keep the inline-rename box glued to its node while it's open — through node + // DRAGS, simulation ticks and pan/zoom — by repositioning the overlay div + // directly each frame from the live sim position + live zoom transform. The + // JSX position only recomputes on React renders (zoom), which is why the box + // lagged a drag until release. + const inlineEditNodeId = inlineEdit?.nodeId ?? null; useEffect(() => { - if (hasNodes && svgRef.current) { - // Check if this is the initial load (no previous transform stored) - const hasStoredTransform = sessionStorage.getItem('graphViewTransform'); - if (!hasStoredTransform) { - // First time loading - auto fit after simulation settles - const timer = setTimeout(() => { - fitViewToNodes(); - // Store the fitted transform - if (svgRef.current) { - const svg = d3.select(svgRef.current); - const transform = d3.zoomTransform(svg.node()!); - sessionStorage.setItem('graphViewTransform', JSON.stringify({ - x: transform.x, - y: transform.y, - k: transform.k - })); - } - }, 1500); - return () => clearTimeout(timer); - } else { - // Restore previous transform - try { - const saved = JSON.parse(hasStoredTransform); - const timer = setTimeout(() => { - if (svgRef.current) { - const svg = d3.select(svgRef.current); - const transform = d3.zoomIdentity.translate(saved.x, saved.y).scale(saved.k); - svg.call(d3.zoom().transform as any, transform); - } - }, 500); - return () => clearTimeout(timer); - } catch (e) { - // If stored transform is invalid, auto-fit - const timer = setTimeout(() => { - fitViewToNodes(); - }, 1500); - return () => clearTimeout(timer); + if (!inlineEditNodeId) return undefined; + let raf = 0; + const sync = () => { + const el = inlineEditRef.current; + const svgEl = svgRef.current; + if (el && svgEl) { + const n = (simulationRef.current?.nodes() as any[])?.find((m: any) => m.id === inlineEditNodeId); + if (n) { + const t = d3.zoomTransform(svgEl); + el.style.left = `${(n.x ?? 0) * t.k + t.x}px`; + el.style.top = `${(n.y ?? 0) * t.k + t.y}px`; } } - } - return undefined; - }, [hasNodes]); // Removed fitViewToNodes dependency to prevent camera jumps + raf = requestAnimationFrame(sync); + }; + raf = requestAnimationFrame(sync); + return () => cancelAnimationFrame(raf); + }, [inlineEditNodeId]); + const currentGraphId = currentGraph?.id; + useEffect(() => { + if (!hasNodes || !svgRef.current) return undefined; + const timer = setTimeout(() => fitViewToNodes(), 1500); + return () => clearTimeout(timer); + }, [hasNodes, currentGraphId, fitViewToNodes]); // Expose reset function to parent component useEffect(() => { @@ -3860,6 +4267,15 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Track previous node count to detect transition from empty to non-empty const prevNodeCountRef = useRef(0); + // Track the previous edge signature (id + type + direction) so a relationship + // TYPE change or a direction FLIP — which keep the edge COUNT the same — still + // forces a rebuild. Without this the edge label/arrow keep the stale value. + const prevEdgeSigRef = useRef(''); + // Track a per-node id+type signature: a node TYPE change keeps node COUNT the + // same, and the selective update path refreshes the badge text but NOT the + // type-derived card color/border/icon, so the graph showed a stale type. A + // signature change forces a full rebuild (same approach as edges). (#30) + const prevNodeSigRef = useRef(''); // Comprehensive reinitialization effect - ONLY when actually needed useEffect(() => { @@ -3876,6 +4292,27 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const isNowPopulated = nodes.length > 0; const transitioningFromEmpty = wasEmpty && isNowPopulated; + // Detect a relationship TYPE change or direction FLIP. Both keep the edge + // count the same, so length-based checks miss them; compare an id+type+ + // direction signature against the last render and force a rebuild on change. + const edgeSig = (validatedEdges as any[]) + .map((e) => { + const sId = typeof e.source === 'object' ? e.source?.id : e.source; + const tId = typeof e.target === 'object' ? e.target?.id : e.target; + return `${e.id}:${e.type}:${sId}>${tId}`; + }) + .sort() + .join(','); + const edgesChanged = prevEdgeSigRef.current !== '' && prevEdgeSigRef.current !== edgeSig; + + // Detect a node TYPE change (same count → length checks miss it). The + // selective path refreshes the badge text but not the card color/icon. (#30) + const nodeSig = (nodes as any[]) + .map((n) => `${n.id}:${n.type}`) + .sort() + .join(','); + const nodesChanged = prevNodeSigRef.current !== '' && prevNodeSigRef.current !== nodeSig; + // Only reinitialize if this is truly necessary const shouldReinit = !svgRef.current || @@ -3883,8 +4320,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap nodes.length === 0 || !d3.select(svgRef.current).select('.main-graph-group').node() || reinitTrigger > 0 || - transitioningFromEmpty; // Force reinit when adding first node to empty graph - + transitioningFromEmpty || // Force reinit when adding first node to empty graph + edgesChanged || // relationship type changed or direction flipped + nodesChanged; // a node's type changed — re-render its color/border/icon + if (shouldReinit) { console.log('[Graph Debug] Full reinitialization required'); initializeVisualization(); @@ -3898,8 +4337,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap updateVisualizationData(); } - // Update previous node count for next comparison + // Update previous node count + edge signature for next comparison prevNodeCountRef.current = nodes.length; + prevEdgeSigRef.current = edgeSig; + prevNodeSigRef.current = nodeSig; const handleResize = () => { if (!containerRef.current || !svgRef.current || !simulationRef.current) return; @@ -3931,7 +4372,13 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap loading, // Re-init when loading completes edgesLoading, // Re-init when edges loading completes // Track node property changes for selective updates (only titles, descriptions, types) - nodes.map(n => `${n.id}:${n.title}:${n.description}:${n.type}:${n.status}`).join(',') + nodes.map(n => `${n.id}:${n.title}:${n.description}:${n.type}:${n.status}`).join(','), + // Track edge type/direction changes so a relationship edit or flip rebuilds + validatedEdges.map((e: any) => { + const sId = typeof e.source === 'object' ? e.source?.id : e.source; + const tId = typeof e.target === 'object' ? e.target?.id : e.target; + return `${e.id}:${e.type}:${sId}>${tId}`; + }).join(',') ]); // Manual reinitialization function (expose globally for debugging) @@ -4034,7 +4481,7 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const isNetworkError = errorMessage.includes('Cannot connect'); return ( -
+
{/* Error message centered in SVG */} @@ -4218,7 +4665,7 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap return ( -
+
diff --git a/packages/web/src/components/Layout.tsx b/packages/web/src/components/Layout.tsx index d0d6f30a..76757289 100644 --- a/packages/web/src/components/Layout.tsx +++ b/packages/web/src/components/Layout.tsx @@ -6,7 +6,7 @@ import { GraphSelector } from './GraphSelector'; import { useAuth } from '../contexts/AuthContext'; import { McpHealthIndicator } from './McpHealthIndicator'; import FloatingConsole from './FloatingConsole'; -import { TlsStatusIndicator, TlsSecurityBanner } from './TlsStatusIndicator'; +import { InsecureConnectionBanner } from './TlsStatusIndicator'; import { APP_VERSION } from '../utils/version'; interface LayoutProps { @@ -31,12 +31,16 @@ export function Layout({ children }: LayoutProps) { ]; return ( -
+ {/* Insecure-connection warning — an in-flow strip at the very top, so it + reserves its own space and never overlaps the app (only over HTTP). */} + + {/* Static gradient background - optimized for all browsers */}
@@ -58,7 +62,7 @@ export function Layout({ children }: LayoutProps) {
-
+
{/* Sidebar */}
-
+
{children}
@@ -305,9 +309,6 @@ export function Layout({ children }: LayoutProps) { onToggle={() => setShowFloatingConsole(!showFloatingConsole)} onClose={() => setShowFloatingConsole(false)} /> - - {/* TLS/SSL Status Indicator */} -
); } \ No newline at end of file diff --git a/packages/web/src/components/MiniMap.tsx b/packages/web/src/components/MiniMap.tsx index bd78d0cf..9307efd4 100644 --- a/packages/web/src/components/MiniMap.tsx +++ b/packages/web/src/components/MiniMap.tsx @@ -25,6 +25,61 @@ export function MiniMap({ width = 192, height = 128 }: { width?: number; height? const [nodes, setNodes] = useState>({}); const [viewport, setViewport] = useState(null); const nodeTypesRef = useRef>({}); + // Live minimap-px <-> graph-coord conversion params, updated each render so + // the native (non-passive) wheel/touch listeners can map a gesture point to + // a graph point and drive the main view's zoom. + const geomRef = useRef({ minX: 0, minY: 0, offsetX: 0, offsetY: 0, scale: 1, k: 1 }); + const svgElRef = useRef(null); + + // Wheel + pinch on the minimap zoom the MAIN view (centered on the gesture + // point). Attached natively with passive:false so we can preventDefault and + // stop the page from scrolling while zooming the map. + useEffect(() => { + const el = svgElRef.current; + if (!el) return undefined; + const toGraph = (clientX: number, clientY: number) => { + const r = el.getBoundingClientRect(); + const g = geomRef.current; + return { + x: g.minX + (clientX - r.left - g.offsetX) / g.scale, + y: g.minY + (clientY - r.top - g.offsetY) / g.scale, + }; + }; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const p = toGraph(e.clientX, e.clientY); + const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18; + (window as any).miniMapZoom?.(p.x, p.y, geomRef.current.k * factor); + }; + let pinchDist0 = 0; + let pinchK0 = 1; + const dist = (t: TouchList) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); + const mid = (t: TouchList) => ({ x: (t[0].clientX + t[1].clientX) / 2, y: (t[0].clientY + t[1].clientY) / 2 }); + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length === 2) { pinchDist0 = dist(e.touches); pinchK0 = geomRef.current.k; } + }; + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length === 2 && pinchDist0 > 0) { + e.preventDefault(); + const m = mid(e.touches); + const p = toGraph(m.x, m.y); + (window as any).miniMapZoom?.(p.x, p.y, pinchK0 * (dist(e.touches) / pinchDist0)); + } + }; + const onTouchEnd = () => { pinchDist0 = 0; }; + el.addEventListener('wheel', onWheel, { passive: false }); + el.addEventListener('touchstart', onTouchStart, { passive: false }); + el.addEventListener('touchmove', onTouchMove, { passive: false }); + el.addEventListener('touchend', onTouchEnd); + return () => { + el.removeEventListener('wheel', onWheel); + el.removeEventListener('touchstart', onTouchStart); + el.removeEventListener('touchmove', onTouchMove); + el.removeEventListener('touchend', onTouchEnd); + }; + // Re-run when the svg appears: the minimap renders a "No nodes yet" div + // first (svg ref null), then the once positions arrive — attach then. + }, [Object.keys(nodes).length > 0]); useEffect(() => { (window as any).updateMiniMapPositions = (positions: Record) => { @@ -75,6 +130,7 @@ export function MiniMap({ width = 192, height = 128 }: { width?: number; height? const scale = Math.min(width / spanX, height / spanY); const offsetX = (width - spanX * scale) / 2; const offsetY = (height - spanY * scale) / 2; + geomRef.current = { minX, minY, offsetX, offsetY, scale, k: viewport?.k || 1 }; const toMini = useCallback( (gx: number, gy: number) => ({ @@ -117,6 +173,7 @@ export function MiniMap({ width = 192, height = 128 }: { width?: number; height? return ( import('./NodeContentRenderer'))` works. + */ +interface NodeContentRendererProps { + content: string; + /** Smaller type + tighter spacing for the on-canvas peek panel. */ + compact?: boolean; + className?: string; +} + +export default function NodeContentRenderer({ content, compact = false, className = '' }: NodeContentRendererProps) { + const trimmed = (content ?? '').trim(); + if (!trimmed) { + return
No contents yet.
; + } + + const prose = compact + ? 'text-xs leading-relaxed' + : 'text-sm leading-relaxed'; + + return ( +
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + a: ({ href, children }) => {children}, + strong: ({ children }) => {children}, + blockquote: ({ children }) =>
    {children}
    , + table: ({ children }) => {children}
    , + th: ({ children }) => {children}, + td: ({ children }) => {children}, + code(props) { + const { children, className: cn, ...rest } = props as any; + const match = /language-(\w+)/.exec(cn || ''); + const isInline = !(cn || '').includes('language-') && !String(children).includes('\n'); + if (isInline) { + return ( + + {children} + + ); + } + return ( + + {String(children).replace(/\n$/, '')} + + ); + }, + }} + > + {trimmed} +
    +
    + ); +} diff --git a/packages/web/src/components/NodeInspector.tsx b/packages/web/src/components/NodeInspector.tsx new file mode 100644 index 00000000..4b865d5d --- /dev/null +++ b/packages/web/src/components/NodeInspector.tsx @@ -0,0 +1,134 @@ +import { lazy, Suspense, useState } from 'react'; +import { X, FileText, Network, CreditCard } from 'lucide-react'; +import { useGraph } from '../contexts/GraphContext'; +import { getTypeConfig, getStatusConfig } from '../constants/workItemConstants'; +import type { WorkItemType } from '../constants/workItemConstants'; +import { NodeSubgraphPreview } from './NodeSubgraphPreview'; + +// Heavy (markdown + Prism) — lazy so it's out of the main bundle until a node's +// contents are first opened. +const NodeContentRenderer = lazy(() => import('./NodeContentRenderer')); + +type Mode = 'card' | 'contents' | 'diagram'; + +interface NodeInspectorProps { + node: any; + onClose: () => void; +} + +/** + * Docked inspector: shows the selected node's Card (summary), Contents (its + * description rendered as readable markdown/code), or Diagram (its sub-graph), + * each at full legible size regardless of canvas zoom. The mode is an explicit, + * per-node toggle — not a side effect of zooming in. + */ +export function NodeInspector({ node, onClose }: NodeInspectorProps) { + const { descendInto } = useGraph(); + const hasSubgraph = !!node?.subgraphId; + const [modeByNode, setModeByNode] = useState>({}); + const mode: Mode = modeByNode[node.id] ?? (node.description ? 'contents' : 'card'); + const setMode = (m: Mode) => setModeByNode((prev) => ({ ...prev, [node.id]: m })); + + const typeCfg = getTypeConfig(node.type as WorkItemType); + const statusCfg = getStatusConfig(node.status as any); + + return ( +
    + {/* Header */} +
    +
    +
    {typeCfg.label}
    +
    {node.title}
    +
    + +
    + + {/* Mode toggle */} +
    + setMode('card')} icon={} label="Card" /> + setMode('contents')} icon={} label="Contents" /> + setMode('diagram')} icon={} label="Diagram" disabled={!hasSubgraph} title={hasSubgraph ? 'Sub-graph' : 'No sub-graph'} /> +
    + + {/* Body */} +
    + {mode === 'card' && ( +
    + + + {typeof node.priority === 'number' && } + {Array.isArray(node.tags) && node.tags.length > 0 && ( +
    +
    Tags
    +
    + {node.tags.map((t: string) => {t})} +
    +
    + )} + {node.description && ( +
    +
    Description (preview)
    +
    {node.description}
    + +
    + )} +
    + )} + + {mode === 'contents' && ( +
    + Loading…
    }> + + +
    + )} + + {mode === 'diagram' && ( + hasSubgraph ? ( + descendInto(node.subgraphId)} + /> + ) : ( +
    This node has no sub-graph diagram.
    + ) + )} +
    +
    + ); +} + +function ModeBtn({ active, onClick, icon, label, disabled, title }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; disabled?: boolean; title?: string }) { + return ( + + ); +} + +function Row({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
    + {label} + {value} +
    + ); +} diff --git a/packages/web/src/components/NodeSubgraphPreview.tsx b/packages/web/src/components/NodeSubgraphPreview.tsx new file mode 100644 index 00000000..4a18b7f1 --- /dev/null +++ b/packages/web/src/components/NodeSubgraphPreview.tsx @@ -0,0 +1,115 @@ +import { useQuery } from '@apollo/client'; +import { Maximize2 } from 'lucide-react'; +import { GET_WORK_ITEMS, GET_EDGES } from '../lib/queries'; +import { getTypeConfig } from '../constants/workItemConstants'; +import type { WorkItemType } from '../constants/workItemConstants'; + +/** + * A STATIC, legible render of a node's sub-graph (its "diagram"), drawn from the + * sub-graph's persisted node positions — no force simulation. Lets you READ a + * diagram at a useful scale without navigating away; "Open" descends into it. + */ +interface NodeSubgraphPreviewProps { + subgraphId: string; + subgraphName?: string; + onOpen: () => void; +} + +export function NodeSubgraphPreview({ subgraphId, subgraphName, onOpen }: NodeSubgraphPreviewProps) { + // A preview is a thumbnail — cap the payload so even a 1000-node sub-graph + // loads fast and stays legible. "Open" shows the full thing. + const PREVIEW_LIMIT = 300; + const { data: wiData, loading } = useQuery(GET_WORK_ITEMS, { + variables: { where: { graph: { id: subgraphId } }, options: { limit: PREVIEW_LIMIT } }, + fetchPolicy: 'cache-and-network', + }); + const { data: edgeData } = useQuery(GET_EDGES, { + variables: { where: { source: { graph: { id: subgraphId } } }, options: { limit: 600 } }, + fetchPolicy: 'cache-and-network', + }); + + const nodes: any[] = wiData?.workItems ?? []; + const edges: any[] = edgeData?.edges ?? []; + + if (loading && nodes.length === 0) { + return
    Loading diagram…
    ; + } + if (nodes.length === 0) { + return ( +
    +
    This sub-graph is empty.
    + +
    + ); + } + + // Bounds from persisted positions, scaled to fit the preview viewBox. + const W = 320; + const H = 240; + const pad = 30; + const xs = nodes.map((n) => n.positionX ?? 0); + const ys = nodes.map((n) => n.positionY ?? 0); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const spanX = Math.max(1, maxX - minX); + const spanY = Math.max(1, maxY - minY); + const scale = Math.min((W - pad * 2) / spanX, (H - pad * 2) / spanY); + const ox = (W - spanX * scale) / 2; + const oy = (H - spanY * scale) / 2; + const toX = (x: number) => ox + (x - minX) * scale; + const toY = (y: number) => oy + (y - minY) * scale; + const byId: Record = {}; + for (const n of nodes) byId[n.id] = n; + + // Cap labels so a dense sub-graph stays readable, not a wall of text. + const showLabels = nodes.length <= 40; + + return ( +
    +
    + {nodes.length} nodes · {edges.length} edges + +
    + + {edges.map((e) => { + const s = byId[typeof e.source === 'object' ? e.source?.id : e.source]; + const t = byId[typeof e.target === 'object' ? e.target?.id : e.target]; + if (!s || !t) return null; + return ( + + ); + })} + {nodes.map((n) => { + const color = getTypeConfig(n.type as WorkItemType).hexColor; + const x = toX(n.positionX ?? 0); + const y = toY(n.positionY ?? 0); + return ( + + + {showLabels && ( + + {String(n.title).slice(0, 18)} + + )} + + ); + })} + +
    + ); +} + +function OpenButton({ onOpen, name }: { onOpen: () => void; name?: string }) { + return ( + + ); +} diff --git a/packages/web/src/components/SafeGraphVisualization.tsx b/packages/web/src/components/SafeGraphVisualization.tsx index aed230a8..3b91e3ae 100644 --- a/packages/web/src/components/SafeGraphVisualization.tsx +++ b/packages/web/src/components/SafeGraphVisualization.tsx @@ -1,19 +1,24 @@ import { GraphErrorBoundary } from './GraphErrorBoundary'; import { InteractiveGraphVisualization } from './InteractiveGraphVisualization'; +import type { WorkItem } from '../types/graph'; /** * Wrapper component that adds error handling to the graph visualization * without modifying the core InteractiveGraphVisualization component. * This prevents breaking the UI when implementing error handling. */ -export function SafeGraphVisualization() { +interface SafeGraphVisualizationProps { + onNodeSelected?: (node: WorkItem | null) => void; +} + +export function SafeGraphVisualization({ onNodeSelected }: SafeGraphVisualizationProps = {}) { return ( { // Error logged by boundary for debugging }} > - + ); -} \ No newline at end of file +} diff --git a/packages/web/src/components/TlsStatusIndicator.tsx b/packages/web/src/components/TlsStatusIndicator.tsx index 95819919..f8cae834 100644 --- a/packages/web/src/components/TlsStatusIndicator.tsx +++ b/packages/web/src/components/TlsStatusIndicator.tsx @@ -1,72 +1,87 @@ import React from 'react'; -import { Shield, ShieldOff, AlertTriangle } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { ShieldOff, AlertTriangle, X } from 'lucide-react'; -interface TlsStatusIndicatorProps { - className?: string; -} +const DISMISS_KEY = 'tlsBannerDismissed'; -export function TlsStatusIndicator({ className = '' }: TlsStatusIndicatorProps) { - // Detect if we're running over HTTPS +function readConnection() { const isSecure = window.location.protocol === 'https:'; - const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + const isLocalhost = + window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + return { isSecure, isLocalhost }; +} - // Don't show anything if we're on HTTPS (secure) - if (isSecure) { - return null; - } +interface InsecureConnectionBannerProps { + /** Render as a fixed full-width strip pinned to the very top (for pages that + * have no app chrome to sit under, e.g. the auth screens). Default is an + * in-flow strip that pushes the content below it down. */ + fixed?: boolean; + /** Called when the user dismisses the strip, so a parent can drop any layout + * offset it added to make room for the (fixed) banner. */ + onDismiss?: () => void; + className?: string; +} - return ( -
    -
    - {isLocalhost ? ( - <> - - Development Mode (HTTP) - - ) : ( - <> - - Insecure Connection (HTTP) - - )} -
    -
    - ); +/** Whether the current connection should trigger an insecure-connection warning + * (i.e. not HTTPS). Lets a layout reserve space for the fixed banner. */ +export function isInsecureConnection(): boolean { + return typeof window !== 'undefined' && window.location.protocol !== 'https:'; } -// For authenticated users, show a more prominent security status -export function TlsSecurityBanner({ className = '' }: TlsStatusIndicatorProps) { - const isSecure = window.location.protocol === 'https:'; - const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; +/** + * A slim, dismissible warning strip shown ONLY when the connection is not + * encrypted (HTTP). It lives in the document flow (or pinned to the top edge + * for chrome-less pages) instead of floating in a corner, so it never overlaps + * the rest of the UI. Dismissal is remembered for the browser session. + */ +export function InsecureConnectionBanner({ fixed = false, onDismiss, className = '' }: InsecureConnectionBannerProps) { + const { isSecure, isLocalhost } = readConnection(); + const [dismissed, setDismissed] = React.useState( + () => typeof sessionStorage !== 'undefined' && sessionStorage.getItem(DISMISS_KEY) === '1' + ); - if (isSecure) { - return ( -
    - - Secure Connection -
    - ); - } + // Nothing to warn about on HTTPS, or once the user has dismissed it. + if (isSecure || dismissed) return null; - if (isLocalhost) { - return ( -
    - - Development Mode -
    - ); - } + const dismiss = () => { + try { + sessionStorage.setItem(DISMISS_KEY, '1'); + } catch { + /* storage may be unavailable; dismissing for this mount is enough */ + } + setDismissed(true); + onDismiss?.(); + }; - return ( -
    - - Insecure Connection + const tone = isLocalhost + ? 'bg-yellow-500/15 border-yellow-600/40 text-yellow-200' + : 'bg-red-500/15 border-red-600/40 text-red-200'; + const position = fixed ? 'fixed top-0 inset-x-0 z-[60]' : 'w-full'; + + const strip = ( +
    + {isLocalhost ? : } + + {isLocalhost + ? 'Development mode — this connection is not encrypted (HTTP).' + : 'Insecure connection — this site is served over HTTP, not HTTPS.'} + +
    ); -} \ No newline at end of file + + // When pinned, portal to so a transformed/blur ancestor (route + // transitions, backdrop-filter) can't turn `fixed` into a clipped/offset box. + return fixed && typeof document !== 'undefined' ? createPortal(strip, document.body) : strip; +} diff --git a/packages/web/src/contexts/GraphContext.tsx b/packages/web/src/contexts/GraphContext.tsx index 13c0e350..38541803 100644 --- a/packages/web/src/contexts/GraphContext.tsx +++ b/packages/web/src/contexts/GraphContext.tsx @@ -154,10 +154,17 @@ export function GraphProvider({ children }: GraphProviderProps) { graphToSelect = parsedGraphs.find((g: any) => g.id === storedGraphId); } - // Auto-select graph: either previously selected or first available + // Auto-select graph: previously selected, else a sensible default. + // Pick by identity (Welcome tutorial first, then the System Overview), + // never by array position — merge order isn't stable and shared/system + // demo graphs must not hijack the fresh-load graph. if (parsedGraphs.length > 0) { if (!currentGraph || !parsedGraphs.find((g: any) => g.id === currentGraph.id)) { - const selectedGraph = graphToSelect || parsedGraphs[0]; + const preferredDefault = + parsedGraphs.find((g: any) => g.name === 'Welcome') || + parsedGraphs.find((g: any) => g.id === 'overview-graph-shared') || + parsedGraphs[0]; + const selectedGraph = graphToSelect || preferredDefault; setCurrentGraph(selectedGraph); // Save to localStorage for persistence localStorage.setItem('currentGraphId', selectedGraph.id); @@ -660,10 +667,26 @@ export function GraphProvider({ children }: GraphProviderProps) { const getGraphPath = (graphId: string): Graph[] => { const graph = availableGraphs.find(g => g.id === graphId); if (!graph?.path) return []; - + return graph.path.map(pathId => availableGraphs.find(g => g.id === pathId)).filter(Boolean) as Graph[]; }; + // Altium-style hierarchy navigation: descend into a node's sub-graph, ascend + // back up via the breadcrumb. Both reduce to selectGraph; the breadcrumb is + // derived from the target graph's `path` (ancestors) + itself. + const descendInto = async (subgraphId: string): Promise => { + await selectGraph(subgraphId); + }; + + const ascendTo = async (graphId: string): Promise => { + await selectGraph(graphId); + }; + + const getBreadcrumb = (): Graph[] => { + if (!currentGraph) return []; + return [...getGraphPath(currentGraph.id), currentGraph]; + }; + const getGraphDepth = (graphId: string): number => { const graph = availableGraphs.find(g => g.id === graphId); return graph?.depth || 0; @@ -718,6 +741,9 @@ export function GraphProvider({ children }: GraphProviderProps) { moveGraph, getGraphPath, getGraphChildren, + descendInto, + ascendTo, + getBreadcrumb, shareGraph, updatePermissions, joinSharedGraph, diff --git a/packages/web/src/index.css b/packages/web/src/index.css index e4de00f6..3a78a08e 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -796,6 +796,71 @@ input[type="date"]:focus { animation: none; } +/* PERF (large graphs): independent of the quality tier, a graph with many + nodes strips the continuous per-node/edge effects. The breathing/ache/flow + animations and drop-shadow halos force a repaint of every filtered layer on + every frame, which collapses FPS at scale (measured: ~7 idle FPS at 1000 + nodes vs ~60 with these off, identical DOM). Small graphs keep the full + living-graph aesthetic. data-dense is set by InteractiveGraphVisualization + above DENSE_GRAPH_NODE_THRESHOLD nodes. */ +.graph-container[data-dense="true"] svg .node-breathing, +.graph-container[data-dense="true"] svg .node-stuck { + animation: none; +} + +.graph-container[data-dense="true"] svg .node-bg, +.graph-container[data-dense="true"] svg .node-stuck { + filter: none !important; +} + +.graph-container[data-dense="true"] svg .edge-flowing-forward, +.graph-container[data-dense="true"] svg .edge-flowing-reverse { + stroke-dasharray: none; + animation: none; +} + +/* PERF (simplified LOD): when a dense graph is zoomed out far enough that the + per-node detail is unreadable anyway, hide that detail entirely (not just + opacity:0 — transparent elements are still painted). Each node drops from ~40 + painted SVG elements to just its colored card, which is what dominates the + frame cost in the whole-graph view. data-simplify is set by + InteractiveGraphVisualization below SIMPLIFY_SCALE on dense graphs; zooming + back in restores full detail. */ +.graph-container[data-simplify="true"] svg .node-title-bar, +.graph-container[data-simplify="true"] svg .node-type-text, +.graph-container[data-simplify="true"] svg .node-title-text, +.graph-container[data-simplify="true"] svg .node-description-text, +.graph-container[data-simplify="true"] svg .node-subgraph-count, +.graph-container[data-simplify="true"] svg .completion-indicator, +.graph-container[data-simplify="true"] svg .node-edit-icon, +.graph-container[data-simplify="true"] svg .node-relationship-icon, +.graph-container[data-simplify="true"] svg .node-descend-icon, +.graph-container[data-simplify="true"] svg .status-icon-svg, +.graph-container[data-simplify="true"] svg .status-label-text, +.graph-container[data-simplify="true"] svg .status-percentage-text, +.graph-container[data-simplify="true"] svg .status-progress-bg, +.graph-container[data-simplify="true"] svg .status-progress-fill, +.graph-container[data-simplify="true"] svg .priority-icon-svg, +.graph-container[data-simplify="true"] svg .priority-label-text, +.graph-container[data-simplify="true"] svg .priority-percentage-text, +.graph-container[data-simplify="true"] svg .priority-progress-bg, +.graph-container[data-simplify="true"] svg .priority-progress-fill, +.graph-container[data-simplify="true"] svg .arrow, +.graph-container[data-simplify="true"] svg .edge-label, +.graph-container[data-simplify="true"] svg .edge-label-bg, +.graph-container[data-simplify="true"] svg .edge-label-icon { + display: none !important; +} + +/* PERF (dot mode): at extreme zoom-out, edges are sub-pixel hairlines that + convey little but cost ~half the painted elements. Hide them so the whole- + graph overview composites only the node cards (the paint-bound limit). Edges + reappear above DOT_SCALE. data-dots is set by InteractiveGraphVisualization. */ +.graph-container[data-dots="true"] svg .edge, +.graph-container[data-dots="true"] svg .edge-clickable { + display: none !important; +} + @media (prefers-reduced-motion: reduce) { .graph-container svg .edge-flowing-forward, .graph-container svg .edge-flowing-reverse { diff --git a/packages/web/src/lib/__tests__/edgeGeometry.test.ts b/packages/web/src/lib/__tests__/edgeGeometry.test.ts new file mode 100644 index 00000000..9c4bfdad --- /dev/null +++ b/packages/web/src/lib/__tests__/edgeGeometry.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import { rectBorderPoint, edgeBorderEndpoints, halfDiagonal, minEdgeLength, clampToMinNeighbors, borderReach, LABEL_MARGIN } from '../edgeGeometry'; + +describe('rectBorderPoint', () => { + const dims = { width: 100, height: 60 }; // hw=50, hh=30 + + it('hits the vertical border for a horizontal ray', () => { + expect(rectBorderPoint({ x: 0, y: 0 }, dims, { x: 100, y: 0 })).toEqual({ x: 50, y: 0 }); + expect(rectBorderPoint({ x: 0, y: 0 }, dims, { x: -100, y: 0 })).toEqual({ x: -50, y: 0 }); + }); + + it('hits the horizontal border for a vertical ray', () => { + expect(rectBorderPoint({ x: 0, y: 0 }, dims, { x: 0, y: 100 })).toEqual({ x: 0, y: 30 }); + expect(rectBorderPoint({ x: 0, y: 0 }, dims, { x: 0, y: -100 })).toEqual({ x: 0, y: -30 }); + }); + + it('hits the nearer border on a diagonal', () => { + // toward (100,100): sx=50/100=0.5, sy=30/100=0.3 -> s=0.3 -> (30,30) + expect(rectBorderPoint({ x: 0, y: 0 }, dims, { x: 100, y: 100 })).toEqual({ x: 30, y: 30 }); + }); + + it('respects the center offset', () => { + expect(rectBorderPoint({ x: 200, y: 100 }, dims, { x: 400, y: 100 })).toEqual({ x: 250, y: 100 }); + }); + + it('returns the center when toward equals center', () => { + expect(rectBorderPoint({ x: 5, y: 5 }, dims, { x: 5, y: 5 })).toEqual({ x: 5, y: 5 }); + }); + + it('always lands on the border (distance from center = exactly one half-extent)', () => { + for (const angle of [0.1, 0.7, 1.2, 2.5, -1.9, 3.0]) { + const p = rectBorderPoint({ x: 0, y: 0 }, dims, { x: Math.cos(angle) * 1000, y: Math.sin(angle) * 1000 }); + const onVertical = Math.abs(Math.abs(p.x) - 50) < 1e-9; + const onHorizontal = Math.abs(Math.abs(p.y) - 30) < 1e-9; + expect(onVertical || onHorizontal, `point ${JSON.stringify(p)} should be on a border`).toBe(true); + // and within the box on the other axis + expect(Math.abs(p.x)).toBeLessThanOrEqual(50 + 1e-9); + expect(Math.abs(p.y)).toBeLessThanOrEqual(30 + 1e-9); + } + }); +}); + +describe('edgeBorderEndpoints', () => { + it('connects the facing borders of two horizontally-separated cards', () => { + const e = edgeBorderEndpoints({ x: 0, y: 0 }, { width: 100, height: 60 }, { x: 300, y: 0 }, { width: 100, height: 60 }); + expect(e).toEqual({ x1: 50, y1: 0, x2: 250, y2: 0 }); + }); + + it('the drawn segment is shorter than center-to-center (it starts at borders)', () => { + const s = { x: 0, y: 0 }, t = { x: 300, y: 0 }; + const e = edgeBorderEndpoints(s, { width: 100, height: 60 }, t, { width: 100, height: 60 }); + const drawn = Math.hypot(e.x2 - e.x1, e.y2 - e.y1); + const center = Math.hypot(t.x - s.x, t.y - s.y); + expect(drawn).toBeLessThan(center); + expect(drawn).toBe(200); // 300 - 50 - 50 + }); +}); + +describe('borderReach', () => { + const dims = { width: 170, height: 106 }; // hw=85, hh=53 + it('is the half-width for a horizontal edge', () => { + expect(borderReach(dims, 1, 0)).toBeCloseTo(85, 6); + }); + it('is the half-height for a vertical edge', () => { + expect(borderReach(dims, 0, 1)).toBeCloseTo(53, 6); + }); + it('never exceeds the half-diagonal (corner is the worst case)', () => { + for (const a of [0.2, 0.9, 1.4, 2.2]) { + expect(borderReach(dims, Math.cos(a), Math.sin(a))).toBeLessThanOrEqual(halfDiagonal(dims) + 1e-9); + } + }); +}); + +describe('minEdgeLength', () => { + it('is zero when there is no label', () => { + expect(minEdgeLength({ width: 170, height: 105 }, { width: 170, height: 105 }, 0)).toBe(0); + }); + + it('leaves only a SMALL margin around the label, not an oversized buffer', () => { + const a = { width: 170, height: 105 }; + const b = { width: 160, height: 100 }; + const labelW = 104; + // horizontal edge: the border-to-border gap should equal labelW + margin exactly. + const min = minEdgeLength(a, b, labelW, 1, 0); + const gap = min - borderReach(a, 1, 0) - borderReach(b, 1, 0); + expect(gap).toBeCloseTo(labelW + LABEL_MARGIN, 6); + // and it must be far tighter than the old half-diagonal buffer. + expect(min).toBeLessThan(halfDiagonal(a) + halfDiagonal(b) + labelW); + }); + + it('keeps the gap == label + margin at a vertical angle too', () => { + const a = { width: 170, height: 105 }; + const b = { width: 170, height: 105 }; + const labelW = 90; + const min = minEdgeLength(a, b, labelW, 0, 1); + const gap = min - borderReach(a, 0, 1) - borderReach(b, 0, 1); + expect(gap).toBeCloseTo(labelW + LABEL_MARGIN, 6); + }); +}); + +describe('clampToMinNeighbors (drag-time min edge length)', () => { + it('leaves a target that is already far enough alone', () => { + const p = clampToMinNeighbors({ x: 300, y: 0 }, [{ x: 0, y: 0, minLen: 200 }]); + expect(p).toEqual({ x: 300, y: 0 }); + }); + + it('pushes a too-close target out to exactly the min radius', () => { + const p = clampToMinNeighbors({ x: 50, y: 0 }, [{ x: 0, y: 0, minLen: 200 }]); + expect(Math.hypot(p.x, p.y)).toBeCloseTo(200, 6); + expect(p.y).toBeCloseTo(0, 6); // stays on the same ray + expect(p.x).toBeCloseTo(200, 6); + }); + + it('pushes out in a stable direction when the target sits on the neighbor', () => { + const p = clampToMinNeighbors({ x: 0, y: 0 }, [{ x: 0, y: 0, minLen: 120 }]); + expect(Math.hypot(p.x, p.y)).toBeCloseTo(120, 6); + }); + + it('respects multiple neighbors (target ends up outside every min radius)', () => { + const neighbors = [ + { x: 0, y: 0, minLen: 150 }, + { x: 100, y: 0, minLen: 150 }, + ]; + const p = clampToMinNeighbors({ x: 50, y: 10 }, neighbors, 8); + for (const n of neighbors) { + expect(Math.hypot(p.x - n.x, p.y - n.y)).toBeGreaterThanOrEqual(150 - 1e-6); + } + }); + + it('ignores neighbors with no minimum', () => { + const p = clampToMinNeighbors({ x: 5, y: 5 }, [{ x: 0, y: 0, minLen: 0 }]); + expect(p).toEqual({ x: 5, y: 5 }); + }); +}); diff --git a/packages/web/src/lib/__tests__/physicsConfig.test.ts b/packages/web/src/lib/__tests__/physicsConfig.test.ts index 37508f86..2d1830ec 100644 --- a/packages/web/src/lib/__tests__/physicsConfig.test.ts +++ b/packages/web/src/lib/__tests__/physicsConfig.test.ts @@ -10,10 +10,13 @@ import { describe('physicsConfig defaults', () => { it('matches the production-tuned values', () => { - expect(DEFAULT_PHYSICS.charge.strength).toBe(-60); - expect(DEFAULT_PHYSICS.alpha.velocityDecay).toBe(0.65); + // Tuned for one-shot, non-overlapping settle (PR-A): centering near-off so + // dense graphs expand until collision is satisfied, stronger collision, + // faster cool-down + damping so the sim reaches rest quickly. + expect(DEFAULT_PHYSICS.charge.strength).toBe(-70); + expect(DEFAULT_PHYSICS.alpha.velocityDecay).toBe(0.78); expect(DEFAULT_PHYSICS.alpha.restTarget).toBe(0); // fully stops when settled - expect(DEFAULT_PHYSICS.collision.strength).toBe(0.85); + expect(DEFAULT_PHYSICS.collision.strength).toBe(1); }); }); @@ -52,6 +55,6 @@ describe('withOverrides (live tuning)', () => { }); it('does not mutate the defaults', () => { withOverrides({ charge: { strength: 999 } }); - expect(DEFAULT_PHYSICS.charge.strength).toBe(-60); + expect(DEFAULT_PHYSICS.charge.strength).toBe(-70); }); }); diff --git a/packages/web/src/lib/edgeGeometry.ts b/packages/web/src/lib/edgeGeometry.ts new file mode 100644 index 00000000..0d6f6548 --- /dev/null +++ b/packages/web/src/lib/edgeGeometry.ts @@ -0,0 +1,131 @@ +/** + * Edge geometry: where an edge meets a node, and how far apart connected nodes + * must sit for their label to fit. Pure functions, no D3 — unit-testable and + * shared by the renderer (border attachment) and the force sim (min length). + * + * Model: a node card is an axis-aligned box centered at (x,y). An edge is the + * straight line between two node centers; it should *draw* from border to + * border (the point where that line crosses each card), and the two nodes + * should never sit so close that the edge's label can't fit in the gap. + */ + +export interface Pt { x: number; y: number; } +export interface Dims { width: number; height: number; } + +/** + * The point on a node card's border along the ray from its center toward + * `toward`. As the two nodes move around each other this point slides around + * the border, always giving the shortest border-to-border connection. + * + * If the cards overlap (the other center is inside this card) the scaled point + * lands outside the segment; we clamp to the border so the result is always on + * the card edge, never past `toward`. + */ +export function rectBorderPoint(center: Pt, dims: Dims, toward: Pt): Pt { + const dx = toward.x - center.x; + const dy = toward.y - center.y; + if (dx === 0 && dy === 0) return { x: center.x, y: center.y }; + const hw = dims.width / 2; + const hh = dims.height / 2; + const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity; + const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity; + // Smaller scale = the first border (vertical vs horizontal) the ray hits. + const s = Math.min(sx, sy); + return { x: center.x + dx * s, y: center.y + dy * s }; +} + +export interface EdgeEndpoints { x1: number; y1: number; x2: number; y2: number; } + +/** + * Border-to-border endpoints for an edge: from the source card's border (facing + * the target) to the target card's border (facing the source). + */ +export function edgeBorderEndpoints( + source: Pt, + sourceDims: Dims, + target: Pt, + targetDims: Dims +): EdgeEndpoints { + const p1 = rectBorderPoint(source, sourceDims, target); + const p2 = rectBorderPoint(target, targetDims, source); + return { x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }; +} + +/** Half the box diagonal — the largest distance from center to border (a + * corner), i.e. the worst-case projection of the card onto any edge angle. */ +export function halfDiagonal(dims: Dims): number { + return Math.hypot(dims.width, dims.height) / 2; +} + +/** A small margin kept around an edge label (px). */ +export const LABEL_MARGIN = 14; + +/** + * Distance from a card's center to where the edge crosses its border, along + * direction (dx,dy) — i.e. how much of the edge the card actually covers at + * this angle (NOT the worst-case corner). With direction unknown (0,0), falls + * back to the half short-side. Mirrors rectBorderPoint's reach. + */ +export function borderReach(dims: Dims, dx: number, dy: number): number { + const hw = dims.width / 2; + const hh = dims.height / 2; + if (dx === 0 && dy === 0) return Math.min(hw, hh); + const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity; + const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity; + const s = Math.min(sx, sy); + return Math.hypot(dx * s, dy * s); +} + +export interface MinNeighbor { x: number; y: number; minLen: number; } + +/** + * Clamp a dragged node's target so it never sits closer than `minLen` to any + * neighbor — drag-time enforcement of the minimum-edge-length rule. Projects + * the target out of each neighbor's min-radius circle; a few passes resolve + * multiple neighbors approximately (the cursor simply can't push past the + * nearest constraint). Pure — the caller supplies neighbor positions + mins. + */ +export function clampToMinNeighbors(target: Pt, neighbors: MinNeighbor[], iterations = 4): Pt { + let x = target.x; + let y = target.y; + for (let i = 0; i < iterations; i++) { + let moved = false; + for (const n of neighbors) { + if (!(n.minLen > 0)) continue; + let dx = x - n.x; + let dy = y - n.y; + let dist = Math.hypot(dx, dy); + if (dist === 0) { dx = 1; dy = 0; dist = 1; } // arbitrary push-out direction + if (dist < n.minLen) { + const s = n.minLen / dist; + x = n.x + dx * s; + y = n.y + dy * s; + moved = true; + } + } + if (!moved) break; + } + return { x, y }; +} + +/** + * Minimum CENTER-to-CENTER distance so the edge label fits in the + * border-to-border gap with just a small margin — NOT an oversized buffer. + * The visible gap = centerLen − reach(src) − reach(tgt); using the per-angle + * border reach (pass the edge direction dx,dy) makes the gap = labelWidth + + * margin exactly. Returns 0 for a zero-width label (no constraint). + */ +export function minEdgeLength( + sourceDims: Dims, + targetDims: Dims, + labelWidth: number, + dx = 0, + dy = 0, + margin = LABEL_MARGIN +): number { + if (!(labelWidth > 0)) return 0; + // Center distance whose BORDER-TO-BORDER gap is exactly labelWidth + a small + // margin. We use the per-angle border reach (not the half-diagonal), so the + // visible edge is just long enough for the label — no excessive buffer. + return borderReach(sourceDims, dx, dy) + borderReach(targetDims, dx, dy) + labelWidth + margin; +} diff --git a/packages/web/src/lib/physicsConfig.ts b/packages/web/src/lib/physicsConfig.ts index 27044b3f..ece4b247 100644 --- a/packages/web/src/lib/physicsConfig.ts +++ b/packages/web/src/lib/physicsConfig.ts @@ -34,12 +34,20 @@ export interface PhysicsConfig { /** Current production defaults (extracted verbatim from the component). */ export const DEFAULT_PHYSICS: PhysicsConfig = { - charge: { strength: -60, distanceMax: 200 }, + charge: { strength: -70, distanceMax: 350 }, link: { minDistanceFactor: 0.4, maxDistanceFactor: 0.6, strengthNormal: 0.2, strengthStretched: 0.5 }, - centering: { center: 0.01, axis: 0.002 }, - collision: { paddingPx: 12, strength: 0.85, iterations: 2 }, + // Only a TINY inward pull: a strong centering compresses dense graphs into a + // core that collision can't separate (the force equilibrium ends up + // overlapping). A tiny value just contains the layout so it converges instead + // of slowly expanding, while strong collision still spreads it to a clean, + // non-overlapping settle. The camera fit handles actual centering. + centering: { center: 0.0015, axis: 0.0003 }, + collision: { paddingPx: 12, strength: 1, iterations: 4 }, hierarchy: { distance: 250, strength: 0.05 }, - alpha: { loadEnergy: 0.6, decay: 0.015, velocityDecay: 0.65, restTarget: 0 }, + // Faster cool-down + heavier damping so a one-shot layout reaches REST + // quickly (it stops when alpha < alphaMin) instead of micro-drifting for + // many seconds — important on big graphs where low fps stretches the settle. + alpha: { loadEnergy: 0.7, decay: 0.03, velocityDecay: 0.78, restTarget: 0 }, reheat: { drag: 0.1, dragNeighbors: 0.2, collisions: 0.3, resize: 0.3 }, }; diff --git a/packages/web/src/lib/queries.ts b/packages/web/src/lib/queries.ts index ebc686f1..93216c87 100644 --- a/packages/web/src/lib/queries.ts +++ b/packages/web/src/lib/queries.ts @@ -18,6 +18,14 @@ export const GET_WORK_ITEMS = gql` dueDate tags metadata + subgraphId + subgraph { + id + name + nodeCount + edgeCount + type + } owner { id name @@ -103,6 +111,14 @@ export const GET_WORK_ITEM_BY_ID = gql` dueDate tags metadata + subgraphId + subgraph { + id + name + nodeCount + edgeCount + type + } owner { id name diff --git a/packages/web/src/pages/Admin.tsx b/packages/web/src/pages/Admin.tsx index cc175531..10123181 100644 --- a/packages/web/src/pages/Admin.tsx +++ b/packages/web/src/pages/Admin.tsx @@ -15,7 +15,7 @@ export function Admin() { // Redirect if not ADMIN if (currentUser?.role !== 'ADMIN') { return ( -
    +

    Access Denied

    diff --git a/packages/web/src/pages/Agents.tsx b/packages/web/src/pages/Agents.tsx index 18e36293..2e778512 100644 --- a/packages/web/src/pages/Agents.tsx +++ b/packages/web/src/pages/Agents.tsx @@ -109,7 +109,7 @@ export function Agents() { const totalActiveCount = activeAgents.length + activeMcpServers.length; return ( -
    +
    {/* Header */}
    diff --git a/packages/web/src/pages/Analytics.tsx b/packages/web/src/pages/Analytics.tsx index 87019b38..8f0ccb61 100644 --- a/packages/web/src/pages/Analytics.tsx +++ b/packages/web/src/pages/Analytics.tsx @@ -47,7 +47,7 @@ export function Analytics() { ]; return ( -
    +
    {/* Header */}
    diff --git a/packages/web/src/pages/Backend.tsx b/packages/web/src/pages/Backend.tsx index af9382f2..c71c18cc 100644 --- a/packages/web/src/pages/Backend.tsx +++ b/packages/web/src/pages/Backend.tsx @@ -460,7 +460,7 @@ export function Backend() { }; return ( -
    +
    {/* Header */}
    diff --git a/packages/web/src/pages/ForgotPassword.tsx b/packages/web/src/pages/ForgotPassword.tsx index 5e4bc16e..2261dd68 100644 --- a/packages/web/src/pages/ForgotPassword.tsx +++ b/packages/web/src/pages/ForgotPassword.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { Mail, ArrowLeft, CheckCircle, XCircle, Shield } from 'lucide-react'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { CodeCaptcha } from '../components/CodeCaptcha'; import { isValidEmail } from '../utils/validation'; @@ -246,8 +246,8 @@ export function ForgotPassword() {
    - {/* TLS/SSL Status Indicator */} - + {/* Insecure-connection warning (top strip, only over HTTP) */} +
    ); } diff --git a/packages/web/src/pages/Ontology.tsx b/packages/web/src/pages/Ontology.tsx index 4970bc15..1ef4f714 100644 --- a/packages/web/src/pages/Ontology.tsx +++ b/packages/web/src/pages/Ontology.tsx @@ -115,7 +115,7 @@ export function Ontology() { }; return ( -
    +
    {/* Header */}
    diff --git a/packages/web/src/pages/ResetPassword.tsx b/packages/web/src/pages/ResetPassword.tsx index 2f5b88a5..1648a3e3 100644 --- a/packages/web/src/pages/ResetPassword.tsx +++ b/packages/web/src/pages/ResetPassword.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams, useNavigate, Link } from 'react-router-dom'; import { Lock, Eye, EyeOff, CheckCircle, XCircle, ArrowLeft } from 'lucide-react'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { CodeCaptcha } from '../components/CodeCaptcha'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { validatePassword, getPasswordStrength } from '../utils/validation'; @@ -255,7 +255,7 @@ export function ResetPassword() {
    - +
    ); } diff --git a/packages/web/src/pages/Settings.tsx b/packages/web/src/pages/Settings.tsx index b449c769..680787e3 100644 --- a/packages/web/src/pages/Settings.tsx +++ b/packages/web/src/pages/Settings.tsx @@ -11,7 +11,7 @@ export function Settings() { const { tier, override, setOverride } = useAdaptiveQuality(); return ( -
    +
    {/* Header */}
    diff --git a/packages/web/src/pages/Signin.tsx b/packages/web/src/pages/Signin.tsx index fb007209..b3c2cdb9 100644 --- a/packages/web/src/pages/Signin.tsx +++ b/packages/web/src/pages/Signin.tsx @@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, gql } from '@apollo/client'; import { Eye, EyeOff, ArrowRight, Mail, Lock, Users, Github, Zap, Check, CheckCircle, XCircle, AlertTriangle, Shield } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { GuestModeDialog } from '../components/GuestModeDialog'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { isValidEmail } from '../utils/validation'; @@ -992,8 +992,8 @@ export function Signin() {
    - {/* TLS/SSL Status Indicator */} - + {/* Insecure-connection warning (top strip, only over HTTP) */} +
    ); } \ No newline at end of file diff --git a/packages/web/src/pages/Signup.tsx b/packages/web/src/pages/Signup.tsx index f0f0f1f9..0fcaaa04 100644 --- a/packages/web/src/pages/Signup.tsx +++ b/packages/web/src/pages/Signup.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useMutation, gql } from '@apollo/client'; import { Eye, EyeOff, ArrowRight, CheckCircle, XCircle, Github, Mail, Info, Shield } from 'lucide-react'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { isValidEmail, getPasswordStrength } from '../utils/validation'; import { CodeCaptcha } from '../components/CodeCaptcha'; @@ -729,8 +729,8 @@ export function Signup() { )}
    - {/* TLS/SSL Status Indicator */} - + {/* Insecure-connection warning (top strip, only over HTTP) */} +
    ); } \ No newline at end of file diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 606feda2..ced7c1c6 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; -import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus } from 'lucide-react'; +import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight } from 'lucide-react'; import { createPortal } from 'react-dom'; import { useQuery } from '@apollo/client'; import { SafeGraphVisualization } from '../components/SafeGraphVisualization'; +import { NodeInspector } from '../components/NodeInspector'; import { GraphSelector } from '../components/GraphSelector'; import { MiniMap } from '../components/MiniMap'; import { CreateWorkItemModal } from '../components/CreateWorkItemModal'; @@ -26,7 +27,9 @@ export function Workspace() { const [graphToEdit, setGraphToEdit] = useState(null); const [viewMode, setViewMode] = useState<'graph' | 'dashboard' | 'table' | 'cards' | 'kanban' | 'gantt' | 'calendar' | 'activity'>('graph'); const [showMiniMap, setShowMiniMap] = useState(true); - const { currentGraph, availableGraphs } = useGraph(); + const { currentGraph, availableGraphs, getBreadcrumb, ascendTo } = useGraph(); + const breadcrumb = getBreadcrumb(); + const [inspectorNode, setInspectorNode] = useState(null); const { currentTeam, currentUser } = useAuth(); const { health, loading: healthLoading, error: healthError } = useHealthStatus(); @@ -61,7 +64,7 @@ export function Workspace() { const actualEdgeCount = edgesData?.edges?.length || 0; return ( -
    +
    {/* Header with Graph Context */}
    {/* Responsive Layout Container */} @@ -249,6 +252,42 @@ export function Workspace() {
    + {/* Hierarchy breadcrumb — shown when we've descended into a sub-graph */} + {breadcrumb.length > 1 && ( +
    + + | + {breadcrumb.map((g, i) => { + const isLast = i === breadcrumb.length - 1; + return ( + + {i > 0 && } + {isLast ? ( + {g.name} + ) : ( + + )} + + ); + })} +
    + )} + {/* Main Content */}
    {!currentGraph ? ( @@ -339,6 +378,7 @@ export function Workspace() {
    ) : viewMode === 'graph' ? (
    +
    {/* Neo4j Connection Warning */} {health?.services?.neo4j?.status !== 'healthy' && (
    @@ -360,7 +400,13 @@ export function Workspace() {
    )} - + +
    + {inspectorNode && ( +
    + setInspectorNode(null)} /> +
    + )}
    ) : ( diff --git a/packages/web/src/types/graph.ts b/packages/web/src/types/graph.ts index 2e3c002a..f4f3c82f 100644 --- a/packages/web/src/types/graph.ts +++ b/packages/web/src/types/graph.ts @@ -100,6 +100,15 @@ export interface WorkItem { userId?: string; dependencies?: WorkItem[]; dependents?: WorkItem[]; + // Altium-style hierarchy: if set, this node drills into another graph + subgraphId?: string; + subgraph?: { + id: string; + name: string; + nodeCount?: number; + edgeCount?: number; + type?: string; + }; } // Import and re-export RelationshipType from central constants file @@ -151,6 +160,10 @@ export interface GraphContextType { moveGraph: (graphId: string, newParentId?: string) => Promise; getGraphPath: (graphId: string) => Graph[]; getGraphChildren: (graphId: string) => Graph[]; + // Altium-style drill-in navigation + descendInto: (subgraphId: string) => Promise; + ascendTo: (graphId: string) => Promise; + getBreadcrumb: () => Graph[]; // Sharing and permissions shareGraph: (graphId: string, settings: Partial) => Promise; diff --git a/playwright.config.ts b/playwright.config.ts index 3a9f10e5..bfb7c281 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,9 +38,10 @@ export default defineConfig({ projects: [ { name: 'GraphDone-Core/dev-neo4j/chromium', - // The showcase tour runs in its own capture-heavy project below; keep it - // out of the default (fast) project so the smoke gate stays quick. - testIgnore: /showcase\.spec\.ts/, + // The showcase tour and the local-VLM visual eval run in their own + // capture-heavy projects below; keep them out of the default (fast) + // project so the smoke gate stays quick. + testIgnore: [/showcase\.spec\.ts/, /visual-vlm\.spec\.ts/], use: { ...devices['Desktop Chrome'] }, }, @@ -65,9 +66,40 @@ export default defineConfig({ { name: 'perf', testDir: './tests/perf', + // The large-scale sweep is heavy and report-only; it has its own project + // so `test:perf` (the budget gate) stays fast. + testIgnore: /scale-sweep\.spec\.ts/, use: { ...devices['Desktop Chrome'] }, }, + /* Large-scale graph creation + performance metric sweep. Seeds graphs of + * increasing size and records window.__graphPerf across them. Heavy + + * report-only; run via `npm run test:perf:scale`. */ + { + name: 'perf-scale', + testDir: './tests/perf', + testMatch: /scale-sweep\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + + /* Local-VLM visual evaluation across personas. Skips unless VLM_ENDPOINTS + * is set in .env.test.local. Run via `npm run test:vlm`. */ + { + name: 'vlm', + testDir: './tests/e2e', + testMatch: /visual-vlm\.spec\.ts/, + use: { ...devices['Desktop Chrome'], screenshot: 'on' }, + }, + + /* Graph-geometry diagnostics: measures node/edge/label geometry from the + * rendered DOM (edge attachment, label fit, overlaps) to SEE layout issues + * before/after a fix. Report-only. Run via `npm run test:geometry`. */ + { + name: 'geometry', + testDir: './tests/diagnostics', + use: { ...devices['Desktop Chrome'], screenshot: 'on' }, + }, + // Commented out until browsers installed with system dependencies // { // name: 'GraphDone-Core/dev-neo4j/firefox', diff --git a/tests/diagnostics/captcha-layout.spec.ts b/tests/diagnostics/captcha-layout.spec.ts new file mode 100644 index 00000000..4c67b41c --- /dev/null +++ b/tests/diagnostics/captcha-layout.spec.ts @@ -0,0 +1,74 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * CodeCaptcha (ALTCHA-style) layout diagnostic. Measures whether the refresh + * ("try different style") button is vertically centered against the challenge + * content, in BOTH the math state (no speaker button) and the text/complex + * state (speaker button present). Report-only — no app behavior changed. + */ +const OUT = path.resolve(process.cwd(), 'test-artifacts/captcha'); + +async function measure(page: Page) { + return page.evaluate(() => { + const refresh = document.querySelector('button[title="Try different style"]') as HTMLElement | null; + const speaker = document.querySelector('button[title="Listen to code"]') as HTMLElement | null; + // the challenge box (the bordered panel) and the math number / canvas + const num = [...document.querySelectorAll('p')].find((p) => /=\s*\?/.test(p.textContent || '')) as HTMLElement | null; + const canvas = document.querySelector('canvas') as HTMLElement | null; + const content = (num ?? canvas) as HTMLElement | null; + const box = refresh?.closest('.rounded-lg') as HTMLElement | null; + const c = (el: HTMLElement | null) => { if (!el) return null; const r = el.getBoundingClientRect(); return { top: Math.round(r.top), bottom: Math.round(r.bottom), cy: Math.round(r.top + r.height / 2), h: Math.round(r.height) }; }; + return { + style: num ? 'math' : (canvas ? 'image' : 'unknown'), + hasSpeaker: !!speaker, + refresh: c(refresh), + content: c(content), + panel: c(box), + }; + }); +} + +test.describe('captcha layout diagnostic @geometry', () => { + test('refresh button vertical centering (math vs speaker state)', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('/signup'); + await page.waitForTimeout(1500); + await page.locator('button[title="Try different style"]').first().waitFor({ timeout: 15000 }); + + const results: any[] = []; + // State 1: as first shown (math, no speaker) + let m = await measure(page); + await page.screenshot({ path: path.join(OUT, `state-${m.style}-speaker${m.hasSpeaker}.png`), clip: m.panel ? { x: 0, y: Math.max(0, m.panel.top - 20), width: 1280, height: m.panel.h + 40 } : undefined }).catch(() => {}); + results.push(m); + + // Click refresh until we land on an image/text style (speaker appears), to + // compare. Bounded so we don't loop forever if RNG keeps picking math. + for (let i = 0; i < 12 && !(results.find((r) => r.hasSpeaker)); i++) { + await page.locator('button[title="Try different style"]').click(); + await page.waitForTimeout(500); + const cur = await measure(page); + if (cur.style !== results[results.length - 1].style || cur.hasSpeaker !== results[results.length - 1].hasSpeaker) { + await page.screenshot({ path: path.join(OUT, `state-${cur.style}-speaker${cur.hasSpeaker}.png`), clip: cur.panel ? { x: 0, y: Math.max(0, cur.panel.top - 20), width: 1280, height: cur.panel.h + 40 } : undefined }).catch(() => {}); + results.push(cur); + } + } + + const report = results.map((r) => ({ + style: r.style, + hasSpeaker: r.hasSpeaker, + refreshCy: r.refresh?.cy, + contentCy: r.content?.cy, + panelCy: r.panel?.cy, + refreshVsContent: r.refresh && r.content ? r.refresh.cy - r.content.cy : null, + refreshVsPanel: r.refresh && r.panel ? r.refresh.cy - r.panel.cy : null, + })); + fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 2)); + // eslint-disable-next-line no-console + for (const r of report) console.log('[captcha] ' + JSON.stringify(r)); + + expect(report.length, 'measured at least the math state').toBeGreaterThan(0); + }); +}); diff --git a/tests/diagnostics/core-interactions.spec.ts b/tests/diagnostics/core-interactions.spec.ts new file mode 100644 index 00000000..d631aee4 --- /dev/null +++ b/tests/diagnostics/core-interactions.spec.ts @@ -0,0 +1,191 @@ +import { test, expect, Page } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * CORE INTERACTION MATRIX — the "basic user checks" every build must pass. + * Each check is an independent, resilient probe of one core user action; failures + * are collected (not thrown immediately) so a single run prints the WHOLE pass/ + * fail picture instead of stopping at the first break. The final assertion fails + * the test if any check failed, with the full matrix in the log. + * + * This is the systematic counterpart to ad-hoc bug reports: it exercises node + + * edge CRUD, selection/inspector, inline rename, drag (incl. the open edit box + * following), relationship type change + flip, deletion, and the structural + * invariants that catch whole classes of rendering bugs (e.g. arrows == edges). + */ + +interface Check { name: string; ok: boolean; detail: string } + +async function counts(page: Page) { + return page.evaluate(() => { + const vis = (sel: string) => Array.from(document.querySelectorAll(sel)).filter((e) => getComputedStyle(e).display !== 'none').length; + return { + nodes: document.querySelectorAll('.graph-container svg .node').length, + edges: vis('.graph-container svg .edge'), + arrows: vis('.graph-container svg .arrow'), + edgeLabels: vis('.graph-container svg .edge-label'), + }; + }); +} + +async function nodeCenter(page: Page, index = 0) { + return page.evaluate((i) => { + const n = document.querySelectorAll('.graph-container svg .node .node-bg')[i] as Element | undefined; + if (!n) return null; + const r = n.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }, index); +} + +test.describe('core interaction matrix @geometry', () => { + test.describe.configure({ timeout: 180_000, mode: 'serial' }); + + test('all basic user checks', async ({ page }) => { + const checks: Check[] = []; + const add = (name: string, ok: boolean, detail = '') => { checks.push({ name, ok, detail }); }; + const jsErrors: string[] = []; + page.on('pageerror', (e) => jsErrors.push(e.message.slice(0, 100))); + + await page.setViewportSize({ width: 1600, height: 1000 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await page.reload(); + await page.locator('.graph-container svg .node').first().waitFor({ timeout: 30_000 }).catch(() => {}); + await page.waitForTimeout(5000); + + // 1. Graph renders nodes + edges + const c0 = await counts(page); + add('graph renders nodes', c0.nodes > 0, `nodes=${c0.nodes}`); + add('graph renders edges', c0.edges > 0, `edges=${c0.edges}`); + + // 2. INVARIANT: one arrow per visible edge (catches flip/delete orphan arrows) + add('arrows == edges (invariant)', c0.arrows === c0.edges, `arrows=${c0.arrows} edges=${c0.edges}`); + + // 3. Select a node → inspector opens + const nc = await nodeCenter(page, 0); + if (nc) { + await page.mouse.click(nc.x, nc.y); + await page.waitForTimeout(1200); + const inspectorVisible = await page.locator('[data-testid="node-inspector"]').isVisible().catch(() => false); + add('click node opens inspector', inspectorVisible, ''); + // 3b. inspector should be reasonably tight, not a huge mostly-empty panel + if (inspectorVisible) { + const box = await page.locator('[data-testid="node-inspector"]').boundingBox(); + add('inspector width is tight (<=340px)', !!box && box.width <= 340, `width=${box?.width ?? '?'}`); + } + } else { + add('click node opens inspector', false, 'no node found'); + } + + // 4. Inline rename: dblclick node → input → type → Enter → title persists + const nc2 = await nodeCenter(page, 0); + if (nc2) { + await page.mouse.dblclick(nc2.x, nc2.y); + const renameVisible = await page.locator('[data-testid="inline-rename"]').isVisible({ timeout: 3000 }).catch(() => false); + add('dblclick opens inline rename', renameVisible, ''); + if (renameVisible) { + const newName = 'CoreCheck ' + Date.now().toString().slice(-5); + await page.locator('[data-testid="inline-rename"]').fill(newName); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1500); + const titleShown = await page.locator(`.graph-container svg text:has-text("${newName.slice(0, 8)}")`).count().catch(() => 0); + add('inline rename updates title', titleShown > 0, `matches=${titleShown}`); + } + } + + // 5. Drag a node: the OPEN edit box must follow during the drag (reported bug) + const nc3 = await nodeCenter(page, 1) ?? await nodeCenter(page, 0); + if (nc3) { + await page.mouse.dblclick(nc3.x, nc3.y); + const editOpen = await page.locator('[data-testid="inline-rename"]').isVisible({ timeout: 3000 }).catch(() => false); + if (editOpen) { + const before = await page.locator('[data-testid="inline-rename"]').boundingBox(); + // drag the node body (mid-drag sample, before release) + await page.mouse.move(nc3.x, nc3.y); + await page.mouse.down(); + await page.mouse.move(nc3.x + 220, nc3.y + 140, { steps: 8 }); + await page.waitForTimeout(150); + const during = await page.locator('[data-testid="inline-rename"]').boundingBox(); + await page.mouse.up(); + const moved = !!before && !!during && Math.hypot((during.x - before.x), (during.y - before.y)) > 60; + add('edit box follows node during drag', moved, `moved=${before && during ? Math.round(Math.hypot(during.x - before.x, during.y - before.y)) : '?'}px`); + await page.keyboard.press('Escape').catch(() => {}); + } else { + add('edit box follows node during drag', false, 'edit box did not open'); + } + } + + // 6. Click an edge → relationship editor opens + await page.waitForTimeout(500); + const edgeBox = await page.evaluate(() => { + const e = document.querySelector('.graph-container svg .edge-clickable, .graph-container svg .edge') as SVGLineElement | null; + if (!e) return null; + const r = e.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + if (edgeBox) { + await page.mouse.click(edgeBox.x, edgeBox.y); + const editorOpen = await page.getByText('Flip Direction', { exact: false }).isVisible({ timeout: 3000 }).catch(() => false); + add('click edge opens relationship editor', editorOpen, ''); + + // 7. Flip direction → still exactly one arrow per edge (reported bug) + if (editorOpen) { + const beforeFlip = await counts(page); + await page.getByText('Flip Direction', { exact: false }).click().catch(() => {}); + await page.waitForTimeout(2500); + const afterFlip = await counts(page); + add('flip keeps arrows == edges', afterFlip.arrows === afterFlip.edges, `before a/e=${beforeFlip.arrows}/${beforeFlip.edges} after a/e=${afterFlip.arrows}/${afterFlip.edges}`); + } + } else { + add('click edge opens relationship editor', false, 'no edge found'); + } + + // 8. Delete a node → its edges' arrows are also removed (reported bug) + await page.keyboard.press('Escape').catch(() => {}); + await page.waitForTimeout(300); + const beforeDel = await counts(page); + // open node context menu (right-click) and delete, if available + const delTarget = await nodeCenter(page, 0); + if (delTarget) { + await page.mouse.click(delTarget.x, delTarget.y, { button: 'right' }).catch(() => {}); + await page.waitForTimeout(500); + const delBtn = page.getByRole('button', { name: /delete/i }).first(); + const canDelete = await delBtn.isVisible().catch(() => false); + if (canDelete) { + await delBtn.click().catch(() => {}); + // confirm if a confirm dialog appears + await page.getByRole('button', { name: /^(delete|confirm|yes)/i }).first().click({ timeout: 2000 }).catch(() => {}); + await page.waitForTimeout(2500); + const afterDel = await counts(page); + add('delete node removes a node', afterDel.nodes < beforeDel.nodes, `before=${beforeDel.nodes} after=${afterDel.nodes}`); + add('after delete, arrows == edges', afterDel.arrows === afterDel.edges, `arrows=${afterDel.arrows} edges=${afterDel.edges}`); + } else { + add('delete node available', false, 'no delete affordance found via right-click'); + } + } + + // 9. No uncaught JS errors during the whole flow + add('no uncaught JS errors', jsErrors.length === 0, jsErrors.slice(0, 3).join(' | ')); + + // ---- Report matrix ---- + const pass = checks.filter((c) => c.ok).length; + // eslint-disable-next-line no-console + console.log('\n===== CORE INTERACTION MATRIX ====='); + for (const c of checks) { + // eslint-disable-next-line no-console + console.log(`${c.ok ? '✅' : '❌'} ${c.name}${c.detail ? ' — ' + c.detail : ''}`); + } + // eslint-disable-next-line no-console + console.log(`===== ${pass}/${checks.length} passed =====\n`); + + // Report-only for now: the UI-trigger probes (click/dblclick/right-click + // coordinates) still need hardening before they can gate, and they are + // unreliable on a CPU-saturated dev box. The STRUCTURAL invariants, however, + // are deterministic — assert those (e.g. arrows must equal edges, which + // catches the orphan/duplicate-arrow class of bugs). Harden the rest on a + // quiet machine, then promote them into the hard assertion. + const invariantNames = ['arrows == edges (invariant)', 'flip keeps arrows == edges', 'after delete, arrows == edges', 'no uncaught JS errors']; + const invariantFails = checks.filter((c) => invariantNames.includes(c.name) && !c.ok).map((c) => c.name); + expect(invariantFails, `failed structural invariants: ${invariantFails.join(', ')}`).toEqual([]); + }); +}); diff --git a/tests/diagnostics/explorer-tree.spec.ts b/tests/diagnostics/explorer-tree.spec.ts new file mode 100644 index 00000000..5760243b --- /dev/null +++ b/tests/diagnostics/explorer-tree.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Project-explorer hierarchy: the graph selector expands a parent graph + * ("System Overview") to reveal its sub-graphs, and selecting a sub-graph + * switches the current graph. Needs the hierarchy demo seeded. + */ +test.describe('explorer tree diagnostic @geometry', () => { + test.describe.configure({ timeout: 120_000 }); + + test('expand System Overview → sub-graphs appear → select one switches graph', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(2500); + + // Open the graph selector. + await page.locator('[data-testid="graph-selector"]').click(); + await page.waitForTimeout(500); + + // Expand the "System Overview" parent (its chevron has a "sub-graphs" title). + const expander = page.locator('button[title$="sub-graphs"]').first(); + await expander.waitFor({ timeout: 10000 }); + await expander.click(); + await page.waitForTimeout(500); + + // A sub-graph row should now be visible. + const child = page.getByRole('button', { name: /Compute Core/ }); + await expect(child.first(), 'sub-graph row appears under the overview').toBeVisible(); + + // Selecting it switches the current graph. + await child.first().click(); + await page.waitForTimeout(3000); + const current = await page.evaluate(() => localStorage.getItem('currentGraphId')); + // eslint-disable-next-line no-console + console.log('[explorer-tree] currentGraphId after select = ' + current); + expect(current, 'selecting a sub-graph switches to it').toBe('subgraph-compute-shared'); + }); +}); diff --git a/tests/diagnostics/graph-geometry.spec.ts b/tests/diagnostics/graph-geometry.spec.ts new file mode 100644 index 00000000..d4673229 --- /dev/null +++ b/tests/diagnostics/graph-geometry.spec.ts @@ -0,0 +1,339 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; +import { sweepTestData, TEST_GRAPH_PREFIX } from '../helpers/dbHealing'; + +/** + * Graph-geometry diagnostic — measures node/edge/label geometry from the REAL + * rendered DOM so layout problems are visible quantitatively + visually before + * (and after) any fix. Report-only; it never changes app behaviour. + * + * It checks the three things called out for the layout overhaul: + * 1. EDGE ATTACHMENT — do edge line endpoints sit at the node CENTER (buried + * under the card) or on the node BORDER? Measured as the gap between the + * edge endpoint and the node center (≈0 today = center-attached) and how + * far the endpoint is INSIDE the card. + * 2. LABEL FIT — is the edge long enough for its label? i.e. is the label's + * rendered box wider than the clear span between the two cards, and does + * it OVERLAP either card? + * 3. MIN LENGTH — does the actual edge length respect a label-width minimum? + * + * Output: test-artifacts/geometry/{report.json, *.png}. The screenshots feed + * the eye (and the VLM suite). A FRESH controlled scenario is seeded so the + * problems are reproducible regardless of demo data; healing cleans it up. + */ + +const OUT = path.resolve(process.cwd(), 'test-artifacts/geometry'); + +interface EdgeGeom { + type: string; + centerLen: number; // node-center to node-center distance (what the sim uses) + endpointAtSourceCenterPx: number; // |edge (x1,y1) - source center| (≈0 => center-attached) + endpointAtTargetCenterPx: number; + sourceInsetPx: number; // how far the edge endpoint is INSIDE the source card border, along the edge + targetInsetPx: number; + labelW: number; + labelH: number; + clearSpanPx: number; // straight-line gap between the two card borders along the edge + labelOverflowPx: number; // labelW - clearSpan (>0 => label can't fit between cards) + labelOverlapsCard: boolean; // label box intersects either node card rect +} + +async function readGeometry(page: Page): Promise<{ edges: EdgeGeom[] }> { + return page.evaluate(() => { + const rectFor = (nodeG: Element) => { + const bg = nodeG.querySelector('.node-bg') as Element | null; + const r = (bg ?? nodeG).getBoundingClientRect(); + return { cx: r.x + r.width / 2, cy: r.y + r.height / 2, w: r.width, h: r.height }; + }; + // node id -> screen rect/center + const nodeById: Record = {}; + document.querySelectorAll('.graph-container svg .node').forEach((n) => { + const id = (n as any).__data__?.id; + if (id) nodeById[id] = rectFor(n); + }); + + const boxesOverlap = (a: any, b: any) => + Math.abs(a.cx - b.cx) * 2 < a.w + b.w && Math.abs(a.cy - b.cy) * 2 < a.h + b.h; + + // straight gap between two axis-aligned card borders along the connecting line + const clearSpan = (s: any, t: any) => { + const dx = t.cx - s.cx, dy = t.cy - s.cy; + const len = Math.hypot(dx, dy) || 1; + const ux = Math.abs(dx) / len, uy = Math.abs(dy) / len; + const proj = (n: any) => (n.w / 2) * ux + (n.h / 2) * uy; + return Math.max(0, len - proj(s) - proj(t)); + }; + + const edges: any[] = []; + document.querySelectorAll('.graph-container svg .edge').forEach((e) => { + const d = (e as any).__data__; + if (!d?.source || !d?.target) return; + const sId = typeof d.source === 'object' ? d.source.id : d.source; + const tId = typeof d.target === 'object' ? d.target.id : d.target; + const s = nodeById[sId], t = nodeById[tId]; + if (!s || !t) return; + + // The rendered edge endpoints (screen coords) + const le = e as SVGLineElement; + const r = le.getBoundingClientRect(); + // endpoints from attributes mapped to screen via the line's CTM is messy; + // instead compare the edge's own bbox extremes to the node centers. + const x1 = le.x1.baseVal.value, y1 = le.y1.baseVal.value, x2 = le.x2.baseVal.value, y2 = le.y2.baseVal.value; + // map svg-userspace endpoint to screen using the element CTM + const m = le.getScreenCTM(); + const p1 = m ? new DOMPoint(x1, y1).matrixTransform(m) : { x: r.left, y: r.top }; + const p2 = m ? new DOMPoint(x2, y2).matrixTransform(m) : { x: r.right, y: r.bottom }; + + const len = Math.hypot(t.cx - s.cx, t.cy - s.cy); + const ux = (t.cx - s.cx) / (len || 1), uy = (t.cy - s.cy) / (len || 1); + const proj = (n: any) => (n.w / 2) * Math.abs(ux) + (n.h / 2) * Math.abs(uy); + + // label box for this edge + const labelG = document.querySelector(`.edge-label-group`); + let labelW = 0, labelH = 0, labelBox: any = null; + const allLabels = [...document.querySelectorAll('.graph-container svg .edge-label-group')]; + // match label to edge by id when possible + const lg = allLabels.find((g) => (g as any).__data__?.id === d.id) as Element | undefined; + if (lg) { + const lr = lg.getBoundingClientRect(); + labelW = lr.width; labelH = lr.height; + labelBox = { cx: lr.x + lr.width / 2, cy: lr.y + lr.height / 2, w: lr.width, h: lr.height }; + } + + edges.push({ + type: d.type, + centerLen: Math.round(len), + endpointAtSourceCenterPx: Math.round(Math.hypot(p1.x - s.cx, p1.y - s.cy)), + endpointAtTargetCenterPx: Math.round(Math.hypot(p2.x - t.cx, p2.y - t.cy)), + sourceInsetPx: Math.round(proj(s)), + targetInsetPx: Math.round(proj(t)), + labelW: Math.round(labelW), + labelH: Math.round(labelH), + clearSpanPx: Math.round(clearSpan(s, t)), + labelOverflowPx: Math.round(labelW - clearSpan(s, t)), + labelOverlapsCard: labelBox ? (boxesOverlap(labelBox, s) || boxesOverlap(labelBox, t)) : false, + }); + }); + return { edges }; + }); +} + +async function gql(page: Page, query: string, variables?: unknown) { + return page.evaluate(async ({ query, variables }) => { + const token = localStorage.getItem('authToken') ?? ''; + const res = await fetch('/api/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }) }); + return res.json(); + }, { query, variables }); +} + +test.describe('graph geometry diagnostic @geometry', () => { + test.describe.configure({ timeout: 180_000 }); + test.beforeAll(async () => { await sweepTestData('geometry:before'); }); + test.afterAll(async () => { await sweepTestData('geometry:after'); }); + + test('measure edge attachment, label fit and overlaps', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + // Seed a controlled scenario: two PINNED pairs sharing the widest edge + // label. One pair is close (label can't fit), one is far (control). + const me = await gql(page, '{ me { id } }'); + const userId = me.data.me.id; + const g = await gql(page, `mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, + { i: [{ name: `${TEST_GRAPH_PREFIX} Geometry ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] }); + const graphId = g.data.createGraphs.graphs[0].id; + + // positions are pinned (snapshot-authoritative) so the edges stay the + // length we choose. Close pair: 200px apart (cards ~170 wide => ~30px clear, + // far short of a "Depends On"/"Is Part Of" label). Far pair: 470px apart. + const nodeDefs = [ + { key: 'closeA', x: -100, y: -120 }, { key: 'closeB', x: 100, y: -120 }, + { key: 'farA', x: -235, y: 140 }, { key: 'farB', x: 235, y: 140 }, + ]; + const created = await gql(page, `mutation($i:[WorkItemCreateInput!]!){createWorkItems(input:$i){workItems{id title}}}`, + { i: nodeDefs.map((n) => ({ type: 'TASK', title: n.key, status: 'IN_PROGRESS', priority: 0.5, positionX: n.x, positionY: n.y, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } })) }); + const ids: Record = {}; + for (const w of created.data.createWorkItems.workItems) ids[w.title] = w.id; + + const mkEdge = (a: string, b: string, type: string) => ({ type, weight: 0.6, source: { connect: { where: { node: { id: ids[a] } } } }, target: { connect: { where: { node: { id: ids[b] } } } } }); + await gql(page, `mutation($i:[EdgeCreateInput!]!){createEdges(input:$i){edges{id}}}`, + { i: [mkEdge('closeA', 'closeB', 'DEPENDS_ON'), mkEdge('farA', 'farB', 'DEPENDS_ON')] }); + + // Open the scenario graph. + await page.evaluate((gid) => { localStorage.setItem('currentGraphId', gid); localStorage.setItem('graphdone.quality.override', 'HIGH'); }, graphId); + await page.reload(); + await page.waitForTimeout(7000); // load + settle (pinned nodes barely move) + + await page.screenshot({ path: path.join(OUT, 'scenario-full.png') }); + + // Center the view on the close pair (graph midpoint (0,-120)) so the label + // overflow is clearly visible, not tucked under the toolbar. + await page.evaluate(() => (window as any).miniMapNavigate?.(0, -120)); + await page.waitForTimeout(1000); + await page.screenshot({ path: path.join(OUT, 'close-pair-centered.png') }); + + const geom = await readGeometry(page); + + // Clipped close-up around the CLOSE pair so the label overflow is obvious, + // using the measured on-screen rects (independent of the app's framing). + const closeRects = await page.evaluate(() => { + const out: Array<{ x: number; y: number; w: number; h: number }> = []; + document.querySelectorAll('.graph-container svg .node').forEach((n) => { + const t = (n as any).__data__?.title; + if (t === 'closeA' || t === 'closeB') { + const r = ((n.querySelector('.node-bg') as Element) ?? n).getBoundingClientRect(); + out.push({ x: r.x, y: r.y, w: r.width, h: r.height }); + } + }); + return out; + }); + if (closeRects.length === 2) { + const margin = 90; + const minX = Math.max(0, Math.min(...closeRects.map((r) => r.x)) - margin); + const minY = Math.max(0, Math.min(...closeRects.map((r) => r.y)) - margin); + const maxX = Math.min(1440, Math.max(...closeRects.map((r) => r.x + r.w)) + margin); + const maxY = Math.min(900, Math.max(...closeRects.map((r) => r.y + r.h)) + margin); + await page.screenshot({ path: path.join(OUT, 'close-pair.png'), clip: { x: minX, y: minY, width: Math.max(50, maxX - minX), height: Math.max(50, maxY - minY) } }).catch(() => {}); + } + + const report = { + generatedAt: new Date().toISOString(), + viewport: '1440x900', + edges: geom.edges, + summary: { + edgeCount: geom.edges.length, + centerAttached: geom.edges.filter((e) => e.endpointAtSourceCenterPx <= 3 && e.endpointAtTargetCenterPx <= 3).length, + labelsOverflowing: geom.edges.filter((e) => e.labelOverflowPx > 0).length, + labelsOverlappingCards: geom.edges.filter((e) => e.labelOverlapsCard).length, + }, + }; + fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 2)); + + // eslint-disable-next-line no-console + console.log('[geometry] ' + JSON.stringify(report.summary)); + for (const e of geom.edges) { + // eslint-disable-next-line no-console + console.log(`[geometry] ${e.type}: centerLen=${e.centerLen} clearSpan=${e.clearSpanPx} labelW=${e.labelW} overflow=${e.labelOverflowPx} overlapsCard=${e.labelOverlapsCard} endpoint@srcCenter=${e.endpointAtSourceCenterPx}px endpoint@tgtCenter=${e.endpointAtTargetCenterPx}px`); + } + + // Diagnostic, not a gate: just assert we measured something real. + expect(geom.edges.length, 'measured at least one edge').toBeGreaterThan(0); + + await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: graphId }); + + // ── Scenario 2: an UNPINNED cluster the sim lays out (positionX/Y=0 => + // unplaced). This exercises the physics floor: every auto-laid edge should + // settle long enough for its label (clearSpan >= labelW). Pinned scenario + // above can't test this (user-placed nodes aren't moved by the sim). + const g2 = await gql(page, `mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, + { i: [{ name: `${TEST_GRAPH_PREFIX} GeometryFlow ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] }); + const flowId = g2.data.createGraphs.graphs[0].id; + const flowNodes = ['hub', 's1', 's2', 's3', 's4', 's5']; + const c2 = await gql(page, `mutation($i:[WorkItemCreateInput!]!){createWorkItems(input:$i){workItems{id title}}}`, + { i: flowNodes.map((t) => ({ type: 'TASK', title: t, status: 'IN_PROGRESS', priority: 0.5, positionX: 0, positionY: 0, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: flowId } } } } })) }); + const fids: Record = {}; + for (const w of c2.data.createWorkItems.workItems) fids[w.title] = w.id; + const fEdge = (a: string, b: string, type: string) => ({ type, weight: 0.6, source: { connect: { where: { node: { id: fids[a] } } } }, target: { connect: { where: { node: { id: fids[b] } } } } }); + await gql(page, `mutation($i:[EdgeCreateInput!]!){createEdges(input:$i){edges{id}}}`, + { i: ['s1', 's2', 's3', 's4', 's5'].map((s, idx) => fEdge('hub', s, ['DEPENDS_ON', 'IS_PART_OF', 'RELATES_TO', 'BLOCKS', 'DEPENDS_ON'][idx])) }); + + await page.evaluate((gid) => { localStorage.setItem('currentGraphId', gid); localStorage.setItem('graphdone.quality.override', 'HIGH'); }, flowId); + await page.reload(); + await page.waitForTimeout(9000); // unplaced nodes flow + settle + await page.evaluate(() => (window as any).miniMapNavigate?.(0, 0)); + await page.waitForTimeout(800); + await page.screenshot({ path: path.join(OUT, 'flow-cluster.png') }); + const flow = await readGeometry(page); + const flowSummary = { + edgeCount: flow.edges.length, + labelsOverflowing: flow.edges.filter((e) => e.labelOverflowPx > 0).length, + labelsOverlappingCards: flow.edges.filter((e) => e.labelOverlapsCard).length, + minClearSpan: Math.min(...flow.edges.map((e) => e.clearSpanPx)), + maxLabelW: Math.max(...flow.edges.map((e) => e.labelW)), + }; + fs.writeFileSync(path.join(OUT, 'report-flow.json'), JSON.stringify({ summary: flowSummary, edges: flow.edges }, null, 2)); + // eslint-disable-next-line no-console + console.log('[geometry:flow] ' + JSON.stringify(flowSummary)); + + await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: flowId }); + await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: flowId }); + await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: flowId }); + }); + + test('drag-time clamp: a node cannot be dragged closer than the label minimum', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + const me = await gql(page, '{ me { id } }'); + const userId = me.data.me.id; + const g = await gql(page, `mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, + { i: [{ name: `${TEST_GRAPH_PREFIX} DragClamp ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] }); + const graphId = g.data.createGraphs.graphs[0].id; + // Two placed nodes, far apart, joined by a wide-label edge. + const created = await gql(page, `mutation($i:[WorkItemCreateInput!]!){createWorkItems(input:$i){workItems{id title}}}`, + { i: [ + { type: 'TASK', title: 'anchor', status: 'IN_PROGRESS', priority: 0.5, positionX: -260, positionY: 0, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } }, + { type: 'TASK', title: 'dragme', status: 'IN_PROGRESS', priority: 0.5, positionX: 260, positionY: 0, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } }, + ] }); + const ids: Record = {}; + for (const w of created.data.createWorkItems.workItems) ids[w.title] = w.id; + await gql(page, `mutation($i:[EdgeCreateInput!]!){createEdges(input:$i){edges{id}}}`, + { i: [{ type: 'IS_PART_OF', weight: 0.6, source: { connect: { where: { node: { id: ids.dragme } } } }, target: { connect: { where: { node: { id: ids.anchor } } } } }] }); + + await page.evaluate((gid) => { localStorage.setItem('currentGraphId', gid); localStorage.setItem('graphdone.quality.override', 'HIGH'); }, graphId); + await page.reload(); + await page.waitForTimeout(6000); + await page.evaluate(() => (window as any).miniMapNavigate?.(0, 0)); + await page.waitForTimeout(1000); + + const centerOf = (title: string) => page.evaluate((t) => { + const n = [...document.querySelectorAll('.graph-container svg .node')].find((el: any) => el.__data__?.title === t) as any; + if (!n) return null; + const r = (n.querySelector('.node-bg') as Element).getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }, title); + + const anchor = await centerOf('anchor'); + const drag = await centerOf('dragme'); + expect(anchor && drag, 'both nodes on screen').toBeTruthy(); + + // Drag "dragme" right onto "anchor" (and past it) — the clamp must stop it. + await page.mouse.move(drag!.x, drag!.y); + await page.mouse.down(); + const steps = 24; + for (let i = 1; i <= steps; i++) { + await page.mouse.move(drag!.x + (anchor!.x - drag!.x) * (i / steps), drag!.y + (anchor!.y - drag!.y) * (i / steps), { steps: 1 }); + await page.waitForTimeout(15); + } + await page.mouse.up(); + await page.waitForTimeout(1500); + await page.screenshot({ path: path.join(OUT, 'drag-clamp.png') }); + + // Final graph-space center distance + the edge's enforced minimum. + const result = await page.evaluate(() => { + const node = (t: string) => [...document.querySelectorAll('.graph-container svg .node')].find((el: any) => el.__data__?.title === t) as any; + const a = node('anchor')?.__data__, b = node('dragme')?.__data__; + const edge = [...document.querySelectorAll('.graph-container svg .edge')].map((e: any) => e.__data__).find((d: any) => d && d._minLen); + return { dist: a && b ? Math.hypot(a.x - b.x, a.y - b.y) : -1, minLen: edge?._minLen ?? -1 }; + }); + // eslint-disable-next-line no-console + console.log(`[geometry:drag] after dragging onto the anchor: centerDist=${Math.round(result.dist)} minLen=${Math.round(result.minLen)}`); + + expect(result.minLen, 'edge has a computed minimum length').toBeGreaterThan(0); + // The clamp must keep them apart — allow a small tolerance for the iterative + // projection + a tick of settling. + expect(result.dist, 'dragged node was held at the label minimum, not on top of the anchor').toBeGreaterThanOrEqual(result.minLen - 25); + + await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: graphId }); + }); +}); diff --git a/tests/diagnostics/hierarchy-navigation.spec.ts b/tests/diagnostics/hierarchy-navigation.spec.ts new file mode 100644 index 00000000..aed0a135 --- /dev/null +++ b/tests/diagnostics/hierarchy-navigation.spec.ts @@ -0,0 +1,92 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Altium-style "graphs of graphs" navigation: from the System Overview, a + * sheet-symbol node drills into its sub-graph; a breadcrumb ascends back. Needs + * the hierarchy demo seeded (`npm run create-hierarchy-demo`). Report-only + * screenshots + hard assertions on descend/ascend. + */ +const OUT = path.resolve(process.cwd(), 'test-artifacts/hierarchy'); +const OVERVIEW_ID = 'overview-graph-shared'; + +async function openOverview(page: Page) { + await page.evaluate((gid) => { + localStorage.setItem('currentGraphId', gid); + localStorage.setItem('graphdone.quality.override', 'HIGH'); + }, OVERVIEW_ID); + await page.reload(); + await page.waitForTimeout(6000); +} + +async function readState(page: Page) { + return page.evaluate(() => { + const nodes = [...document.querySelectorAll('.graph-container svg .node')]; + const sheets = nodes.filter((n) => (n as any).__data__?.subgraphId); + return { + currentGraphId: localStorage.getItem('currentGraphId'), + nodeCount: nodes.length, + sheetCount: sheets.length, + firstSheetSubgraphId: (sheets[0] as any)?.__data__?.subgraphId ?? null, + }; + }); +} + +test.describe('hierarchy navigation @geometry', () => { + test.describe.configure({ timeout: 150_000 }); + + test('descend into a sheet symbol, ascend via breadcrumb', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openOverview(page); + + // Overview should render sheet-symbol nodes (each with a subgraphId). + const overview = await readState(page); + await page.screenshot({ path: path.join(OUT, '1-overview.png') }); + // eslint-disable-next-line no-console + console.log('[hierarchy] overview ' + JSON.stringify(overview)); + expect(overview.currentGraphId, 'on the overview graph').toBe(OVERVIEW_ID); + expect(overview.sheetCount, 'overview has sheet-symbol nodes').toBeGreaterThan(0); + + // Descend: click a sheet node's DESCEND glyph (plain card-click now selects + // for the inspector; descending is the explicit ⤢ glyph or inspector Open). + const targetSubgraphId = overview.firstSheetSubgraphId as string; + await page.evaluate(() => { + const sheet = [...document.querySelectorAll('.graph-container svg .node')].find( + (n) => (n as any).__data__?.subgraphId + ) as SVGGElement | undefined; + const glyph = sheet?.querySelector('.node-descend-icon') as Element | undefined; + (glyph as any)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await page.waitForTimeout(5000); + + const descended = await readState(page); + await page.screenshot({ path: path.join(OUT, '2-descended.png') }); + // eslint-disable-next-line no-console + console.log('[hierarchy] descended ' + JSON.stringify(descended)); + expect(descended.currentGraphId, 'descended into the sheet sub-graph').toBe(targetSubgraphId); + expect(descended.nodeCount, 'sub-graph rendered its own nodes').toBeGreaterThan(0); + + // Breadcrumb shows two crumbs (overview / sub-graph). + const crumbs = await page.locator('[data-testid="graph-breadcrumb"]').count(); + expect(crumbs, 'breadcrumb is shown after descending').toBe(1); + + // Ascend via the "Up" button. + await page.locator('[data-testid="graph-breadcrumb"] button', { hasText: 'Up' }).click(); + await page.waitForTimeout(4000); + const ascended = await readState(page); + await page.screenshot({ path: path.join(OUT, '3-ascended.png') }); + // eslint-disable-next-line no-console + console.log('[hierarchy] ascended ' + JSON.stringify(ascended)); + expect(ascended.currentGraphId, 'back on the overview after Up').toBe(OVERVIEW_ID); + + fs.writeFileSync( + path.join(OUT, 'report.json'), + JSON.stringify({ overview, descended, ascended }, null, 2) + ); + }); +}); diff --git a/tests/diagnostics/insecure-connection-banner.spec.ts b/tests/diagnostics/insecure-connection-banner.spec.ts new file mode 100644 index 00000000..d3a16b4b --- /dev/null +++ b/tests/diagnostics/insecure-connection-banner.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * The insecure-connection (HTTP) warning must integrate cleanly: a slim strip + * at the very top that reserves its own space (never overlaps the app), and is + * dismissible. Runs over the dev HTTP origin, so the banner is expected. + * Report-only screenshots + hard assertions on placement. + */ +const OUT = path.resolve(process.cwd(), 'test-artifacts/tls-banner'); +const SEL = '[data-testid="insecure-connection-banner"]'; + +async function measure(page: Page) { + return page.evaluate((sel) => { + const b = document.querySelector(sel) as HTMLElement | null; + if (!b) return { present: false } as const; + const r = b.getBoundingClientRect(); + // The first app chrome under the banner: the sidebar or the main content. + const main = document.querySelector('main') as HTMLElement | null; + const sidebar = document.querySelector('nav')?.closest('div') as HTMLElement | null; + const topOfApp = Math.min( + main ? main.getBoundingClientRect().top : Infinity, + sidebar ? sidebar.getBoundingClientRect().top : Infinity + ); + return { + present: true, + top: Math.round(r.top), + bottom: Math.round(r.bottom), + height: Math.round(r.height), + width: Math.round(r.width), + topOfApp: Math.round(topOfApp), + scrollY: Math.round(window.scrollY), + }; + }, SEL); +} + +test.describe('insecure-connection banner @geometry', () => { + test.describe.configure({ timeout: 120_000 }); + + test('renders as a top strip, reserves space (no overlap), dismissible', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + + // Auth page (chrome-less): a pinned strip at the very top. + await page.goto('/signin'); + await page.waitForTimeout(1200); + const auth = await measure(page); + await page.screenshot({ path: path.join(OUT, 'auth-signin.png'), clip: { x: 0, y: 0, width: 1440, height: 220 } }); + expect(auth.present, 'banner shown over HTTP on auth page').toBe(true); + if (auth.present) { + expect(auth.top, 'auth banner pinned to the very top').toBeLessThanOrEqual(1); + expect(auth.height, 'auth banner is a slim strip').toBeLessThan(60); + } + + // In-app: an in-flow strip that pushes the app below it (no overlap, no scroll). + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(3000); + const app = await measure(page); + await page.screenshot({ path: path.join(OUT, 'app-top.png'), clip: { x: 0, y: 0, width: 1440, height: 220 } }); + // eslint-disable-next-line no-console + console.log('[tls-banner] ' + JSON.stringify(app)); + expect(app.present, 'banner shown in-app over HTTP').toBe(true); + if (app.present) { + expect(app.top, 'in-app banner sits at the top').toBeLessThanOrEqual(1); + expect(app.height, 'in-app banner is a slim strip').toBeLessThan(60); + expect(app.scrollY, 'banner must not introduce a page scroll').toBeLessThanOrEqual(1); + expect(app.topOfApp, 'app chrome starts at/below the banner (no overlap)').toBeGreaterThanOrEqual(app.bottom - 1); + } + + // Dismiss reclaims the space. + await page.locator(`${SEL} button[aria-label="Dismiss insecure-connection warning"]`).click(); + await page.waitForTimeout(500); + await page.screenshot({ path: path.join(OUT, 'app-after-dismiss.png'), clip: { x: 0, y: 0, width: 1440, height: 220 } }); + expect(await page.locator(SEL).count(), 'banner gone after dismiss').toBe(0); + }); +}); diff --git a/tests/diagnostics/interaction-audit.spec.ts b/tests/diagnostics/interaction-audit.spec.ts new file mode 100644 index 00000000..461b7a71 --- /dev/null +++ b/tests/diagnostics/interaction-audit.spec.ts @@ -0,0 +1,138 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; +import { sweepTestData, TEST_GRAPH_PREFIX } from '../helpers/dbHealing'; + +/** + * Basic-interaction audit — walks the everyday graph interactions a user does + * and asserts the CORRECT outcome from the real rendered DOM. It exists to make + * "basic" regressions impossible to miss: each check is named after the user + * action it guards, and a failure prints exactly what went wrong. + * + * Covered here (the relationship-editing basics that were visibly broken): + * - changing an edge's relationship TYPE updates its label immediately, with + * no reload (the label used to stay on the old type); + * - flipping an edge's DIRECTION leaves EXACTLY ONE edge for the pair (it + * used to leave a stale duplicate because the delete+recreate kept the same + * edge count and the DOM was never reconciled). + * + * Output: test-artifacts/interaction-audit/report.json + screenshots. Seeds a + * controlled [E2E] graph; healing cleans up even if a run is killed. + */ + +const OUT = path.resolve(process.cwd(), 'test-artifacts/interaction-audit'); + +async function gql(page: Page, query: string, variables?: unknown) { + return page.evaluate(async ({ query, variables }) => { + const token = localStorage.getItem('authToken') ?? ''; + const res = await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query, variables }), + }); + return res.json(); + }, { query, variables }); +} + +async function readEdges(page: Page) { + return page.evaluate(() => { + const edges: Array<{ id: string; rtype: string; label: string; sId: string; tId: string }> = []; + document.querySelectorAll('.graph-container svg .edge').forEach((e) => { + const d = (e as any).__data__; + if (!d) return; + const sId = typeof d.source === 'object' ? d.source?.id : d.source; + const tId = typeof d.target === 'object' ? d.target?.id : d.target; + edges.push({ id: d.id, rtype: (e as Element).getAttribute('data-rtype') ?? d.type, label: '', sId, tId }); + }); + // labels live in a sibling group, keyed by the same datum id + const labelById: Record = {}; + document.querySelectorAll('.graph-container svg .edge-label-group').forEach((g) => { + const d = (g as any).__data__; + const txt = (g.querySelector('.edge-label') as Element | null)?.textContent ?? ''; + if (d?.id) labelById[d.id] = txt; + }); + for (const e of edges) e.label = labelById[e.id] ?? ''; + return edges; + }); +} + +test.describe('interaction audit @geometry', () => { + test.describe.configure({ timeout: 180_000 }); + test.beforeAll(async () => { await sweepTestData('audit:before'); }); + test.afterAll(async () => { await sweepTestData('audit:after'); }); + + test('relationship type change + flip direction', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + // Seed one PINNED horizontal edge so the label is on-screen and clickable. + const me = await gql(page, '{ me { id } }'); + const userId = me.data.me.id; + const g = await gql(page, `mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, + { i: [{ name: `${TEST_GRAPH_PREFIX} Audit ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] }); + const graphId = g.data.createGraphs.graphs[0].id; + + const nodeDefs = [{ key: 'A', x: -190, y: 0 }, { key: 'B', x: 190, y: 0 }]; + const created = await gql(page, `mutation($i:[WorkItemCreateInput!]!){createWorkItems(input:$i){workItems{id title}}}`, + { i: nodeDefs.map((n) => ({ type: 'TASK', title: n.key, status: 'IN_PROGRESS', priority: 0.5, positionX: n.x, positionY: n.y, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } })) }); + const ids: Record = {}; + for (const w of created.data.createWorkItems.workItems) ids[w.title] = w.id; + + await gql(page, `mutation($i:[EdgeCreateInput!]!){createEdges(input:$i){edges{id}}}`, + { i: [{ type: 'DEPENDS_ON', weight: 0.6, source: { connect: { where: { node: { id: ids.A } } } }, target: { connect: { where: { node: { id: ids.B } } } } }] }); + + await page.evaluate((gid) => { localStorage.setItem('currentGraphId', gid); localStorage.setItem('graphdone.quality.override', 'HIGH'); }, graphId); + await page.reload(); + await page.waitForTimeout(7000); + await page.evaluate(() => (window as any).miniMapNavigate?.(0, 0)); + await page.waitForTimeout(1000); + + const results: Record = {}; + + // ── Baseline ───────────────────────────────────────────────────────── + let edges = await readEdges(page); + results.baseline = { edgeCount: edges.length, label: edges[0]?.label, rtype: edges[0]?.rtype }; + await page.screenshot({ path: path.join(OUT, '1-baseline.png') }); + expect(edges.length, 'one edge rendered at baseline').toBe(1); + expect(edges[0].label, 'baseline label is the DEPENDS_ON label').toBe('Depends On'); + + // ── Interaction 1: change relationship type → label updates immediately ─ + await page.locator('.graph-container svg .edge-label-group').first().click({ force: true }); + await page.waitForTimeout(800); + await page.locator('button', { hasText: /^Blocks$/ }).first().click(); + await page.waitForTimeout(2500); // mutation + refetch + re-render (no reload) + await page.screenshot({ path: path.join(OUT, '2-after-type-change.png') }); + edges = await readEdges(page); + results.afterTypeChange = { edgeCount: edges.length, label: edges[0]?.label, rtype: edges[0]?.rtype }; + expect(edges.length, 'still exactly one edge after type change').toBe(1); + expect(edges[0].label, 'label updates to Blocks immediately, no reload').toBe('Blocks'); + + // ── Interaction 2: flip direction → exactly one edge remains ──────────── + const beforeFlip = edges[0]; + await page.locator('.graph-container svg .edge-label-group').first().click({ force: true }); + await page.waitForTimeout(800); + await page.getByRole('button', { name: /Flip Direction/ }).click(); + await page.waitForTimeout(3000); // delete + create + refetch + re-render + await page.screenshot({ path: path.join(OUT, '3-after-flip.png') }); + edges = await readEdges(page); + results.afterFlip = { + edgeCount: edges.length, + label: edges[0]?.label, + directionSwapped: edges[0] ? (edges[0].sId === beforeFlip.tId && edges[0].tId === beforeFlip.sId) : false, + }; + expect(edges.length, 'flip leaves EXACTLY ONE edge (no duplicate)').toBe(1); + expect(edges[0].label, 'flipped edge keeps its Blocks label').toBe('Blocks'); + + fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(results, null, 2)); + // eslint-disable-next-line no-console + console.log('[interaction-audit] ' + JSON.stringify(results)); + + // Cleanup (edges before work items — orphan edges break the edges query). + await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: graphId }); + }); +}); diff --git a/tests/diagnostics/large-graph-profile.spec.ts b/tests/diagnostics/large-graph-profile.spec.ts new file mode 100644 index 00000000..0749b07c --- /dev/null +++ b/tests/diagnostics/large-graph-profile.spec.ts @@ -0,0 +1,162 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * BASELINE profiler for the Compute Core (1000-node) example graph the user + * called out as low-FPS. Report-only: measures idle FPS, drag-interaction FPS, + * zoom-interaction FPS, and a DOM-weight breakdown, plus where per-frame time + * goes (long-task sampling). Writes one JSON under test-artifacts/large-graph/. + */ +const COMPUTE_GRAPH_ID = 'subgraph-compute-shared'; +const OUT = path.resolve(process.cwd(), 'test-artifacts/large-graph'); + +async function openGraph(page: Page, gid: string, quality: string) { + await page.evaluate( + ({ g, q }) => { + localStorage.setItem('currentGraphId', g); + localStorage.setItem('graphdone.quality.override', q); + }, + { g: gid, q: quality } + ); + await page.reload(); + await page.locator('.graph-container svg .node').first().waitFor({ timeout: 60_000 }).catch(() => {}); + await page.waitForTimeout(8000); // let one-shot physics settle +} + +async function rafFps(page: Page, ms: number): Promise { + await page.evaluate(() => { + (window as any).__fc = 0; + const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; + (window as any).__rafId = requestAnimationFrame(loop); + }); + await page.waitForTimeout(ms); + const frames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + return Math.round((frames / (ms / 1000)) * 10) / 10; +} + +test.describe('large-graph baseline profile @geometry', () => { + // Serial: two browsers rendering a 1000-node graph at once thrash the CPU and + // make the FPS numbers meaningless. One at a time. + test.describe.configure({ timeout: 180_000, mode: 'serial' }); + + for (const quality of ['HIGH', 'LOW']) { + test(`compute-core profile @${quality}`, async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1920, height: 1080 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openGraph(page, COMPUTE_GRAPH_ID, quality); + + const renderedNodes = await page.locator('.graph-container svg .node').count(); + const renderedEdges = await page.locator('.graph-container svg .edge').count(); + const dataDense = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-dense') ?? null); + const dataSimplify = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-simplify') ?? null); + const dataDots = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-dots') ?? null); + const paintedEls = await page.evaluate(() => Array.from(document.querySelectorAll('.graph-container svg .node-bg, .graph-container svg .edge')).filter((e) => getComputedStyle(e).display !== 'none').length); + const paintedDetail = await page.evaluate(() => { + const sel = '.graph-container svg .node-title-bar, .graph-container svg .status-progress-bg, .graph-container svg .priority-progress-bg, .graph-container svg .node-type-text'; + return Array.from(document.querySelectorAll(sel)).filter((e) => getComputedStyle(e).display !== 'none').length; + }); + + // DOM weight: total SVG elements, per-node element count, CSS filter usage. + const dom = await page.evaluate(() => { + const svg = document.querySelector('.graph-container svg'); + const all = svg ? svg.querySelectorAll('*').length : 0; + const nodes = document.querySelectorAll('.graph-container svg .node').length; + const texts = svg ? svg.querySelectorAll('text').length : 0; + const fos = svg ? svg.querySelectorAll('foreignObject').length : 0; + const filtered = document.querySelectorAll('.graph-container [style*="filter"], .graph-container [filter]').length; + const blur = Array.from(document.querySelectorAll('.graph-container *')).filter((e) => { + const s = getComputedStyle(e as Element); + return s.backdropFilter !== 'none' || s.filter !== 'none'; + }).length; + return { totalSvgEls: all, nodes, texts, foreignObjects: fos, inlineFilterEls: filtered, blurOrFilterEls: blur, perNodeEls: nodes ? Math.round(all / nodes) : 0 }; + }); + + // Idle FPS (nothing happening, physics stopped). + const idleFps = await rafFps(page, 3000); + + // Drag-interaction FPS: hold a node and move it for 5s (sim ticks while dragging). + const box = await page.evaluate(() => { + const n = document.querySelector('.graph-container svg .node .node-bg') as Element | null; + if (!n) return null; + const r = n.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + let dragFps = -1; + if (box) { + await page.evaluate(() => { (window as any).__fc = 0; const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; (window as any).__rafId = requestAnimationFrame(loop); }); + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + const t = Date.now(); + let a = 0; + while (Date.now() - t < 5000) { a += 0.6; await page.mouse.move(box.x + Math.cos(a) * 80, box.y + Math.sin(a) * 60); await page.waitForTimeout(110); } + await page.mouse.up(); + const frames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + dragFps = Math.round((frames / ((Date.now() - t) / 1000)) * 10) / 10; + } + + // Pan-interaction FPS: drag the empty top-left background (clear of the + // centered cloud at fit view). This is the paint-bound whole-graph metric. + await page.evaluate(() => { (window as any).__fc = 0; const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; (window as any).__rafId = requestAnimationFrame(loop); }); + await page.mouse.move(150, 150); + await page.mouse.down(); + const pt = Date.now(); + let pang = 0; + while (Date.now() - pt < 4000) { pang += 0.5; await page.mouse.move(150 + Math.cos(pang) * 80, 150 + Math.sin(pang) * 80); await page.waitForTimeout(16); } + await page.mouse.up(); + const pframes = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + const panFps = Math.round((pframes / ((Date.now() - pt) / 1000)) * 10) / 10; + + // Zoom-interaction FPS: wheel-zoom repeatedly for 4s. + await page.mouse.move(960, 540); + await page.evaluate(() => { (window as any).__fc = 0; const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; (window as any).__rafId = requestAnimationFrame(loop); }); + const zt = Date.now(); + let dir = -1; + while (Date.now() - zt < 4000) { dir = -dir; await page.mouse.wheel(0, dir * 120); await page.waitForTimeout(60); } + const zframes = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + const zoomFps = Math.round((zframes / ((Date.now() - zt) / 1000)) * 10) / 10; + + // Zoomed-IN drag: zoom in hard so most of the graph is off-screen, then + // drag. This is where viewport culling should help (the fit-view drag above + // keeps every node on screen, so culling can't help there). + await page.mouse.move(960, 540); + for (let i = 0; i < 10; i++) { await page.mouse.wheel(0, 200); await page.waitForTimeout(60); } + await page.waitForTimeout(500); + const zoomState = await page.evaluate(() => { + const g = document.querySelector('.graph-container svg g'); + const tr = g?.getAttribute('transform') ?? ''; + const m = tr.match(/scale\(([0-9.]+)\)/); + const nodes = Array.from(document.querySelectorAll('.graph-container svg .node')); + const hidden = nodes.filter((n) => getComputedStyle(n).display === 'none').length; + return { transform: tr.slice(0, 60), scale: m ? parseFloat(m[1]) : null, hidden, total: nodes.length }; + }); + const culledHidden = zoomState.hidden; + // eslint-disable-next-line no-console + console.log(`[profile] ${quality} zoomState: scale=${zoomState.scale} hidden=${zoomState.hidden}/${zoomState.total} tr="${zoomState.transform}"`); + const zinBox = await page.evaluate(() => { + const n = document.querySelector('.graph-container svg .node .node-bg') as Element | null; + if (!n) return { x: 960, y: 540 }; + const r = n.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + await page.evaluate(() => { (window as any).__fc = 0; const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; (window as any).__rafId = requestAnimationFrame(loop); }); + await page.mouse.move(zinBox.x, zinBox.y); + await page.mouse.down(); + const zt2 = Date.now(); + let aa = 0; + while (Date.now() - zt2 < 4000) { aa += 0.6; await page.mouse.move(zinBox.x + Math.cos(aa) * 60, zinBox.y + Math.sin(aa) * 45); await page.waitForTimeout(110); } + await page.mouse.up(); + const zinFrames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + const zoomedInDragFps = Math.round((zinFrames / ((Date.now() - zt2) / 1000)) * 10) / 10; + + const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, dataSimplify, dataDots, paintedEls, paintedDetail, renderedNodes, renderedEdges, idleFps, panFps, dragFps, zoomFps, zoomedInDragFps, culledHidden, dom }; + fs.writeFileSync(path.join(OUT, `compute-${quality}.json`), JSON.stringify(result, null, 2)); + // eslint-disable-next-line no-console + console.log(`[profile] ${quality}: simplify=${dataSimplify} dots=${dataDots} paintedEls=${paintedEls} idleFps=${idleFps} panFps=${panFps} zoomFps=${zoomFps} dragFps=${dragFps} zoomInDragFps=${zoomedInDragFps}`); + expect(renderedNodes, 'compute core renders nodes').toBeGreaterThan(0); + }); + } +}); diff --git a/tests/diagnostics/minimap-zoom.spec.ts b/tests/diagnostics/minimap-zoom.spec.ts new file mode 100644 index 00000000..518984a7 --- /dev/null +++ b/tests/diagnostics/minimap-zoom.spec.ts @@ -0,0 +1,58 @@ +import { test, expect, Page } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Minimap wheel/pinch zoom: a wheel over the minimap zooms the MAIN view + * (centered on the gesture point). Asserts the main view's zoom scale changes. + */ +const GRAPH_ID = 'subgraph-power-shared'; + +async function mainScale(page: Page): Promise { + return page.evaluate(() => { + const g = document.querySelector('.graph-container svg .main-graph-group') as SVGGElement | null; + const t = g?.getAttribute('transform') || ''; + const m = /scale\(([-0-9.]+)/.exec(t); + return m ? parseFloat(m[1]) : 1; + }); +} + +test.describe('minimap zoom diagnostic @geometry', () => { + test.describe.configure({ timeout: 120_000 }); + + test('wheel on the minimap changes the main view zoom', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await page.evaluate((gid) => { + localStorage.setItem('currentGraphId', gid); + localStorage.setItem('graphdone.quality.override', 'HIGH'); + }, GRAPH_ID); + await page.reload(); + await page.waitForTimeout(6000); + + const mini = page.locator('[data-testid="mini-map"]'); + await mini.waitFor({ timeout: 15000 }); + const box = await mini.boundingBox(); + expect(box, 'minimap is visible').not.toBeNull(); + + const cx = box!.x + box!.width / 2; + const cy = box!.y + box!.height / 2; + await page.mouse.move(cx, cy); + const before = await mainScale(page); + + // Zoom IN: real (trusted) wheel events at the minimap centre. + for (let i = 0; i < 3; i++) { await page.mouse.wheel(0, -120); await page.waitForTimeout(250); } + await page.waitForTimeout(400); + const afterIn = await mainScale(page); + + // Zoom OUT. + for (let i = 0; i < 4; i++) { await page.mouse.wheel(0, 120); await page.waitForTimeout(250); } + await page.waitForTimeout(400); + const afterOut = await mainScale(page); + + // eslint-disable-next-line no-console + console.log(`[minimap-zoom] before=${before} afterIn=${afterIn} afterOut=${afterOut}`); + expect(afterIn, 'wheel-in increases main zoom').toBeGreaterThan(before); + expect(afterOut, 'wheel-out decreases zoom below the zoomed-in level').toBeLessThan(afterIn); + }); +}); diff --git a/tests/diagnostics/node-inspector.spec.ts b/tests/diagnostics/node-inspector.spec.ts new file mode 100644 index 00000000..1750586c --- /dev/null +++ b/tests/diagnostics/node-inspector.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, Page } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Docked inspector: selecting a node opens it; Contents renders its description + * as readable markdown; Diagram renders the sub-graph; modes switch explicitly + * (not via zoom). Needs the hierarchy demo seeded (System Overview). + */ +const OVERVIEW_ID = 'overview-graph-shared'; + +async function openOverview(page: Page) { + await page.evaluate((gid) => { + localStorage.setItem('currentGraphId', gid); + localStorage.setItem('graphdone.quality.override', 'HIGH'); + }, OVERVIEW_ID); + await page.reload(); + await page.waitForTimeout(6000); +} + +test.describe('node inspector diagnostic @geometry', () => { + test.describe.configure({ timeout: 120_000 }); + + test('select node → inspector Contents + Diagram + Card modes', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openOverview(page); + + // Plain-click a sheet node's card → it SELECTS (no longer descends). + const box = await page.evaluate(() => { + const sheet = [...document.querySelectorAll('.graph-container svg .node')].find( + (n) => (n as any).__data__?.subgraphId + ) as SVGGElement | undefined; + const bg = sheet?.querySelector('.node-bg') as Element | undefined; + if (!bg) return null; + const r = bg.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + expect(box, 'found a sheet node on screen').not.toBeNull(); + await page.mouse.click(box!.x, box!.y); + await page.waitForTimeout(1800); + + const inspector = page.locator('[data-testid="node-inspector"]'); + await expect(inspector, 'inspector opens on node select').toBeVisible({ timeout: 8000 }); + + // Contents (default for a node with a description) renders markdown. + const contents = page.locator('[data-testid="node-content-rendered"]'); + await expect(contents, 'Contents renders the description').toBeVisible({ timeout: 8000 }); + const contentsText = await contents.innerText(); + expect(contentsText.length, 'contents has readable text').toBeGreaterThan(5); + + // Switch to Diagram → static sub-graph preview renders. + await inspector.getByRole('button', { name: 'Diagram' }).click(); + await page.waitForTimeout(2000); + // eslint-disable-next-line no-console + console.log('[node-inspector] diagram pane: ' + (await inspector.innerText()).slice(0, 160).replace(/\n/g, ' ')); + await expect(page.locator('[data-testid="subgraph-preview"]'), 'Diagram renders the sub-graph').toBeVisible({ timeout: 15000 }); + + // Switch to Card → summary rows. + await inspector.getByRole('button', { name: 'Card' }).click(); + await expect(inspector.getByText('Type', { exact: true }), 'Card shows the summary').toBeVisible({ timeout: 5000 }); + + // Legibility independent of zoom: zoom the canvas way out, inspector text stays. + await page.mouse.move(400, 400); + for (let i = 0; i < 5; i++) { await page.mouse.wheel(0, 240); await page.waitForTimeout(100); } + await inspector.getByRole('button', { name: 'Contents', exact: true }).click(); + await expect(page.locator('[data-testid="node-content-rendered"]'), 'contents readable regardless of zoom').toBeVisible(); + + // eslint-disable-next-line no-console + console.log('[node-inspector] ok — inspector + Contents/Diagram/Card verified'); + }); +}); diff --git a/tests/diagnostics/physics-settle.spec.ts b/tests/diagnostics/physics-settle.spec.ts new file mode 100644 index 00000000..ea29827b --- /dev/null +++ b/tests/diagnostics/physics-settle.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Physics-lifecycle diagnostic — proves the one-shot model: a graph settles, + * then the simulation goes IDLE and stays idle (no continuous drift), and the + * manual Organize reflow re-settles a piled graph. Captures the full metric + * time series (window.__layoutMetrics) so the behaviour can be studied deeply + * and skeptically. Report-only; needs the hierarchy demo seeded. + */ +const OUT = path.resolve(process.cwd(), 'test-artifacts/physics'); +// A mid-size sub-graph (varies the load); change via env if desired. +const GRAPH_ID = process.env.PHYS_GRAPH_ID || 'subgraph-power-shared'; + +async function openGraph(page: Page, id: string) { + await page.evaluate((gid) => { + localStorage.setItem('currentGraphId', gid); + localStorage.setItem('graphdone.quality.override', 'HIGH'); + }, id); + await page.reload(); + await page.waitForTimeout(6000); +} + +async function metrics(page: Page) { + return page.evaluate(() => (window as any).__layoutMetrics?.() ?? null); +} + +async function sampleSeries(page: Page, label: string, seconds: number) { + const series: any[] = []; + for (let i = 0; i < seconds; i++) { + series.push({ t: i, ...(await metrics(page)) }); + await page.waitForTimeout(1000); + } + // eslint-disable-next-line no-console + console.log(`[physics ${label}] ` + JSON.stringify(series[series.length - 1])); + return series; +} + +test.describe('physics settle diagnostic @geometry', () => { + test.describe.configure({ timeout: 180_000 }); + + test('graph settles then stays idle (no drift); Organize reflows', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openGraph(page, GRAPH_ID); + + // After the initial settle, sample for 8s: the sim must be IDLE and nodes + // must NOT keep moving (the perpetual-reheat bug would show movingNodes>0). + const settleSeries = await sampleSeries(page, 'settled', 8); + const last4 = settleSeries.slice(-4); + const maxMoving = Math.max(...last4.map((s) => s.drift?.movingNodes ?? 0)); + const anyRunning = last4.some((s) => s.simRunning); + + // Organize: reflow a (possibly piled) graph with physics, then it settles + // and STOPS. Poll the live signals (alpha-based atRest + true overlap) — the + // drift field goes stale once the sim stops ticking, so don't rely on it. + await page.evaluate(() => (window as any).__organizeGraph?.()); + const organizeSeries: any[] = []; + let lastAfter: any = null; + for (let i = 0; i < 30; i++) { + await page.waitForTimeout(1000); + lastAfter = await metrics(page); + organizeSeries.push({ t: i, ...lastAfter }); + if (lastAfter?.atRest && i >= 2) break; // settled + stopped + } + // eslint-disable-next-line no-console + console.log('[physics after-organize] ' + JSON.stringify(lastAfter)); + + const report = { + graphId: GRAPH_ID, + settleSeries, + organizeSeries, + summary: { + idleAfterSettle: !anyRunning, + maxMovingNodesLast4s: maxMoving, + overlapBefore: settleSeries[settleSeries.length - 1]?.overlappingNodePairs, + overlapAfterOrganize: lastAfter?.overlappingNodePairs, + labelOverlapAfter: lastAfter?.overlappingLabelPairs, + settledAndStopped: lastAfter?.atRest, + settleSeconds: organizeSeries.length, + lastSettleMs: lastAfter?.lastSettleMs, + }, + }; + fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 2)); + // eslint-disable-next-line no-console + console.log('[physics] summary ' + JSON.stringify(report.summary)); + + // No continuous drift after the initial settle (the poll-reheat fix). + expect(maxMoving, 'no continuous drift: nodes stop moving after settle').toBeLessThanOrEqual(1); + expect(anyRunning, 'simulation is idle after settle (not reheating)').toBe(false); + // Organize lays the graph out CLEAN (zero true card overlaps) and the sim + // comes to a full STOP (one-shot physics, then disabled). + expect(lastAfter.overlappingNodePairs, 'Organize settles to zero card overlaps').toBe(0); + expect(lastAfter.atRest, 'simulation reaches rest (stops) after Organize').toBe(true); + }); +}); diff --git a/tests/e2e/visual-vlm.spec.ts b/tests/e2e/visual-vlm.spec.ts new file mode 100644 index 00000000..e5ca9673 --- /dev/null +++ b/tests/e2e/visual-vlm.spec.ts @@ -0,0 +1,124 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; +import { seedLargeGraph, deleteGraphDeep } from '../helpers/seedGraph'; +import { sweepTestData, TEST_GRAPH_PREFIX } from '../helpers/dbHealing'; +import '../helpers/testEnv'; +import { isVlmAvailable, evaluateBatch, PERSONAS, personaByKey } from '../helpers/vlm'; + +/** + * Local-VLM visual evaluation. Captures key user-facing states, then asks a + * locally-hosted vision model to judge each one from four perspectives + * (visual defects, new-user clarity, accessibility, living-graph aliveness). + * + * Report-only: it never fails on a model's subjective verdict — it writes + * test-artifacts/vlm/results.json for `npm run report:vlm` and prints a + * summary. It only asserts the VLM actually answered (so a broken client is + * still caught). Skips entirely when no VLM endpoint is configured/reachable + * (VLM_ENDPOINTS in .env.test.local), so CI stays green. + */ + +const SHOT_DIR = path.resolve(process.cwd(), 'test-artifacts/vlm/shots'); +const OUT = path.resolve(process.cwd(), 'test-artifacts/vlm/results.json'); +const SCALE_DIR = path.resolve(process.cwd(), 'test-artifacts/scale-sweep'); + +interface Capture { file: string; context: string; personas: string[] } + +async function shot(page: Page, name: string): Promise { + fs.mkdirSync(SHOT_DIR, { recursive: true }); + const file = path.join(SHOT_DIR, `${name}.png`); + await page.screenshot({ path: file, fullPage: false }).catch(() => {}); + return file; +} + +// Self-heal: clear leftover test graphs + orphans before and after, so an +// interrupted run never leaves the dev DB dirty (which can break THE GATE). +test.beforeAll(async () => { await sweepTestData('vlm:before'); }); +test.afterAll(async () => { await sweepTestData('vlm:after'); }); + +test('VLM visual evaluation across personas @vlm', async ({ page }) => { + test.setTimeout(900_000); + const available = await isVlmAvailable(); + test.skip(!available, 'No reachable VLM endpoint (set VLM_ENDPOINTS in .env.test.local)'); + + const allPersonas = PERSONAS.map((p) => p.key); + const captures: Capture[] = []; + const cleanup: string[] = []; + + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + // 1. Empty graph — first-run invitation (new-user + visual defects). + const empty = await page.evaluate(async (pfx) => { + const token = localStorage.getItem('authToken') ?? ''; + const post = (query: string, variables?: unknown) => + fetch('/api/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }) }).then((r) => r.json()); + const me = await post('{ me { id } }'); + const g = await post(`mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, { i: [{ name: `${pfx} VLM Empty ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: me.data.me.id, isShared: true }] }); + return g.data.createGraphs.graphs[0].id as string; + }, TEST_GRAPH_PREFIX); + cleanup.push(empty); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.evaluate((id) => localStorage.setItem('currentGraphId', id), empty); + await page.reload(); + await page.waitForTimeout(5000); + captures.push({ file: await shot(page, 'empty-graph-desktop'), context: 'the first-run empty-state of a brand-new project graph in GraphDone, a graph-based task manager', personas: ['visual-defects', 'new-user', 'accessibility'] }); + + // 2. Populated graph at ULTRA quality — full living-graph experience. + const seeded = await seedLargeGraph(page, { size: 60, namePrefix: 'VLM' }); + cleanup.push(seeded.graphId); + await page.evaluate((id) => { localStorage.setItem('currentGraphId', id); localStorage.setItem('graphdone.quality.override', 'ULTRA'); }, seeded.graphId); + await page.reload(); + await page.waitForTimeout(8000); // let it settle + effects run + captures.push({ file: await shot(page, 'populated-desktop'), context: 'a populated project graph (~60 work items) with dependency edges; nodes glow by priority and animate by status (in-progress breathes, blocked aches, complete settles)', personas: allPersonas }); + + // 3. Same graph on a phone viewport — accessibility + new-user on mobile. + await page.setViewportSize({ width: 393, height: 852 }); + await page.reload(); + await page.waitForTimeout(6000); + captures.push({ file: await shot(page, 'populated-mobile'), context: 'the same project graph viewed on a phone-sized screen (393x852)', personas: ['visual-defects', 'new-user', 'accessibility'] }); + + // 4. Bonus: judge the SINGLE largest scale-sweep frame for density/legibility + // (those frames are 1920px and slow on the model; one is enough signal). + if (fs.existsSync(SCALE_DIR)) { + const largest = fs.readdirSync(SCALE_DIR) + .filter((f) => f.endsWith('.png')) + .map((f) => ({ f, size: parseInt(f, 10) || 0 })) + .sort((a, b) => b.size - a.size)[0]; + if (largest) { + captures.push({ file: path.join(SCALE_DIR, largest.f), context: `a large graph rendered at scale (${largest.size} nodes) — judge whether it stays legible at this density`, personas: ['visual-defects'] }); + } + } + + // Build and run the persona jobs. + const jobs = captures.flatMap((c) => + c.personas + .map((pk) => personaByKey(pk)) + .filter((p): p is NonNullable => Boolean(p)) + .map((persona) => ({ imagePath: c.file, persona, context: c.context, meta: { capture: path.basename(c.file) } })) + ); + + let results: Awaited> = []; + try { + results = await evaluateBatch(jobs); + } finally { + for (const id of cleanup) await deleteGraphDeep(page, id); + } + + fs.mkdirSync(path.dirname(OUT), { recursive: true }); + fs.writeFileSync(OUT, JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2)); + + const fails = results.filter((r) => !r.verdict.pass); + // eslint-disable-next-line no-console + console.log(`[vlm] ${results.length} evaluations, ${results.length - fails.length} pass, ${fails.length} flagged:`); + for (const f of fails) { + // eslint-disable-next-line no-console + console.log(` ⚠️ [${f.persona}] ${f.meta?.capture}: ${f.verdict.summary || f.verdict.issues.join('; ')}`); + } + + // Report-only: we assert the VLM produced answers, not what it concluded. + expect(results.length, 'VLM returned evaluations').toBeGreaterThan(0); + const answered = results.filter((r) => !r.verdict.issues.some((i) => i.startsWith('VLM request failed') || i.startsWith('No reachable'))); + expect(answered.length, 'at least some VLM calls succeeded').toBeGreaterThan(0); +}); diff --git a/tests/generate-perf-report.mjs b/tests/generate-perf-report.mjs new file mode 100644 index 00000000..8cd78d82 --- /dev/null +++ b/tests/generate-perf-report.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * Renders the large-scale perf sweep into a single self-contained page: + * test-artifacts/scale-sweep/index.html + * + * Input: test-artifacts/scale-sweep/n-.json (from scale-sweep.spec.ts) + * Output: an HTML table of every metric plus inline SVG line charts (no deps, + * no external assets) showing how settle time, tick cost, FPS, drift and query + * latency scale with graph size, per quality tier. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +const DIR = path.resolve(process.cwd(), 'test-artifacts/scale-sweep'); +const OUT = path.join(DIR, 'index.html'); + +if (!fs.existsSync(DIR)) { + console.error(`No sweep results at ${DIR} — run "npm run test:perf:scale" first.`); + process.exit(1); +} + +const rows = fs + .readdirSync(DIR) + .filter((f) => f.endsWith('.json')) + .map((f) => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8'))) + .sort((a, b) => a.size - b.size || String(a.quality).localeCompare(b.quality)); + +if (rows.length === 0) { + console.error('No JSON sweep results found.'); + process.exit(1); +} + +const qualities = [...new Set(rows.map((r) => r.quality))]; +const sizes = [...new Set(rows.map((r) => r.size))].sort((a, b) => a - b); +const COLORS = ['#34d399', '#60a5fa', '#f472b6', '#fbbf24', '#a78bfa']; + +const num = (v) => (typeof v === 'number' && v >= 0 ? v : null); + +function lineChart(title, key, { unit = '', budget = null } = {}) { + const W = 560, H = 260, PADL = 56, PADB = 36, PADT = 28, PADR = 16; + const series = qualities.map((q) => ({ + q, + pts: sizes.map((s) => { + const row = rows.find((r) => r.size === s && r.quality === q); + return { x: s, y: row ? num(row[key]) : null }; + }).filter((p) => p.y !== null), + })).filter((s) => s.pts.length); + const allY = series.flatMap((s) => s.pts.map((p) => p.y)).concat(budget != null ? [budget] : []); + if (allY.length === 0) return ''; + const maxY = Math.max(...allY) * 1.1 || 1; + const maxX = Math.max(...sizes); + const minX = Math.min(...sizes); + const sx = (x) => PADL + ((x - minX) / (maxX - minX || 1)) * (W - PADL - PADR); + const sy = (y) => H - PADB - (y / maxY) * (H - PADT - PADB); + + const grid = [0, 0.25, 0.5, 0.75, 1].map((f) => { + const y = sy(maxY * f); + return `${Math.round(maxY * f)}`; + }).join(''); + const xticks = sizes.map((s) => `${s}`).join(''); + const budgetLine = budget != null ? `budget ${budget}${unit}` : ''; + const lines = series.map((s, i) => { + const c = COLORS[qualities.indexOf(s.q) % COLORS.length]; + const d = s.pts.map((p, j) => `${j === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' '); + const dots = s.pts.map((p) => `${s.q} @ ${p.x}n: ${p.y}${unit}`).join(''); + return `${dots}`; + }).join(''); + const legend = series.map((s, i) => { + const c = COLORS[qualities.indexOf(s.q) % COLORS.length]; + return `● ${s.q}`; + }).join('  '); + return `

    ${title}

    ${legend}
    ${grid}${xticks}${budgetLine}${lines}graph size (nodes)
    `; +} + +const HEADERS = [ + ['size', 'nodes'], ['quality', 'quality'], ['renderedNodes', 'rendered n'], ['renderedEdges', 'rendered e'], + ['loadMs', 'load ms'], ['interactionFps', 'drag fps'], ['settleMs', 'settle ms'], ['finalAlpha', 'alpha'], + ['avgTickMs', 'tick ms'], ['p95TickMs', 'tick p95'], ['rmsFromSavedPx', 'drift px'], + ['queryP95Ms', 'query p95'], +]; +const tableRows = rows.map((r) => `${HEADERS.map(([k]) => `${r[k] === null ? '—' : r[k]}`).join('')}`).join(''); + +const html = `GraphDone — Large-Scale Perf Sweep + +

    GraphDone — Large-Scale Graph Performance Sweep

    +

    ${rows.length} runs · sizes ${sizes.join(', ')} · qualities ${qualities.join(', ')} · generated ${new Date().toISOString()}

    +
    +${lineChart('Interaction FPS vs size (drag)', 'interactionFps', { unit: '' })} +${lineChart('Initial load vs size', 'loadMs', { unit: 'ms' })} +${lineChart('Avg simulation tick vs size', 'avgTickMs', { unit: 'ms', budget: 8 })} +${lineChart('Settle time vs size', 'settleMs', { unit: 'ms' })} +${lineChart('Layout drift vs size', 'rmsFromSavedPx', { unit: 'px', budget: 25 })} +${lineChart('Query p95 latency vs size', 'queryP95Ms', { unit: 'ms', budget: 800 })} +
    +

    All metrics

    +${HEADERS.map(([, h]) => ``).join('')}${tableRows}
    ${h}
    +

    Report-only. Budgets shown (red dashed) mirror the @perf gate; this sweep characterises how they scale, it does not enforce them.

    +`; + +fs.writeFileSync(OUT, html); +console.log(`✅ Perf sweep report: ${OUT} (${rows.length} runs)`); diff --git a/tests/generate-vlm-report.mjs b/tests/generate-vlm-report.mjs new file mode 100644 index 00000000..819a63a0 --- /dev/null +++ b/tests/generate-vlm-report.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Renders local-VLM visual evaluations into one self-contained gallery: + * test-artifacts/vlm/index.html + * + * Input: test-artifacts/vlm/results.json (from visual-vlm.spec.ts) + * Output: each captured screenshot with a card per persona verdict + * (pass/flag badge, 0-1 score, summary, issues). No deps, no external assets. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +const VLM_DIR = path.resolve(process.cwd(), 'test-artifacts/vlm'); +const RESULTS = path.join(VLM_DIR, 'results.json'); +const OUT = path.join(VLM_DIR, 'index.html'); + +if (!fs.existsSync(RESULTS)) { + console.error(`No VLM results at ${RESULTS} — run "npm run test:vlm" (with VLM_ENDPOINTS set) first.`); + process.exit(1); +} + +const { generatedAt, results } = JSON.parse(fs.readFileSync(RESULTS, 'utf8')); +const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); + +// Group verdicts by the screenshot they judged. +const byCapture = new Map(); +for (const r of results) { + const key = r.imagePath; + if (!byCapture.has(key)) byCapture.set(key, { imagePath: r.imagePath, context: r.context, verdicts: [] }); + byCapture.get(key).verdicts.push(r); +} + +const total = results.length; +const passed = results.filter((r) => r.verdict.pass).length; +const avgScore = total ? (results.reduce((a, r) => a + (r.verdict.score || 0), 0) / total).toFixed(2) : '—'; + +const sections = [...byCapture.values()].map((cap) => { + const rel = path.relative(VLM_DIR, cap.imagePath).split(path.sep).join('/'); + const cards = cap.verdicts.map((r) => { + const v = r.verdict; + const cls = v.pass ? 'pass' : 'flag'; + const issues = v.issues?.length ? `
      ${v.issues.map((i) => `
    • ${esc(i)}
    • `).join('')}
    ` : ''; + const model = (v.model || '').replace(/\.gguf$/, '').slice(0, 28); + const host = (v.endpoint || '').replace(/^https?:\/\//, ''); + const foot = (v.endpoint || v.latencyMs) ? `
    ${esc(model)} @ ${esc(host)}${v.latencyMs ? ` · ${(v.latencyMs / 1000).toFixed(1)}s` : ''}
    ` : ''; + return `
    +
    ${v.pass ? 'PASS' : 'FLAG'} + ${esc(r.persona)}score ${Number(v.score ?? 0).toFixed(2)}
    +

    ${esc(v.summary)}

    ${issues}${foot}
    `; + }).join(''); + return `
    +
    ${esc(path.basename(cap.imagePath))}
    ${esc(path.basename(cap.imagePath))}

    ${esc(cap.context)}

    +
    ${cards}
    +
    `; +}).join(''); + +const html = `GraphDone — Local VLM Visual Review + +

    GraphDone — Local VLM Visual Review

    +
    ${passed}/${total} persona checks passed · avg score ${avgScore}
    +generated ${esc(generatedAt)} · evaluated by a local vision model
    +${sections} +

    Report-only. "FLAG" is the model's subjective concern from one perspective, not a hard failure — use it to spot real UX/rendering regressions worth a human look.

    +`; + +fs.writeFileSync(OUT, html); +console.log(`✅ VLM review report: ${OUT} (${total} evaluations, ${passed} pass)`); diff --git a/tests/helpers/dbHealing.ts b/tests/helpers/dbHealing.ts new file mode 100644 index 00000000..eb286cfa --- /dev/null +++ b/tests/helpers/dbHealing.ts @@ -0,0 +1,106 @@ +import neo4j, { Driver } from 'neo4j-driver'; + +/** + * Self-healing for the dev Neo4j so test runs never leave the database dirty — + * even when a run is killed mid-flight (timeout, Ctrl-C) and its per-test + * cleanup never executes. + * + * Heavy suites (scale-sweep, visual-vlm) call sweepTestData() in beforeAll + * (heal leftovers from a previous interrupted run) AND afterAll (clean up this + * run). It removes: + * - graphs whose name carries the test sentinel (or a legacy test prefix), + * with their WorkItems and Edge nodes, + * - orphan WorkItems (no BELONGS_TO) — what a half-finished delete leaves, + * - orphan Edge nodes (missing a source or target) — these 500 the edges + * query, the original data-integrity incident. + * + * It NEVER touches seed/demo graphs (Welcome, Cycle 2, Aquarium, …) — only + * sentinel/test-named graphs and true orphans. Fully graceful: if Neo4j is + * unreachable it logs and returns zeros rather than failing the run. + */ + +/** Every test-seeded graph name starts with this so the sweep can find them + * unambiguously without ever matching a real graph. */ +export const TEST_GRAPH_PREFIX = '[E2E]'; + +// Legacy/explicit test-name patterns (graphs created before the sentinel, or by +// ad-hoc probes). Anchored so they can't match real graphs. +const LEGACY_TEST_NAME_REGEX = + '^(\\[E2E\\]|Scale |VLM |Clone|Parity|PathP|NodeAttach|Contract|CloneFix|CloneProbe|Pop|TP |Empty Smoke|Living E2E|ParityV|Smoke ).*'; + +const URI = process.env.NEO4J_URI || 'bolt://localhost:7687'; +const USER = process.env.NEO4J_USER || 'neo4j'; +const PASS = process.env.NEO4J_PASSWORD || 'graphdone_password'; + +export interface SweepResult { + testGraphs: number; + testGraphNodes: number; + orphanNodes: number; + orphanEdges: number; + ok: boolean; +} + +async function deleteInBatches(session: any, matchDelete: string): Promise { + // matchDelete must be a query of shape: MATCH ... WITH x LIMIT 5000 DETACH DELETE x RETURN count(x) AS c + let total = 0; + for (;;) { + const r = await session.run(matchDelete); + const c = r.records[0]?.get('c')?.toNumber?.() ?? 0; + total += c; + if (c === 0) break; + } + return total; +} + +export async function sweepTestData(label = ''): Promise { + const result: SweepResult = { testGraphs: 0, testGraphNodes: 0, orphanNodes: 0, orphanEdges: 0, ok: false }; + let driver: Driver | undefined; + try { + driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASS)); + await driver.verifyConnectivity(); + const session = driver.session(); + try { + // 1) WorkItems + Edge nodes that belong to test-named graphs. + result.testGraphNodes = await deleteInBatches( + session, + `MATCH (g:Graph) WHERE g.name =~ '${LEGACY_TEST_NAME_REGEX}' + MATCH (g)<-[:BELONGS_TO]-(w:WorkItem) + OPTIONAL MATCH (w)<-[:EDGE_SOURCE|EDGE_TARGET]-(e:Edge) + WITH w, e LIMIT 5000 DETACH DELETE e, w RETURN count(w) AS c` + ); + // 2) The test-named graphs themselves. + const g = await session.run( + `MATCH (g:Graph) WHERE g.name =~ '${LEGACY_TEST_NAME_REGEX}' DETACH DELETE g RETURN count(g) AS c` + ); + result.testGraphs = g.records[0]?.get('c')?.toNumber?.() ?? 0; + // 3) Orphan WorkItems (belong to no graph) — what a killed delete leaves. + result.orphanNodes = await deleteInBatches( + session, + `MATCH (w:WorkItem) WHERE NOT (w)-[:BELONGS_TO]->(:Graph) WITH w LIMIT 5000 DETACH DELETE w RETURN count(w) AS c` + ); + // 4) Orphan Edge nodes (missing a source or target) — these break the + // edges query for everyone. + result.orphanEdges = await deleteInBatches( + session, + `MATCH (e:Edge) WHERE NOT (e)-[:EDGE_SOURCE]->(:WorkItem) OR NOT (e)-[:EDGE_TARGET]->(:WorkItem) WITH e LIMIT 5000 DETACH DELETE e RETURN count(e) AS c` + ); + result.ok = true; + const touched = result.testGraphs + result.testGraphNodes + result.orphanNodes + result.orphanEdges; + if (touched > 0) { + // eslint-disable-next-line no-console + console.log( + `[db-heal${label ? ' ' + label : ''}] swept ${result.testGraphs} test graphs, ${result.testGraphNodes} their nodes, ${result.orphanNodes} orphan nodes, ${result.orphanEdges} orphan edges` + ); + } + } finally { + await session.close(); + } + } catch (err) { + // Graceful: never fail the test run because healing couldn't connect. + // eslint-disable-next-line no-console + console.warn(`[db-heal] skipped (${err instanceof Error ? err.message.split('\n')[0] : String(err)})`); + } finally { + await driver?.close(); + } + return result; +} diff --git a/tests/helpers/seedGraph.ts b/tests/helpers/seedGraph.ts new file mode 100644 index 00000000..b8d267d2 --- /dev/null +++ b/tests/helpers/seedGraph.ts @@ -0,0 +1,152 @@ +import { Page } from '@playwright/test'; +import { TEST_GRAPH_PREFIX } from './dbHealing'; + +/** + * Seeds realistically-shaped graphs of arbitrary size through the real GraphQL + * API (the same path a human or AI uses), so the perf sweep measures the true + * stack — Neo4j + Apollo + the web force simulation — not a synthetic shortcut. + * + * Nodes are spread on a grid (real positions, not all stacked at the origin), + * statuses/types/priorities are varied so living-graph effects and priority + * glow actually exercise, and edges form a connected backbone plus extra links + * to hit a target edge:node ratio. Edges are created as Edge nodes (the + * canonical model the web renders). Everything batches to stay within request + * limits, and cleanup deletes edges before nodes (orphan edges break the whole + * edges query). + */ + +export interface SeededGraph { + graphId: string; + nodeIds: string[]; + edgeCount: number; +} + +async function gql(page: Page, query: string, variables?: unknown): Promise { + return page.evaluate( + async ({ query, variables }) => { + const token = localStorage.getItem('authToken') ?? ''; + const res = await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query, variables }), + }); + const body = await res.json(); + if (body.errors) throw new Error(body.errors[0]?.message ?? 'GraphQL error'); + return body.data; + }, + { query, variables } + ); +} + +const STATUSES = ['PROPOSED', 'IN_PROGRESS', 'BLOCKED', 'COMPLETED'] as const; +const TYPES = ['TASK', 'BUG', 'FEATURE', 'MILESTONE', 'OUTCOME'] as const; +const EDGE_TYPES = ['DEPENDS_ON', 'BLOCKS', 'RELATES_TO'] as const; + +function chunk(arr: T[], n: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n)); + return out; +} + +export interface SeedOptions { + size: number; + /** edges ≈ edgeFactor * size (default 1.4). */ + edgeFactor?: number; + /** grid spacing in px (default 130). */ + spacing?: number; + namePrefix?: string; +} + +export async function seedLargeGraph(page: Page, opts: SeedOptions): Promise { + const { size, edgeFactor = 1.4, spacing = 130, namePrefix = 'Scale' } = opts; + const me = await gql(page, '{ me { id } }'); + const userId = me.me.id; + + const g = await gql( + page, + `mutation($input: [GraphCreateInput!]!) { createGraphs(input: $input) { graphs { id } } }`, + { input: [{ name: `${TEST_GRAPH_PREFIX} ${namePrefix} ${size}n ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] } + ); + const graphId = g.createGraphs.graphs[0].id as string; + + // Grid layout centered on the origin so the sim starts from a real arrangement. + const cols = Math.ceil(Math.sqrt(size)); + const half = (cols * spacing) / 2; + const nodeInputs = Array.from({ length: size }, (_, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + // Deterministic pseudo-variety without Math.random (kept reproducible). + const status = STATUSES[i % STATUSES.length]; + const type = TYPES[(i * 7) % TYPES.length]; + const priority = ((i * 37) % 100) / 100; + return { + type, + title: `${type} ${i}`, + status, + priority, + positionX: col * spacing - half, + positionY: row * spacing - half, + positionZ: 0, + owner: { connect: { where: { node: { id: userId } } } }, + graph: { connect: { where: { node: { id: graphId } } } }, + }; + }); + + const nodeIds: string[] = []; + for (const batch of chunk(nodeInputs, 100)) { + const res = await gql( + page, + `mutation($input: [WorkItemCreateInput!]!) { createWorkItems(input: $input) { workItems { id } } }`, + { input: batch } + ); + for (const w of res.createWorkItems.workItems) nodeIds.push(w.id); + } + + // Backbone chain guarantees connectivity; extra forward links add realism. + const targetEdges = Math.round(size * edgeFactor); + const edgeInputs: Array> = []; + const link = (a: string, b: string, t: string) => + edgeInputs.push({ + type: t, + weight: 0.5 + ((edgeInputs.length % 5) / 10), + source: { connect: { where: { node: { id: a } } } }, + target: { connect: { where: { node: { id: b } } } }, + }); + for (let i = 0; i + 1 < nodeIds.length; i++) link(nodeIds[i], nodeIds[i + 1], 'DEPENDS_ON'); + let extra = targetEdges - edgeInputs.length; + for (let i = 0; i < nodeIds.length && extra > 0; i++) { + const jump = 2 + ((i * 5) % Math.max(2, Math.floor(nodeIds.length / 4))); + const j = i + jump; + if (j < nodeIds.length) { + link(nodeIds[i], nodeIds[j], EDGE_TYPES[i % EDGE_TYPES.length]); + extra--; + } + } + + let edgeCount = 0; + for (const batch of chunk(edgeInputs, 100)) { + const res = await gql( + page, + `mutation($input: [EdgeCreateInput!]!) { createEdges(input: $input) { edges { id } } }`, + { input: batch } + ); + edgeCount += res.createEdges.edges.length; + } + + return { graphId, nodeIds, edgeCount }; +} + +export async function deleteGraphDeep(page: Page, graphId: string): Promise { + // Edges first (orphan edges break the edges query), then nodes, then graph. + await gql( + page, + `mutation($id: ID!) { deleteEdges(where: { source: { graph: { id: $id } } }) { nodesDeleted } }`, + { id: graphId } + ).catch(() => {}); + await gql( + page, + `mutation($id: ID!) { deleteWorkItems(where: { graph: { id: $id } }) { nodesDeleted } }`, + { id: graphId } + ).catch(() => {}); + await gql(page, `mutation($id: ID!) { deleteGraphs(where: { id: $id }) { nodesDeleted } }`, { id: graphId }).catch(() => {}); +} diff --git a/tests/helpers/testEnv.ts b/tests/helpers/testEnv.ts new file mode 100644 index 00000000..f29c8c80 --- /dev/null +++ b/tests/helpers/testEnv.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import dotenv from 'dotenv'; + +/** + * Loads local-only test configuration from `.env.test.local` (gitignored) into + * process.env, without ever baking secrets or hostnames into the repo. Import + * this for its side effect at the top of any spec/generator that needs the VLM + * endpoints or sweep config: + * + * import '../helpers/testEnv'; + * + * Safe to import everywhere — it's a no-op when the file is absent (e.g. CI), + * so VLM-driven suites skip cleanly. Existing process.env values win, so you + * can still override per-run on the command line. + */ +const localEnvPath = path.resolve(process.cwd(), '.env.test.local'); +if (fs.existsSync(localEnvPath)) { + dotenv.config({ path: localEnvPath }); +} + +/** Comma/whitespace separated env list -> trimmed non-empty string[]. */ +export function envList(name: string): string[] { + return (process.env[name] ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +/** Parse a comma-separated list of positive integers (sweep sizes). */ +export function envIntList(name: string): number[] { + return envList(name) + .map((s) => Number.parseInt(s, 10)) + .filter((n) => Number.isFinite(n) && n > 0); +} + +export function envNumber(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const n = Number(raw); + return Number.isFinite(n) ? n : fallback; +} diff --git a/tests/helpers/vlm.ts b/tests/helpers/vlm.ts new file mode 100644 index 00000000..aeb4db24 --- /dev/null +++ b/tests/helpers/vlm.ts @@ -0,0 +1,331 @@ +import * as fs from 'fs'; +import './testEnv'; +import { envList, envNumber } from './testEnv'; + +/** + * Protocol-agnostic client for LOCAL Vision-Language-Model servers. + * + * The actual endpoints (GPU workstations) live only in `.env.test.local` + * (gitignored) as VLM_ENDPOINTS — never in the repo. Requests are round-robined + * across every configured endpoint so visual evaluation spreads over all GPUs. + * + * Two wire protocols are supported and auto-detected per endpoint: + * - OpenAI-compatible: POST /v1/chat/completions with image_url data URIs + * (vLLM, LM Studio, llama.cpp server, Ollama's /v1 compat shim) + * - Ollama native: POST /api/chat with a base64 images[] array + * + * Everything degrades gracefully: when VLM_ENDPOINTS is unset or no endpoint is + * reachable, isVlmAvailable() is false and suites skip — so CI stays green. + */ + +export type VlmProtocol = 'openai' | 'ollama'; + +export interface VlmVerdict { + pass: boolean; + score: number; // 0..1 + issues: string[]; + summary: string; + raw?: string; // raw model text, for the report when parsing is imperfect + endpoint?: string; // which box judged this (honesty in the report) + model?: string; + latencyMs?: number; +} + +export interface Persona { + key: string; + label: string; + /** Framing for the model — who it is and what it cares about. */ + system: string; + /** What "pass" means, appended to every prompt for this persona. */ + rubric: string; +} + +/** + * The evaluation perspectives. Each judges a rendered screenshot from a + * distinct point of view, so one capture yields several independent reads. + */ +export const PERSONAS: Persona[] = [ + { + key: 'visual-defects', + label: 'Visual defects', + system: + 'You are a meticulous UI rendering QA inspector for a graph-visualization web app. ' + + 'You judge ONLY what is visible in the screenshot — objective rendering correctness.', + rubric: + 'Fail if you see: nodes overlapping so labels are unreadable, nodes/text cut off at the edges, ' + + 'a broken or empty layout where content is expected, edges that clearly do not connect nodes, ' + + 'obvious visual glitches, or any error message / "Error" badge / blank red state. ' + + 'Pass if the graph (or its empty-state invitation) renders cleanly and legibly.', + }, + { + key: 'new-user', + label: 'New-user clarity', + system: + 'You are a first-time user who has never seen this product. You are evaluating whether the ' + + 'screen is clear, inviting, and self-explanatory.', + rubric: + 'Fail if you would feel lost or could not tell what to do next, or the screen looks intimidating ' + + 'or cluttered to a newcomer. Pass if the purpose is clear and there is an obvious next action.', + }, + { + key: 'accessibility', + label: 'Accessibility', + system: + 'You are an accessibility reviewer judging a rendered screenshot for visual a11y.', + rubric: + 'Fail if text contrast looks too low to read, text is too small, information is conveyed by color ' + + 'alone, or interactive targets look too small to tap. Pass if it appears broadly legible and usable.', + }, + { + key: 'living-graph', + label: 'Living-graph aliveness', + system: + 'You evaluate whether a graph visualization feels "alive" and communicates work status. Nodes may ' + + 'glow by priority, pulse/breathe when in progress, look settled when complete, or ache when blocked; ' + + 'edges may show energy flow.', + rubric: + 'Fail if the graph looks completely static/flat with no visual hierarchy or status cues, or if the ' + + 'effects look chaotic/noisy rather than purposeful. Pass if status and priority read clearly and the ' + + 'scene feels alive but legible. (Judge the single frame; do not penalize lack of motion in a still.)', + }, +]; + +export const personaByKey = (key: string): Persona | undefined => + PERSONAS.find((p) => p.key === key); + +const TIMEOUT_MS = envNumber('VLM_TIMEOUT_MS', 120_000); +const MAX_CONCURRENCY = Math.max(1, envNumber('VLM_MAX_CONCURRENCY', 3)); + +let rrCounter = 0; +const protocolCache = new Map(); +const modelCache = new Map(); + +export function vlmEndpoints(): string[] { + return envList('VLM_ENDPOINTS').map((e) => e.replace(/\/+$/, '')); +} + +export function vlmModel(): string { + return (process.env.VLM_MODEL ?? '').trim(); +} + +/** + * Resolve the model id for an endpoint. With a single shared model set + * VLM_MODEL; with multiple boxes serving DIFFERENT models, set VLM_MODEL=auto + * (or leave blank) and each endpoint's own loaded model is used. llama.cpp + * serves one model and ignores the field, but sending the right id keeps logs + * honest and works with multi-model servers too. + */ +async function resolveModel(base: string, protocol: VlmProtocol): Promise { + const configured = vlmModel(); + if (configured && configured.toLowerCase() !== 'auto') return configured; + if (modelCache.has(base)) return modelCache.get(base)!; + let id = 'default'; + try { + if (protocol === 'openai') { + const r = await fetchWithTimeout(`${base}/v1/models`, { headers: authHeaders() }, 5000); + const d = await r.json(); + id = d?.data?.[0]?.id ?? d?.models?.[0]?.name ?? 'default'; + } else { + const r = await fetchWithTimeout(`${base}/api/tags`, {}, 5000); + const d = await r.json(); + id = d?.models?.[0]?.name ?? d?.models?.[0]?.model ?? 'default'; + } + } catch { /* keep default */ } + modelCache.set(base, id); + return id; +} + +export function isVlmConfigured(): boolean { + return vlmEndpoints().length > 0 && vlmModel().length > 0; +} + +function authHeaders(): Record { + const key = (process.env.VLM_API_KEY ?? '').trim(); + return key ? { Authorization: `Bearer ${key}` } : {}; +} + +async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = TIMEOUT_MS): Promise { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(t); + } +} + +/** Detect (and cache) the wire protocol for a single endpoint. */ +async function detectProtocol(base: string): Promise { + const forced = (process.env.VLM_PROTOCOL ?? 'auto').trim().toLowerCase(); + if (forced === 'openai' || forced === 'ollama') return forced; + if (protocolCache.has(base)) return protocolCache.get(base)!; + // OpenAI-compatible servers expose /v1/models. + try { + const r = await fetchWithTimeout(`${base}/v1/models`, { headers: authHeaders() }, 5000); + if (r.ok) { protocolCache.set(base, 'openai'); return 'openai'; } + } catch { /* try next */ } + // Ollama exposes /api/tags. + try { + const r = await fetchWithTimeout(`${base}/api/tags`, {}, 5000); + if (r.ok) { protocolCache.set(base, 'ollama'); return 'ollama'; } + } catch { /* unreachable */ } + return null; +} + +/** Endpoints that are configured AND currently reachable, with their protocol. */ +export async function reachableEndpoints(): Promise> { + const out: Array<{ base: string; protocol: VlmProtocol }> = []; + await Promise.all( + vlmEndpoints().map(async (base) => { + const protocol = await detectProtocol(base); + if (protocol) out.push({ base, protocol }); + }) + ); + return out; +} + +let availabilityCache: boolean | null = null; +/** True only if VLM is configured and at least one endpoint responds. */ +export async function isVlmAvailable(): Promise { + if (!isVlmConfigured()) return false; + if (availabilityCache !== null) return availabilityCache; + availabilityCache = (await reachableEndpoints()).length > 0; + return availabilityCache; +} + +function extractVerdict(text: string): VlmVerdict { + // Models wrap JSON in prose or code fences; grab the first balanced object. + let parsed: Record | null = null; + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = fence ? fence[1] : text; + const start = candidate.indexOf('{'); + const end = candidate.lastIndexOf('}'); + if (start !== -1 && end > start) { + try { parsed = JSON.parse(candidate.slice(start, end + 1)); } catch { /* fall through */ } + } + if (!parsed) { + return { pass: false, score: 0, issues: ['Could not parse a JSON verdict from the model'], summary: text.slice(0, 300), raw: text }; + } + const issuesRaw = parsed.issues; + const issues = Array.isArray(issuesRaw) ? issuesRaw.map((i) => String(i)) : issuesRaw ? [String(issuesRaw)] : []; + let score = Number(parsed.score); + if (!Number.isFinite(score)) score = parsed.pass ? 1 : 0; + if (score > 1) score = score / 100; // tolerate 0-100 scales + return { + pass: Boolean(parsed.pass), + score: Math.max(0, Math.min(1, score)), + issues, + summary: String(parsed.summary ?? '').slice(0, 600), + raw: text, + }; +} + +const PROMPT_TAIL = + 'Respond with ONLY a JSON object, no prose, of exactly this shape: ' + + '{"pass": boolean, "score": number between 0 and 1, "issues": string[], "summary": string}. ' + + 'Keep issues short and specific. Be fair: this is a still frame.'; + +function buildPrompt(persona: Persona, context: string): string { + return `Context: this screenshot shows ${context}.\n\n${persona.rubric}\n\n${PROMPT_TAIL}`; +} + +async function callOpenAI(base: string, model: string, system: string, prompt: string, b64: string): Promise { + const r = await fetchWithTimeout(`${base}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ + model, + temperature: 0, + max_tokens: 400, + messages: [ + { role: 'system', content: system }, + { + role: 'user', + content: [ + { type: 'text', text: prompt }, + { type: 'image_url', image_url: { url: `data:image/png;base64,${b64}` } }, + ], + }, + ], + }), + }); + if (!r.ok) throw new Error(`OpenAI VLM ${base} HTTP ${r.status}: ${(await r.text()).slice(0, 200)}`); + const data = await r.json(); + return data?.choices?.[0]?.message?.content ?? ''; +} + +async function callOllama(base: string, model: string, system: string, prompt: string, b64: string): Promise { + const r = await fetchWithTimeout(`${base}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + stream: false, + options: { temperature: 0 }, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: prompt, images: [b64] }, + ], + }), + }); + if (!r.ok) throw new Error(`Ollama VLM ${base} HTTP ${r.status}: ${(await r.text()).slice(0, 200)}`); + const data = await r.json(); + return data?.message?.content ?? ''; +} + +/** + * Evaluate one screenshot from one persona's perspective. Round-robins across + * reachable endpoints. Never throws — failures come back as a non-pass verdict + * so the report is always complete. + */ +export async function evaluateImage( + imagePath: string, + persona: Persona, + context: string, + endpoints?: Array<{ base: string; protocol: VlmProtocol }> +): Promise { + const eps = endpoints ?? (await reachableEndpoints()); + if (eps.length === 0) { + return { pass: false, score: 0, issues: ['No reachable VLM endpoint'], summary: '' }; + } + const { base, protocol } = eps[rrCounter++ % eps.length]; + const prompt = buildPrompt(persona, context); + const started = Date.now(); + try { + const model = await resolveModel(base, protocol); + const b64 = fs.readFileSync(imagePath).toString('base64'); + const text = + protocol === 'openai' + ? await callOpenAI(base, model, persona.system, prompt, b64) + : await callOllama(base, model, persona.system, prompt, b64); + const v = extractVerdict(text); + return { ...v, endpoint: base, model, latencyMs: Date.now() - started }; + } catch (err) { + return { + pass: false, + score: 0, + issues: [`VLM request failed: ${err instanceof Error ? err.message : String(err)}`], + summary: '', + endpoint: base, + latencyMs: Date.now() - started, + }; + } +} + +/** Run a batch of {imagePath, persona, context} jobs with bounded concurrency. */ +export async function evaluateBatch( + jobs: Array<{ imagePath: string; persona: Persona; context: string; meta?: Record }> +): Promise }>> { + const eps = await reachableEndpoints(); + const results: Array<{ persona: string; context: string; imagePath: string; verdict: VlmVerdict; meta?: Record }> = []; + let idx = 0; + async function worker() { + while (idx < jobs.length) { + const job = jobs[idx++]; + const verdict = await evaluateImage(job.imagePath, job.persona, job.context, eps); + results.push({ persona: job.persona.key, context: job.context, imagePath: job.imagePath, verdict, meta: job.meta }); + } + } + await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENCY, jobs.length) }, worker)); + return results; +} diff --git a/tests/perf/scale-sweep.spec.ts b/tests/perf/scale-sweep.spec.ts new file mode 100644 index 00000000..00b452e5 --- /dev/null +++ b/tests/perf/scale-sweep.spec.ts @@ -0,0 +1,231 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; +import { seedLargeGraph, deleteGraphDeep } from '../helpers/seedGraph'; +import { sweepTestData } from '../helpers/dbHealing'; +import '../helpers/testEnv'; +import { envIntList, envList } from '../helpers/testEnv'; + +/** + * Large-scale graph creation + performance metric sweep. Seeds real graphs of + * increasing size through the GraphQL API, loads each in the browser at one or + * more quality tiers, and records the in-app PerfMeter/DriftMeter readings + * (window.__graphPerf) plus settle time, load time, and query latency. + * + * Report-only: writes one JSON per (size, quality) under + * test-artifacts/scale-sweep/, which `npm run report:perf` renders into a table + * + charts. It does NOT fail on thresholds — the goal is a metric sweep, not a + * gate (the @perf budgets spec is the gate). The only hard assertion is that a + * seeded graph actually renders, so a silent breakage still surfaces. + * + * Sizes/qualities come from env (.env.test.local) so you can push it hard + * locally; CI uses a small set just to keep the harness honest. + */ + +const SIZES = (() => { + const fromEnv = envIntList('SCALE_SWEEP_SIZES'); + if (fromEnv.length) return fromEnv; + return process.env.CI ? [40, 120] : [50, 200, 500, 1000, 2000]; +})(); + +const QUALITIES = (() => { + const fromEnv = envList('SCALE_SWEEP_QUALITIES').map((q) => q.toUpperCase()); + const valid = fromEnv.filter((q) => ['LOW', 'MEDIUM', 'HIGH', 'ULTRA'].includes(q)); + if (valid.length) return valid; + return process.env.CI ? ['HIGH'] : ['HIGH', 'ULTRA']; +})(); + +const OUT_DIR = path.resolve(process.cwd(), 'test-artifacts/scale-sweep'); +const SETTLE_BUDGET_MS = 30_000; +const REST_ALPHA = 0.02; + +interface SweepResult { + size: number; + quality: string; + seededNodes: number; + seededEdges: number; + renderedNodes: number; + renderedEdges: number; + loadMs: number; // time from reload to first node painted + interactionFps: number; // RELIABLE: rendered frames/sec while dragging the graph + settleMs: number | null; // time to reach REST_ALPHA (null = never settled within budget) + finalAlpha: number; + avgTickMs: number; + p95TickMs: number; + fps: number; + droppedFrames: number; + rmsFromSavedPx: number; + maxStepPx: number; + queryP95Ms: number; + timestampISO: string; +} + +async function measure(page: Page, graphId: string, size: number, quality: string): Promise { + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.evaluate( + ({ gid, q }) => { + localStorage.setItem('currentGraphId', gid); + localStorage.setItem('graphdone.quality.override', q); + }, + { gid: graphId, q: quality } + ); + + const t0 = Date.now(); + await page.reload(); + // Load time = first node painted. + await page.locator('.graph-container svg .node').first().waitFor({ timeout: 60_000 }).catch(() => {}); + const loadMs = Date.now() - t0; + + // Seeded nodes carry saved grid positions, so the app pins them and the force + // sim sits idle — PerfMeter (window.__graphPerf, published only every ~2s + // WHILE ticking) then never reports. We hold a node and drag it continuously + // for a few seconds: d3 keeps alphaTarget>0 while dragging, so the sim ticks + // the whole time and the meter publishes real UNDER-INTERACTION samples (tick + // cost / fps at this scale — a realistic "dragging a big graph" metric). We + // keep the best (lowest-tick) live sample, then release and time the settle. + const box = await page.evaluate(() => { + const n = document.querySelector('.graph-container svg .node .node-bg') as Element | null; + if (!n) return null; + const r = n.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + + // Reliable interaction FPS: count real rendered frames (requestAnimationFrame) + // over a fixed wall-clock window while dragging. This needs no app + // instrumentation, so it works at every size — when the main thread is busy + // ticking a huge sim, rAF visibly drops, which is exactly the scaling signal. + let lastNonNull: any = null; + const samples: any[] = []; + let interactionFps = -1; + if (box) { + await page.evaluate(() => { + (window as any).__fc = 0; + const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; + (window as any).__rafId = requestAnimationFrame(loop); + }); + await page.mouse.move(box.x, box.y).catch(() => {}); + await page.mouse.down().catch(() => {}); + const dragStart = Date.now(); + let a = 0; + while (Date.now() - dragStart < 6000) { + a += 0.6; + await page.mouse.move(box.x + Math.cos(a) * 70, box.y + Math.sin(a) * 55).catch(() => {}); + await page.waitForTimeout(120); + const cur = await page.evaluate(() => (window as any).__graphPerf ?? null); + if (cur) { lastNonNull = cur; samples.push(cur); } + } + await page.mouse.up().catch(() => {}); + const frames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + const secs = (Date.now() - dragStart) / 1000; + interactionFps = Math.round((frames / secs) * 10) / 10; + } + + // Now measure how long it takes to come to rest after the perturbation. + let settleMs: number | null = null; + const settleStart = Date.now(); + while (Date.now() - settleStart < SETTLE_BUDGET_MS) { + const cur = await page.evaluate(() => (window as any).__graphPerf ?? null); + if (cur) { + lastNonNull = cur; + if (typeof cur.alpha === 'number' && cur.alpha <= REST_ALPHA) { + settleMs = Date.now() - settleStart; + break; + } + } + await page.waitForTimeout(300); + } + // Prefer the worst (max) tick seen under interaction — that's the real cost at + // scale; a single settled sample understates it. + const underLoad = samples.length + ? samples.reduce((w, s) => ((s.avgTickMs ?? 0) > (w.avgTickMs ?? 0) ? s : w)) + : null; + const last = underLoad ?? (await page.evaluate(() => (window as any).__graphPerf ?? null)) ?? lastNonNull ?? {}; + + const renderedNodes = await page.locator('.graph-container svg .node').count(); + const renderedEdges = await page.locator('.graph-container svg .edge').count(); + + // Query latency a human/AI would feel: a graph-scoped workItems fetch. + const queryP95Ms = await page.evaluate(async (gid) => { + const token = localStorage.getItem('authToken') ?? ''; + const times: number[] = []; + for (let i = 0; i < 10; i++) { + const s = performance.now(); + await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + query: `query($w: WorkItemWhere) { workItems(where: $w, options: { limit: 5000 }) { id status type priority } }`, + variables: { w: { graph: { id: gid } } }, + }), + }).then((r) => r.json()); + times.push(performance.now() - s); + } + times.sort((a, b) => a - b); + return Math.round(times[Math.floor(times.length * 0.95)] ?? times[times.length - 1]); + }, graphId); + + const spatial = last?.spatial ?? {}; + fs.mkdirSync(OUT_DIR, { recursive: true }); + await page.screenshot({ path: path.join(OUT_DIR, `${size}n-${quality}.png`), fullPage: false }).catch(() => {}); + + return { + size, + quality, + seededNodes: size, + seededEdges: 0, // filled by caller + renderedNodes, + renderedEdges, + loadMs, + interactionFps, + settleMs, + finalAlpha: typeof last?.alpha === 'number' ? last.alpha : -1, + avgTickMs: last?.avgTickMs ?? -1, + p95TickMs: last?.p95TickMs ?? -1, + fps: last?.fps ?? -1, + droppedFrames: last?.droppedFrames ?? -1, + rmsFromSavedPx: spatial.rmsFromSavedPx ?? -1, + maxStepPx: spatial.maxStepPx ?? -1, + queryP95Ms, + timestampISO: new Date(t0).toISOString(), + }; +} + +test.describe('large-scale graph perf sweep @scale', () => { + test.describe.configure({ mode: 'serial', timeout: 600_000 }); + + // Self-heal: clear leftover test graphs + orphans from any prior interrupted + // run before starting, and clean up this run afterward even if a per-run + // delete was skipped (e.g. a killed run). + test.beforeAll(async () => { await sweepTestData('scale:before'); }); + test.afterAll(async () => { await sweepTestData('scale:after'); }); + + for (const size of SIZES) { + test(`sweep ${size} nodes`, async ({ page }) => { + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + // A FRESH graph per (size, quality): otherwise the second quality loads + // the first run's already-settled positions, the sim never ticks, and + // PerfMeter never publishes (the -1 / settle=NONE gaps in v1). + for (const quality of QUALITIES) { + const seeded = await seedLargeGraph(page, { size }); + try { + const result = await measure(page, seeded.graphId, size, quality); + result.seededEdges = seeded.edgeCount; + fs.mkdirSync(OUT_DIR, { recursive: true }); + fs.writeFileSync(path.join(OUT_DIR, `${size}n-${quality}.json`), JSON.stringify(result, null, 2)); + // eslint-disable-next-line no-console + console.log( + `[scale] ${size}n/${quality}: rendered ${result.renderedNodes}n/${result.renderedEdges}e ` + + `load=${result.loadMs}ms dragFps=${result.interactionFps} settle=${result.settleMs ?? 'NONE'}ms ` + + `tick=${result.avgTickMs}ms qP95=${result.queryP95Ms}ms` + ); + expect(result.renderedNodes, `graph of ${size} nodes must render some nodes`).toBeGreaterThan(0); + } finally { + await deleteGraphDeep(page, seeded.graphId); + } + } + }); + } +});