From 91492c069633b8fdfc3749dfa1a598a70123d07e Mon Sep 17 00:00:00 2001 From: Max Richter Date: Tue, 3 Feb 2026 16:36:27 +0100 Subject: [PATCH 01/10] fix(ci): only fmt CHANGELOG --- .gitea/scripts/create-release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/scripts/create-release.sh b/.gitea/scripts/create-release.sh index ba93354..a1eb635 100755 --- a/.gitea/scripts/create-release.sh +++ b/.gitea/scripts/create-release.sh @@ -51,7 +51,7 @@ tmp_changelog="CHANGELOG.tmp" mv "$tmp_changelog" CHANGELOG.md -pnpm run format +pnpm exec dprint fmt CHANGELOG.md # ------------------------------------------------------------------- # 4. Create release commit -- 2.49.1 From 731a9683e780d97d01742d738fcf2fbefa7ddc6d Mon Sep 17 00:00:00 2001 From: Max Richter Date: Tue, 3 Feb 2026 20:57:29 +0100 Subject: [PATCH 02/10] feat: add initial e2e tests --- .gitea/workflows/release.yaml | 3 + Dockerfile | 18 +- app/.gitignore | 2 + app/e2e/main.test.ts | 62 +++++ .../main.test.ts-snapshots/test-1-linux.png | Bin 0 -> 42047 bytes app/package.json | 12 +- app/playwright.config.ts | 20 ++ app/src/app.css | 2 +- app/src/lib/graph-templates.test.ts | 110 ++++++++ app/src/lib/graph-templates/index.ts | 1 + app/src/lib/graph-templates/simple.json | 63 +++++ app/src/lib/helpers.test.ts | 150 +++++++++++ app/src/lib/helpers/deepMerge.test.ts | 72 ++++++ .../lib/project-manager/ProjectManager.svelte | 43 ++-- .../project-manager/project-manager.svelte.ts | 2 +- app/src/lib/sidebar/Panel.svelte | 2 +- .../sidebar/panels/ActiveNodeSettings.svelte | 2 +- app/src/routes/dev/+page.svelte | 7 +- app/src/routes/dev/Code.svelte | 26 -- app/vite.config.ts | 34 ++- package.json | 1 + pnpm-lock.yaml | 234 ++++++++++++++++-- 22 files changed, 776 insertions(+), 90 deletions(-) create mode 100644 app/e2e/main.test.ts create mode 100644 app/e2e/main.test.ts-snapshots/test-1-linux.png create mode 100644 app/playwright.config.ts create mode 100644 app/src/lib/graph-templates.test.ts create mode 100644 app/src/lib/graph-templates/simple.json create mode 100644 app/src/lib/helpers.test.ts create mode 100644 app/src/lib/helpers/deepMerge.test.ts delete mode 100644 app/src/routes/dev/Code.svelte diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 8ddce8b..2ac20a7 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -43,6 +43,9 @@ jobs: - name: 🛠️ Build run: pnpm build:deploy + - name: 🔬 Tests + run: xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" pnpm test + - name: 🚀 Create Release Commit if: github.ref_type == 'tag' run: ./.gitea/scripts/create-release.sh diff --git a/Dockerfile b/Dockerfile index 6d202d1..d0633fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,22 @@ -FROM node:24-alpine +# FROM jacoblincool/playwright:chromium-light +FROM jacoblincool/playwright:firefox -# Install all required packages in one layer -RUN apk add --no-cache curl git jq g++ make +# RUN apk add --no-cache curl git jq g++ make +RUN apt update && apt install -y curl git jq g++ make \ + libgl1-mesa-dri \ + libglapi-mesa \ + libosmesa6 \ + mesa-utils \ + xvfb \ + && rm -rf /var/lib/apt/lists/* # Set Rust paths ENV RUSTUP_HOME=/usr/local/rustup \ CARGO_HOME=/usr/local/cargo \ PATH=/usr/local/cargo/bin:$PATH +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + # Install Rust, wasm target, and pnpm RUN curl --silent --show-error --location --fail --retry 3 \ --proto '=https' --tlsv1.2 \ @@ -16,4 +25,5 @@ RUN curl --silent --show-error --location --fail --retry 3 \ && rm /tmp/rustup-init.sh \ && rustup target add wasm32-unknown-unknown \ && rm -rf /usr/local/rustup/toolchains/*/share/doc \ - && npm i -g pnpm + && npm i -g pnpm \ + && pnpx playwright install firefox diff --git a/app/.gitignore b/app/.gitignore index 3187a0f..38aa141 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -27,3 +27,5 @@ dist-ssr *.sln *.sw? build/ + +test-results/ diff --git a/app/e2e/main.test.ts b/app/e2e/main.test.ts new file mode 100644 index 0000000..eab844f --- /dev/null +++ b/app/e2e/main.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from '@playwright/test'; + +test('test', async ({ page }) => { + // Listen for console messages + page.on('console', msg => { + console.log(`[Browser Console] ${msg.type()}: ${msg.text()}`); + }); + + await page.goto('http://localhost:4173', { waitUntil: 'load' }); + + // await expect(page).toHaveScreenshot(); + await expect(page.locator('.graph-wrapper')).toHaveScreenshot(); + + await page.getByRole('button', { name: 'projects' }).click(); + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('combobox').selectOption('2'); + await page.getByRole('textbox', { name: 'Project name' }).click(); + await page.getByRole('textbox', { name: 'Project name' }).fill('Test Project'); + await page.getByRole('button', { name: 'Create' }).click(); + + const expectedNodes = [ + { + id: '10', + type: 'max/plantarium/stem', + props: { + amount: 50, + length: 4, + thickness: 1 + } + }, + { + id: '11', + type: 'max/plantarium/noise', + props: { + scale: 0.5, + strength: 5 + } + }, + { + id: '9', + type: 'max/plantarium/output' + } + ]; + + for (const node of expectedNodes) { + const wrapper = page.locator( + `div.wrapper[data-node-id="${node.id}"][data-node-type="${node.type}"]` + ); + await expect(wrapper).toBeVisible(); + if ('props' in node) { + const props = node.props as unknown as Record; + for (const propId in node.props) { + const expectedValue = props[propId]; + const inputElement = page.locator( + `div.wrapper[data-node-type="${node.type}"][data-node-input="${propId}"] input[type="number"]` + ); + const value = parseFloat(await inputElement.inputValue()); + expect(value).toBe(expectedValue); + } + } + } +}); diff --git a/app/e2e/main.test.ts-snapshots/test-1-linux.png b/app/e2e/main.test.ts-snapshots/test-1-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..d9e3eaa5fb131be938cd191ae41d65ddd9177c4e GIT binary patch literal 42047 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 literal 0 HcmV?d00001 diff --git a/app/package.json b/app/package.json index d7ee252..154a9c5 100644 --- a/app/package.json +++ b/app/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite dev", "build": "svelte-kit sync && vite build", - "test": "vitest", + "test:unit": "vitest", + "test": "npm run test:unit -- --run && npm run test:e2e", + "test:e2e": "playwright test", "preview": "vite preview", "format": "dprint fmt -c '../.dprint.jsonc' .", "format:check": "dprint check -c '../.dprint.jsonc' .", @@ -25,8 +27,7 @@ "idb": "^8.0.3", "jsondiffpatch": "^0.7.3", "tailwindcss": "^4.1.18", - "three": "^0.182.0", - "wabt": "^1.0.39" + "three": "^0.182.0" }, "devDependencies": { "@eslint/compat": "^2.0.2", @@ -34,11 +35,13 @@ "@iconify-json/tabler": "^1.2.26", "@iconify/tailwind4": "^1.2.1", "@nodarium/types": "workspace:", + "@playwright/test": "^1.58.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tsconfig/svelte": "^5.0.6", "@types/file-saver": "^2.0.7", "@types/three": "^0.182.0", + "@vitest/browser-playwright": "^4.0.18", "dprint": "^0.51.1", "eslint": "^9.39.2", "eslint-plugin-svelte": "^3.14.0", @@ -52,6 +55,7 @@ "vite-plugin-comlink": "^5.3.0", "vite-plugin-glsl": "^1.5.5", "vite-plugin-wasm": "^3.5.0", - "vitest": "^4.0.17" + "vitest": "^4.0.17", + "vitest-browser-svelte": "^2.0.2" } } diff --git a/app/playwright.config.ts b/app/playwright.config.ts new file mode 100644 index 0000000..76e6db0 --- /dev/null +++ b/app/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + webServer: { command: 'pnpm build && pnpm preview', port: 4173 }, + testDir: 'e2e', + use: { + browserName: 'firefox', + launchOptions: { + firefoxUserPrefs: { + // Force WebGL even without a GPU + 'webgl.force-enabled': true, + 'webgl.disabled': false, + // Use software rendering (Mesa) instead of hardware + 'layers.acceleration.disabled': true, + 'gfx.webrender.software': true, + 'webgl.enable-webgl2': true + } + } + } +}); diff --git a/app/src/app.css b/app/src/app.css index 731a79f..933ccea 100644 --- a/app/src/app.css +++ b/app/src/app.css @@ -2,7 +2,7 @@ @source "../../packages/ui/**/*.svelte"; @plugin "@iconify/tailwind4" { prefix: "i"; - icon-sets: from-folder(custom, "./src/lib/icons"); + icon-sets: from-folder("custom", "./src/lib/icons"); } body * { diff --git a/app/src/lib/graph-templates.test.ts b/app/src/lib/graph-templates.test.ts new file mode 100644 index 0000000..442f895 --- /dev/null +++ b/app/src/lib/graph-templates.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { grid } from '$lib/graph-templates/grid'; +import { tree } from '$lib/graph-templates/tree'; + +describe('graph-templates', () => { + describe('grid', () => { + it('should create a grid graph with nodes and edges', () => { + const result = grid(2, 3); + expect(result.nodes.length).toBeGreaterThan(0); + expect(result.edges.length).toBeGreaterThan(0); + }); + + it('should have output node at the end', () => { + const result = grid(1, 1); + const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output'); + expect(outputNode).toBeDefined(); + }); + + it('should create nodes based on grid dimensions', () => { + const result = grid(2, 2); + const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math'); + expect(mathNodes.length).toBeGreaterThan(0); + const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output'); + expect(outputNode).toBeDefined(); + }); + + it('should have output node at the end', () => { + const result = grid(1, 1); + const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output'); + expect(outputNode).toBeDefined(); + }); + + it('should create nodes based on grid dimensions', () => { + const result = grid(2, 2); + const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math'); + expect(mathNodes.length).toBeGreaterThan(0); + const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output'); + expect(outputNode).toBeDefined(); + }); + + it('should have valid node positions', () => { + const result = grid(3, 2); + + result.nodes.forEach(node => { + expect(node.position).toHaveLength(2); + expect(typeof node.position[0]).toBe('number'); + expect(typeof node.position[1]).toBe('number'); + }); + }); + + it('should generate valid graph structure', () => { + const result = grid(2, 2); + + result.nodes.forEach(node => { + expect(typeof node.id).toBe('number'); + expect(node.type).toBeTruthy(); + }); + + result.edges.forEach(edge => { + expect(edge).toHaveLength(4); + }); + }); + }); + + describe('tree', () => { + it('should create a tree graph with specified depth', () => { + const result = tree(0); + + expect(result.nodes.length).toBeGreaterThan(0); + expect(result.edges.length).toBeGreaterThan(0); + }); + + it('should have root output node', () => { + const result = tree(2); + + const outputNode = result.nodes.find(n => n.type === 'max/plantarium/output'); + expect(outputNode).toBeDefined(); + expect(outputNode?.id).toBe(0); + }); + + it('should increase node count with depth', () => { + const tree0 = tree(0); + const tree1 = tree(1); + const tree2 = tree(2); + + expect(tree0.nodes.length).toBeLessThan(tree1.nodes.length); + expect(tree1.nodes.length).toBeLessThan(tree2.nodes.length); + }); + + it('should create binary tree structure', () => { + const result = tree(2); + + const mathNodes = result.nodes.filter(n => n.type === 'max/plantarium/math'); + expect(mathNodes.length).toBeGreaterThan(0); + + const edgeCount = result.edges.length; + expect(edgeCount).toBe(result.nodes.length - 1); + }); + + it('should have valid node positions', () => { + const result = tree(3); + + result.nodes.forEach(node => { + expect(node.position).toHaveLength(2); + expect(typeof node.position[0]).toBe('number'); + expect(typeof node.position[1]).toBe('number'); + }); + }); + }); +}); diff --git a/app/src/lib/graph-templates/index.ts b/app/src/lib/graph-templates/index.ts index 8c69b52..9984539 100644 --- a/app/src/lib/graph-templates/index.ts +++ b/app/src/lib/graph-templates/index.ts @@ -4,4 +4,5 @@ export { default as lottaFaces } from './lotta-faces.json'; export { default as lottaNodesAndFaces } from './lotta-nodes-and-faces.json'; export { default as lottaNodes } from './lotta-nodes.json'; export { plant } from './plant'; +export { default as simple } from './simple.json'; export { tree } from './tree'; diff --git a/app/src/lib/graph-templates/simple.json b/app/src/lib/graph-templates/simple.json new file mode 100644 index 0000000..0834128 --- /dev/null +++ b/app/src/lib/graph-templates/simple.json @@ -0,0 +1,63 @@ +{ + "id": 0, + "settings": { + "resolution.circle": 54, + "resolution.curve": 20, + "randomSeed": true + }, + "meta": { + "title": "New Project", + "lastModified": "2026-02-03T16:56:40.375Z" + }, + "nodes": [ + { + "id": 9, + "position": [ + 215, + 85 + ], + "type": "max/plantarium/output", + "props": {} + }, + { + "id": 10, + "position": [ + 165, + 72.5 + ], + "type": "max/plantarium/stem", + "props": { + "amount": 50, + "length": 4, + "thickness": 1 + } + }, + { + "id": 11, + "position": [ + 190, + 77.5 + ], + "type": "max/plantarium/noise", + "props": { + "plant": 0, + "scale": 0.5, + "strength": 5 + } + } + ], + "edges": [ + [ + 10, + 0, + 11, + "plant" + ], + [ + 11, + 0, + 9, + "input" + ] + ] +} diff --git a/app/src/lib/helpers.test.ts b/app/src/lib/helpers.test.ts new file mode 100644 index 0000000..236f70a --- /dev/null +++ b/app/src/lib/helpers.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { + snapToGrid, + lerp, + humanizeNumber, + humanizeDuration, + debounce, + clone +} from '$lib/helpers'; + +describe('helpers', () => { + describe('snapToGrid', () => { + it('should snap to nearest grid point', () => { + expect(snapToGrid(5, 10)).toBe(10); + expect(snapToGrid(15, 10)).toBe(20); + expect(snapToGrid(0, 10)).toBe(0); + expect(snapToGrid(-10, 10)).toBe(-10); + }); + + it('should snap exact midpoint values', () => { + expect(snapToGrid(5, 10)).toBe(10); + }); + + it('should use default grid size of 10', () => { + expect(snapToGrid(5)).toBe(10); + expect(snapToGrid(15)).toBe(20); + }); + + it('should handle values exactly on grid', () => { + expect(snapToGrid(10, 10)).toBe(10); + expect(snapToGrid(20, 10)).toBe(20); + }); + }); + + describe('lerp', () => { + it('should linearly interpolate between two values', () => { + expect(lerp(0, 100, 0)).toBe(0); + expect(lerp(0, 100, 0.5)).toBe(50); + expect(lerp(0, 100, 1)).toBe(100); + }); + + it('should handle negative values', () => { + expect(lerp(-50, 50, 0.5)).toBe(0); + expect(lerp(-100, 0, 0.5)).toBe(-50); + }); + + it('should handle t values outside 0-1 range', () => { + expect(lerp(0, 100, -0.5)).toBe(-50); + expect(lerp(0, 100, 1.5)).toBe(150); + }); + }); + + describe('humanizeNumber', () => { + it('should return unchanged numbers below 1000', () => { + expect(humanizeNumber(0)).toBe('0'); + expect(humanizeNumber(999)).toBe('999'); + }); + + it('should add K suffix for thousands', () => { + expect(humanizeNumber(1000)).toBe('1K'); + expect(humanizeNumber(1500)).toBe('1.5K'); + expect(humanizeNumber(999999)).toBe('1000K'); + }); + + it('should add M suffix for millions', () => { + expect(humanizeNumber(1000000)).toBe('1M'); + expect(humanizeNumber(2500000)).toBe('2.5M'); + }); + + it('should add B suffix for billions', () => { + expect(humanizeNumber(1000000000)).toBe('1B'); + }); + }); + + describe('humanizeDuration', () => { + it('should return ms for very short durations', () => { + expect(humanizeDuration(100)).toBe('100ms'); + expect(humanizeDuration(999)).toBe('999ms'); + }); + + it('should format seconds', () => { + expect(humanizeDuration(1000)).toBe('1s'); + expect(humanizeDuration(1500)).toBe('1s500ms'); + expect(humanizeDuration(59000)).toBe('59s'); + }); + + it('should format minutes', () => { + expect(humanizeDuration(60000)).toBe('1m'); + expect(humanizeDuration(90000)).toBe('1m 30s'); + }); + + it('should format hours', () => { + expect(humanizeDuration(3600000)).toBe('1h'); + expect(humanizeDuration(3661000)).toBe('1h 1m 1s'); + }); + + it('should format days', () => { + expect(humanizeDuration(86400000)).toBe('1d'); + expect(humanizeDuration(90061000)).toBe('1d 1h 1m 1s'); + }); + + it('should handle zero', () => { + expect(humanizeDuration(0)).toBe('0ms'); + }); + }); + + describe('debounce', () => { + it('should return a function', () => { + const fn = debounce(() => {}, 100); + expect(typeof fn).toBe('function'); + }); + + it('should only call once when invoked multiple times within delay', () => { + let callCount = 0; + const fn = debounce(() => { callCount++; }, 100); + fn(); + const firstCall = callCount; + fn(); + fn(); + expect(callCount).toBe(firstCall); + }); + }); + + describe('clone', () => { + it('should deep clone objects', () => { + const original = { a: 1, b: { c: 2 } }; + const cloned = clone(original); + + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + expect(cloned.b).not.toBe(original.b); + }); + + it('should handle arrays', () => { + const original = [1, 2, [3, 4]]; + const cloned = clone(original); + + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + expect(cloned[2]).not.toBe(original[2]); + }); + + it('should handle primitives', () => { + expect(clone(42)).toBe(42); + expect(clone('hello')).toBe('hello'); + expect(clone(true)).toBe(true); + expect(clone(null)).toBe(null); + }); + }); +}); diff --git a/app/src/lib/helpers/deepMerge.test.ts b/app/src/lib/helpers/deepMerge.test.ts new file mode 100644 index 0000000..dde7fd2 --- /dev/null +++ b/app/src/lib/helpers/deepMerge.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { mergeDeep, isObject } from '$lib/helpers/deepMerge'; + +describe('deepMerge', () => { + describe('isObject', () => { + it('should return true for plain objects', () => { + expect(isObject({})).toBe(true); + expect(isObject({ a: 1 })).toBe(true); + }); + + it('should return false for non-objects', () => { + expect(isObject([])).toBe(false); + expect(isObject('string')).toBe(false); + expect(isObject(42)).toBe(false); + expect(isObject(undefined)).toBe(false); + }); + }); + + describe('mergeDeep', () => { + it('should merge two flat objects', () => { + const target = { a: 1, b: 2 }; + const source = { b: 3, c: 4 }; + const result = mergeDeep(target, source); + + expect(result).toEqual({ a: 1, b: 3, c: 4 }); + }); + + it('should deeply merge nested objects', () => { + const target = { a: { x: 1 }, b: { y: 2 } }; + const source = { a: { y: 2 }, c: { z: 3 } }; + const result = mergeDeep(target, source); + + expect(result).toEqual({ + a: { x: 1, y: 2 }, + b: { y: 2 }, + c: { z: 3 } + }); + }); + + it('should handle multiple sources', () => { + const target = { a: 1 }; + const source1 = { b: 2 }; + const source2 = { c: 3 }; + const result = mergeDeep(target, source1, source2); + + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it('should return target if no sources provided', () => { + const target = { a: 1 }; + const result = mergeDeep(target); + + expect(result).toBe(target); + }); + + it('should overwrite non-object values', () => { + const target = { a: { b: 1 } }; + const source = { a: 'string' }; + const result = mergeDeep(target, source); + + expect(result.a).toBe('string'); + }); + + it('should handle arrays by replacing', () => { + const target = { a: [1, 2] }; + const source = { a: [3, 4] }; + const result = mergeDeep(target, source); + + expect(result.a).toEqual([3, 4]); + }); + }); +}); diff --git a/app/src/lib/project-manager/ProjectManager.svelte b/app/src/lib/project-manager/ProjectManager.svelte index 4d3ff66..88486dc 100644 --- a/app/src/lib/project-manager/ProjectManager.svelte +++ b/app/src/lib/project-manager/ProjectManager.svelte @@ -1,13 +1,13 @@ -
+

Project

{#if showNewProject} -
+
e.key === 'Enter' && handleCreate()} /> - + t.name)} bind:value={selectedTemplateIndex} />
{/if} -
+
{#if projectManager.loading}

Loading...

{/if} -
    +
      {#each projectManager.projects as project (project.id)}
    • projectManager.handleSelectProject(project.id!)} role="button" @@ -89,10 +86,10 @@ e.key === 'Enter' && projectManager.handleSelectProject(project.id!)} > -
      +
      {project.meta?.title || 'Untitled'}