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 0000000..d9e3eaa Binary files /dev/null and b/app/e2e/main.test.ts-snapshots/test-1-linux.png differ 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'}