Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,21 @@ No test files exist in the `shared` package.

> **Maintainer note:** E2E test coverage is between "bad and moderate" — roughly 50% of happy paths, with no edge case or error coverage. They've been problematic over the years. Unit tests are well-maintained and passing. If we ever migrate off CRA, E2E tests will become much more important and we'll need to write more.

### Visual Regression (templates)

Pixel-diffs all 13 templates' rendered graphs against committed golden images — the rendering half of the safety net (the logic half is the characterization tests in `app/src/lib/*.characterization.test.ts`). Runs via its own config (`app/playwright.visual.config.ts`), NOT the e2e config. See `app/e2e/visual/README.md`.

> **⚠️ RUN THIS ON ANY VISUALLY SIGNIFICANT CHANGE.** It is currently a **local, manual gate — NOT wired to CI** (goldens are macOS-specific; Linux goldens via the Playwright Docker image is a follow-up). So CI will NOT catch a rendering regression for you. If you touch anything that can change how a chart renders — `toTheme.ts`, `graphUtilityClasses.ts`, `getSize.ts`, `preprocessStyle.ts`, the Cytoscape style/layout pipeline, `FFTheme`, or any template file — you must run it yourself.

```bash
# the app must be served; if :3000 is taken by another project, use another port:
BROWSER=none PORT=3001 pnpm -F app dev
E2E_START_URL=http://localhost:3001 pnpm -F app visual # compare against goldens
E2E_START_URL=http://localhost:3001 pnpm -F app visual:update # regenerate after an INTENTIONAL change
```

After an intentional visual change: regenerate goldens, **eyeball the diff**, then commit the updated `*.png` goldens. The 3 force-directed mindmap templates use frozen-position fixtures (`pnpm -F app visual:fixtures`); regenerate those only if their content/theme changes.

## CI/CD

- **GitHub Actions:**
Expand Down
11 changes: 9 additions & 2 deletions app/e2e/pro.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,18 @@ test("Create chart from imported data", async () => {
.selectOption("Connector Label");
await page.getByTestId("import-submit-button").click();

// Import parsing + preview can be slow against the Vercel preview, so give
// the confirmation dialog generous time rather than the 5s default (this
// assertion has flaked at the default timeout).
await expect(
page.getByText("You are about to add 9 nodes and 10 edges to your graph.")
).toBeVisible();
).toBeVisible({ timeout: 30000 });

await page.getByTestId("import-confirm-button").click();
// Wait for the confirm button to be actionable before clicking — this click
// has hung to the test timeout when the dialog was still settling.
const confirmButton = page.getByTestId("import-confirm-button");
await confirmButton.waitFor({ state: "visible", timeout: 30000 });
await confirmButton.click();
} catch (error) {
console.error(error);
throw error;
Expand Down
52 changes: 52 additions & 0 deletions app/e2e/visual/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Visual regression

Pixel-diffs the rendered graph for every template against committed golden
images. This is the **rendering half of the safety net**: a refactor, a
framework migration, or a deliberate fix to a render bug that changes how a
chart looks will fail here loudly — and you review the diff before accepting a
new golden.

## Running

Requires the flowchart-fun dev server. If something else owns `:3000`, run it
elsewhere and point the tests at it with `E2E_START_URL`:

```bash
# start the app (client-only is enough — visual tests hit no /api routes)
BROWSER=none PORT=3001 pnpm -F app dev

# compare current render against goldens
E2E_START_URL=http://localhost:3001 pnpm -F app visual

# (re)generate goldens — do this after an intentional visual change, and review the diff
E2E_START_URL=http://localhost:3001 pnpm -F app visual:update
```

## Deterministic vs frozen templates

Most templates use deterministic layouts (dagre/layered/mrtree/radial, and even
the cose network-diagrams converge stably) and are rendered live — so the test
covers **both layout and rendering**.

Three mindmap templates use a force-directed layout that re-randomizes node
positions every render. They are listed in `frozen-templates.ts` and rendered
from a committed fixture (`fixtures/{name}.doc.txt`) with `meta.nodePositions`
baked in, so geometry is fixed via a `preset` layout and the test
deterministically checks **rendering** (not layout). The flaky set was found
empirically (failed two consecutive comparison runs), not assumed from layout
name.

Regenerate fixtures if a frozen template's content/theme changes:

```bash
E2E_START_URL=http://localhost:3001 pnpm -F app visual:fixtures
# then regenerate those goldens:
E2E_START_URL=http://localhost:3001 pnpm -F app visual:update -- -g mindmap
```

## Not yet wired to CI

Playwright goldens are platform-specific (suffixed `-darwin` here). CI is Linux,
so running this in CI needs Linux-generated goldens (e.g. via the Playwright
Docker image). That's a deliberate follow-up — today this is a **local**
pre-migration / pre-render-fix tool, like `scripts/screenshot-templates.mjs`.
14 changes: 14 additions & 0 deletions app/e2e/visual/fixtures/mindmap-dark.doc.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Universe .color_blue .shape_ellipse
Stars .color_yellow
Sun .shape_circle
Planets .color_green
Earth .shape_roundrectangle
Moon .color_grey .shape_circle
Mars .color_red .shape_circle
Galaxies .color_purple
Milky Way .shape_star
Black Holes .color_black .shape_octagon

=====
{"themeEditor":{"layoutName":"cose","spacingFactor":1,"background":"#111827","fontFamily":"Space Grotesk","shape":"roundrectangle","nodeBackground":"#1f2937","nodeForeground":"#f3f4f6","padding":15,"borderWidth":2,"borderColor":"#4b5563","textMaxWidth":100,"lineHeight":1.3,"textMarginY":0,"useFixedHeight":false,"curveStyle":"bezier","edgeWidth":2,"edgeColor":"#6b7280","sourceArrowShape":"none","targetArrowShape":"none","sourceDistanceFromNode":5,"targetDistanceFromNode":5,"edgeTextSize":1,"rotateEdgeLabel":false,"direction":"DOWN","fixedHeight":300,"arrowScale":1},"cytoscapeStyle":"\n$bg-dark: #111827;\n$bg-light: #1f2937;\n$text-light: #f3f4f6;\n$text-dark: #9ca3af;\n$accent-blue: #3b82f6;\n$accent-yellow: #fbbf24;\n$accent-green: #10b981;\n$accent-red: #ef4444;\n$accent-purple: #8b5cf6;\n$accent-grey: #6b7280;\n$accent-black: #000000;\n\nnode {\n font-weight: 400;\n text-halign: center;\n text-valign: center;\n color: $text-light;\n background-color: $bg-light;\n border-color: $text-dark;\n box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);\n text-outline-color: $bg-dark;\n text-outline-width: 1px;\n text-outline-opacity: 0.5;\n}\n\nedge {\n curve-style: bezier;\n line-color: $text-dark;\n width: 2px;\n opacity: 0.8;\n}\n\n:parent {\n background-color: rgba(29, 78, 216, 0.15);\n border-color: $accent-blue;\n border-width: 2px;\n border-style: dashed;\n}\n\n:childless {\n padding: 12px;\n font-size: 14px;\n text-wrap: wrap;\n text-max-width: 90px;\n}\n\n.color_blue { background-color: $accent-blue; }\n.color_yellow { background-color: $accent-yellow; color: $bg-dark; }\n.color_green { background-color: $accent-green; }\n.color_red { background-color: $accent-red; }\n.color_purple { background-color: $accent-purple; }\n.color_grey { background-color: $accent-grey; }\n.color_black { background-color: $accent-black; }\n\n.shape_circle { shape: circle; }\n.shape_ellipse { shape: ellipse; }\n.shape_roundrectangle { shape: roundrectangle; }\n.shape_star { shape: star; }\n.shape_octagon { shape: octagon; }\n\n#Universe {\n font-size: 18px;\n font-weight: bold;\n text-max-width: 120px;\n border-width: 3px;\n border-color: $accent-yellow;\n}\n\n:childless[depth = 1] {\n font-weight: 700;\n}\n\n:childless:selected,\n:parent:selected {\n border-color: $accent-yellow;\n border-width: 3px;\n border-style: solid;\n box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.4);\n}\n\nedge:selected {\n width: 4px;\n line-color: $accent-yellow;\n}\n","expires":"2026-05-30T05:15:18.481Z","customCssOnly":false,"nodePositions":{"n1":{"x":90.67366458040678,"y":-11.956547958492905},"n2":{"x":123.27758161762398,"y":80.78059141462374},"n3":{"x":92.78993602175682,"y":232.28754556266546},"n4":{"x":42.97874508916401,"y":-101.13440975012406},"n5":{"x":41.63073908276477,"y":-196.17265159894208},"n6":{"x":36.65882349396908,"y":-290.28754556266546},"n7":{"x":-166.95238074430725,"y":-116.91847656696797},"n8":{"x":-92.41263496731636,"y":42.73202943040194},"n9":{"x":-299.48551400430085,"y":57.35070103967438},"n10":{"x":299.48551400430085,"y":-17.722130573523543}}}
=====
16 changes: 16 additions & 0 deletions app/e2e/visual/fixtures/mindmap.doc.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Mind Mapping .size_lg
Learning Style .color_blue
Read .color_blue
Listen .color_blue
Summarize .color_blue
Motivation .color_orange
Tips .color_orange
Roadmap .color_orange
Review .color_green
Notes .color_green
Method .color_green
Discuss .color_green

=====
{"themeEditor":{"layoutName":"cose","direction":"RIGHT","spacingFactor":0.95,"lineHeight":1.2,"shape":"ellipse","background":"#ffffff","textMaxWidth":100,"padding":17,"fontFamily":"Kalam","curveStyle":"bezier","textMarginY":2,"borderWidth":0,"edgeTextSize":0.8,"edgeWidth":2,"sourceArrowShape":"none","targetArrowShape":"triangle-backcurve","edgeColor":"#314137","borderColor":"#000000","nodeBackground":"#ffffff","nodeForeground":"#314137","sourceDistanceFromNode":0,"targetDistanceFromNode":7,"arrowScale":1.25,"rotateEdgeLabel":false,"useFixedHeight":false,"fixedHeight":130},"cytoscapeStyle":"$green: #ddff75;\n$blue: #bde2ff;\n$orange: #ffe253;\n$pink: #ffb6bc;\n$grey: #f2f0ea;\n\n:childless.size_lg {\n font-size: 30;\n width: 150;\n line-height: 1;\n text-max-width: 130;\n}\n\n:childless.color_orange {\n background-color: $orange;\n}\n:childless.color_green {\n background-color: $green;\n}\n:childless.color_pink {\n background-color: $pink;\n}\n:childless.color_grey {\n background-color: $grey;\n}\n:childless.color_blue {\n background-color: $blue;\n}","expires":"2026-05-30T05:15:07.598Z","customCssOnly":false,"nodePositions":{"n1":{"x":88.13698150351098,"y":-3.748122996878957},"n2":{"x":52.98235435026533,"y":-125.95716601551867},"n3":{"x":264.10074377013405,"y":-122.45533797838313},"n4":{"x":168.67021831132791,"y":-203.16977658929224},"n5":{"x":-31.78655166530496,"y":-212.3694571223207},"n6":{"x":-129.52566613670123,"y":2.300665721769835},"n7":{"x":-290.3224840190624,"y":69.70981178708847},"n8":{"x":-338.2752463117439,"y":-46.44360767536899},"n9":{"x":126.01598857370895,"y":117.85656508241166},"n10":{"x":338.27524631174384,"y":124.1721084655266},"n11":{"x":178.95491348801377,"y":212.3694571223207},"n12":{"x":-29.706801086715835,"y":186.99239568347014}}}
=====
29 changes: 29 additions & 0 deletions app/e2e/visual/fixtures/playful-mindmap.doc.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
My Favorite Things! .color_pink .size_lg
Animals .color_yellow
Dogs .color_blue
Fluffy .color_green
Spotty .color_green
Cats .color_blue
Whiskers .color_green
Mittens .color_green
Unicorns .color_blue
Food .color_yellow
Pizza .color_blue
Cheese .color_green
Pepperoni .color_green
Ice Cream .color_blue
Chocolate .color_green
Vanilla .color_green
Cookies .color_blue
Hobbies .color_yellow
Drawing .color_blue
Dancing .color_blue
Singing .color_blue
Places .color_yellow
Beach .color_blue
Mountains .color_blue
Space .color_blue

=====
{"themeEditor":{"layoutName":"cose","direction":"DOWN","spacingFactor":1.1,"background":"#FFFFFF","fontFamily":"Patrick Hand","shape":"ellipse","nodeBackground":"#FFB6C1","nodeForeground":"#333333","padding":10,"borderWidth":2,"borderColor":"#FF69B4","textMaxWidth":100,"lineHeight":1.2,"textMarginY":0,"useFixedHeight":false,"curveStyle":"bezier","edgeWidth":2,"edgeColor":"#888888","sourceArrowShape":"none","targetArrowShape":"none","sourceDistanceFromNode":5,"targetDistanceFromNode":5,"arrowScale":1,"edgeTextSize":0.875,"rotateEdgeLabel":false,"fixedHeight":100},"cytoscapeStyle":"\n@import url('https://fonts.googleapis.com/css2?family=Patrick+Hand&display=swap');\n\n$pink: #FF69B4;\n$yellow: #FFD700;\n$blue: #87CEFA;\n$green: #98FB98;\n$red: #FF6B6B;\n$orange: #FFB347;\n$purple: #DDA0DD;\n$grey: #D3D3D3;\n\n:childless.size_lg {\n font-size: 30;\n width: 150;\n line-height: 1;\n text-max-width: 130;\n}\n\n:childless.color_pink {\n background-color: $pink;\n border-color: #FF1493;\n color: #FFFFFF;\n}\n\n:childless.color_yellow {\n background-color: $yellow;\n border-color: #FFA500;\n}\n\n:childless.color_blue {\n background-color: $blue;\n border-color: #4169E1;\n}\n\n:childless.color_green {\n background-color: $green;\n border-color: #32CD32;\n}\n\n:childless.color_red {\n background-color: $red;\n border-color: #CC0000;\n}\n\n:childless.color_orange {\n background-color: $orange;\n border-color: #FF8C00;\n}\n\n:childless.color_purple {\n background-color: $purple;\n border-color: #9B30FF;\n}\n\n:childless.color_grey {\n background-color: $grey;\n border-color: #A9A9A9;\n}\n\nnode:selected {\n border-width: 4px;\n border-color: #FF4500;\n}\n\nedge:selected {\n width: 4px;\n line-color: #FF4500;\n opacity: 1;\n}\n","expires":"2026-05-30T05:15:13.724Z","customCssOnly":false,"nodePositions":{"n1":{"x":-173.48639827097753,"y":-52.46520675810685},"n2":{"x":53.93519799010399,"y":-21.67378689473931},"n3":{"x":277.43638905065643,"y":24.181064150182593},"n4":{"x":492.15386732062393,"y":77.88712963131638},"n5":{"x":496.0306101024097,"y":-27.649587788518637},"n6":{"x":283.7174278419482,"y":-81.22794296358356},"n7":{"x":497.95198837020564,"y":-137.58668572355117},"n8":{"x":304.7321419601531,"y":-186.19030550597225},"n9":{"x":-171.12382748847867,"y":26.23047765790793},"n10":{"x":-96.88343749829544,"y":81.51260782776107},"n11":{"x":-48.538637440887086,"y":187.56341239001543},"n12":{"x":45.18399707265733,"y":274.3018631974429},"n13":{"x":-151.66001677937737,"y":267.70682244066893},"n14":{"x":-275.76457264334186,"y":145.64685689180754},"n15":{"x":-361.08182757054135,"y":230.6673493230766},"n16":{"x":-492.2166478178611,"y":149.6238083660829},"n17":{"x":118.31799121635522,"y":105.96625172226744},"n18":{"x":-96.44458526298203,"y":-145.07011523881692},"n19":{"x":102.3943721932104,"y":-100.18046875264983},"n20":{"x":117.24336762094659,"y":-182.25365016568432},"n21":{"x":-40.55106833627173,"y":-234.62639893791427},"n22":{"x":-282.36334235761996,"y":-169.752453377662},"n23":{"x":-228.51469396366863,"y":-274.3018631974429},"n24":{"x":-497.95198837020564,"y":-141.30781205862962},"n25":{"x":-411.8667509979206,"y":-247.34234468384224}}}
=====
40 changes: 40 additions & 0 deletions app/e2e/visual/frozen-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { readFileSync } from "fs";
import { join } from "path";

import { compressToEncodedURIComponent } from "lz-string";

/**
* Templates that proved NON-deterministic in the stability check (force-directed
* layouts re-randomize node positions every render). These are rendered with
* FROZEN positions baked into a committed fixture so the geometry is fixed and
* the visual test deterministically checks RENDERING rather than layout.
*
* Each entry has a fixture at ./fixtures/{name}.doc.txt — the full flowchart.fun
* document string with meta.nodePositions populated. Generated by
* `generate-frozen-fixtures` (see scripts/), regenerate if a template changes.
*
* Populated empirically after the first all-live stability run: these three
* mindmap templates (force-directed cose layout) failed the same comparison on
* two consecutive runs; the other 10 — including the two cose network-diagram
* templates and the ELK stress one — rendered stably and use the live path.
*/
export const FROZEN_TEMPLATES: string[] = [
"mindmap",
"playful-mindmap",
"mindmap-dark",
];

const ORIGIN = process.env.E2E_START_URL || "http://localhost:3000";

/**
* Build a fullscreen screenshot URL from a committed frozen-document fixture.
* The /f route decodes the lz-string hash; with meta.nodePositions set, Graph
* renders a deterministic `preset` layout.
*/
export function frozenScreenshotUrl(name: string): string {
const doc = readFileSync(
join(__dirname, "fixtures", `${name}.doc.txt`),
"utf8"
);
return `${ORIGIN}/f?screenshot=true#${compressToEncodedURIComponent(doc)}`;
}
72 changes: 72 additions & 0 deletions app/e2e/visual/templates.visual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { test, expect, Page } from "@playwright/test";
import { templates } from "shared";

import { FROZEN_TEMPLATES, frozenScreenshotUrl } from "./frozen-templates";

/**
* Visual-regression baseline for the 13 templates.
*
* Templates exercise every layout, theme and cytoscape-style path, so pixel-
* diffing their rendered output is high-coverage protection for any rendering
* change (refactor, framework migration, or a deliberate fix to a render bug).
*
* Two render paths:
* - Deterministic-layout templates (dagre/layered/mrtree/radial): load the
* template and screenshot the live render — this tests BOTH layout and
* rendering.
* - Force-directed templates (cose/fcose, and any other that proved unstable):
* render with FROZEN node positions from a committed fixture, so geometry is
* fixed and the test deterministically checks RENDERING (not layout). See
* ./frozen-templates.ts. Listed in FROZEN_TEMPLATES.
*/

// window.__load_template__ / __get_screenshot_link__ / __cy are declared
// globally by the app (loadTemplate.ts, useEnsureGetScreenshotLink.ts, Graph).
type TemplateName = (typeof templates)[number];

const RENDER_SETTLE_MS = 3000;

/** Wait for the fullscreen canvas to be visible, fonts loaded, and render settled. */
async function waitForCanvas(page: Page) {
const canvas = page.locator('[data-flowchart-fun-canvas="true"]');
await canvas.waitFor({ state: "visible", timeout: 30000 });
await page.evaluate(() => (document as any).fonts?.ready);
await page.waitForTimeout(RENDER_SETTLE_MS);
return canvas;
}

/** Deterministic path: load template on the sandbox, open its screenshot link, screenshot. */
async function renderLive(page: Page, name: TemplateName) {
await page.goto("/?isE2E=true");
await page.waitForFunction(
() => typeof window.__load_template__ === "function",
null,
{ timeout: 30000 }
);
await page.evaluate((n) => window.__load_template__(n, true), name);
await page.waitForFunction(() => !!window.__cy, null, { timeout: 30000 });
await page.waitForTimeout(RENDER_SETTLE_MS);

const link = await page.evaluate(() => window.__get_screenshot_link__());
expect(link, `screenshot link for ${name}`).toBeTruthy();
await page.goto(link);
return waitForCanvas(page);
}

test.describe("template visual regression", () => {
for (const name of templates) {
test(name, async ({ page }) => {
let canvas;
if (FROZEN_TEMPLATES.includes(name)) {
// Frozen path: navigate straight to a screenshot URL built from the
// committed fixed positions, so the layout is a deterministic preset.
await page.goto(frozenScreenshotUrl(name));
canvas = await waitForCanvas(page);
} else {
canvas = await renderLive(page, name);
}

await expect(canvas).toHaveScreenshot(`${name}.png`);
});
}
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "app",
"version": "1.64.2",
"version": "1.65.0",
"main": "module/module.js",
"license": "MIT",
"scripts": {
Expand All @@ -22,6 +22,9 @@
"e2e": "playwright test --config=playwright.config.ts",
"e2e:debug": "DEBUG=1 pnpm run e2e --workers 1 --ui",
"e2e:generate": "npx playwright codegen localhost:3000",
"visual": "playwright test --config=playwright.visual.config.ts",
"visual:update": "playwright test --config=playwright.visual.config.ts --update-snapshots",
"visual:fixtures": "node scripts/generate-frozen-fixtures.mjs",
"generate:types": "export $(cat .env.local | xargs) && supabase gen types typescript --project-id \"${PROJECT_ID}\" > src/types/database.types.ts",
"analyze": "source-map-explorer 'build/static/js/*.js'",
"autotranslations": "node scripts/autotranslations.mjs",
Expand Down
Loading
Loading