From 3c82241690d354f3a63dce9570de88cabd03a94f Mon Sep 17 00:00:00 2001 From: Lorchie Date: Fri, 26 Jun 2026 17:54:00 +0200 Subject: [PATCH] chore: add ESLint config + portable test runner + pre-push hook --- .githooks/pre-push | 12 ++ .gitignore | 2 + api/tests/test_extension_process.py | 71 ++++++++- api/tests/test_runner.py | 90 +++++++++++ .../main/artifact-registry-service.test.ts | 2 +- electron/main/ipc-handlers.ts | 2 +- electron/main/updater.ts | 1 - eslint.config.mjs | 63 ++++++++ package.json | 9 +- scripts/run-pytests.mjs | 37 +++++ scripts/setup-hooks.mjs | 11 ++ src/App.tsx | 2 + src/areas/generate/GeneratePage.tsx | 1 + src/areas/generate/assetLibraryUi.ts | 2 - src/areas/generate/components/ChatPanel.tsx | 1 + .../generate/components/GenerationOptions.tsx | 2 + src/areas/generate/components/Viewer3D.tsx | 4 + .../generate/components/WorkflowPanel.tsx | 7 +- src/areas/models/ModelsPage.tsx | 6 +- src/areas/models/utils.test.mjs | 36 +++++ src/areas/setup/FirstRunSetup.tsx | 1 + src/areas/workflows/WorkflowsPage.tsx | 97 ++---------- src/areas/workflows/nodeBehaviors.test.mjs | 109 +++++++++++++ src/areas/workflows/nodes/AddToSceneNode.tsx | 2 +- .../nodes/mesh-exporter/processor.ts | 1 - .../nodes/mesh-optimizer/processor.ts | 1 - src/areas/workflows/preflight.test.mjs | 133 ++++++++++++++++ src/shared/components/ui/Toast.tsx | 1 + src/shared/hooks/useApi.test.mjs | 134 ++++++++++++++++ src/shared/hooks/useApi.ts | 6 +- src/shared/hooks/useGeneration.ts | 1 + src/shared/stores/workflowsStore.test.mjs | 148 ++++++++++++++++++ src/shared/stores/workflowsStore.ts | 14 +- src/shared/types/electron.d.ts | 3 + src/shared/types/gaussian-splats-3d.d.ts | 3 + src/shared/utils/format.test.mjs | 62 ++++++++ tsconfig.web.json | 9 +- 37 files changed, 975 insertions(+), 111 deletions(-) create mode 100644 .githooks/pre-push create mode 100644 eslint.config.mjs create mode 100644 scripts/run-pytests.mjs create mode 100644 scripts/setup-hooks.mjs create mode 100644 src/areas/models/utils.test.mjs create mode 100644 src/areas/workflows/nodeBehaviors.test.mjs create mode 100644 src/areas/workflows/preflight.test.mjs create mode 100644 src/shared/hooks/useApi.test.mjs create mode 100644 src/shared/stores/workflowsStore.test.mjs create mode 100644 src/shared/types/gaussian-splats-3d.d.ts create mode 100644 src/shared/utils/format.test.mjs diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 0000000..cafa508 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,12 @@ +#!/bin/sh +# Run the full test suite before every push; abort on any failure. +# Bypass in a pinch with: git push --no-verify +echo "[pre-push] Running test suite (npm test)..." +if ! npm test; then + echo "" + echo "[pre-push] Tests failed — push aborted." + echo "[pre-push] Fix the tests, or bypass with: git push --no-verify" + exit 1 +fi +echo "[pre-push] All tests passed — pushing." +exit 0 diff --git a/.gitignore b/.gitignore index b3e1fa5..0afebef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ out/ dist/ +install.bat +launch.bat # Generated at build time by scripts/download-python-embed.js resources/python-embed/ diff --git a/api/tests/test_extension_process.py b/api/tests/test_extension_process.py index cbe7562..1cabb06 100644 --- a/api/tests/test_extension_process.py +++ b/api/tests/test_extension_process.py @@ -1,13 +1,19 @@ import io +import platform import queue import unittest +from pathlib import Path -from services.extension_process import ExtensionProcess +from services.extension_process import ExtensionProcess, _venv_python + + +def _make_proc() -> ExtensionProcess: + return ExtensionProcess(ext_dir=None, manifest={"id": "demo"}) # type: ignore[arg-type] class ExtensionProcessTests(unittest.TestCase): def test_read_loop_writes_sentinel_to_own_queue_only(self) -> None: - proc = ExtensionProcess(ext_dir=None, manifest={"id": "demo"}) # type: ignore[arg-type] + proc = _make_proc() old_queue: queue.Queue = queue.Queue() new_queue: queue.Queue = queue.Queue() @@ -21,5 +27,66 @@ def test_read_loop_writes_sentinel_to_own_queue_only(self) -> None: self.assertTrue(new_queue.empty()) +class VenvPythonTests(unittest.TestCase): + def test_resolves_interpreter_path_for_current_platform(self) -> None: + result = _venv_python(Path("/tmp/ext")) + if platform.system() == "Windows": + self.assertEqual(result, Path("/tmp/ext") / "venv" / "Scripts" / "python.exe") + else: + self.assertEqual(result, Path("/tmp/ext") / "venv" / "bin" / "python") + + +class MissingModuleExtractionTests(unittest.TestCase): + def test_extracts_module_name_from_message(self) -> None: + proc = _make_proc() + name = proc._extract_missing_module({"message": "No module named 'PIL'"}) + self.assertEqual(name, "PIL") + + def test_extracts_module_name_from_traceback(self) -> None: + proc = _make_proc() + name = proc._extract_missing_module( + {"message": "boom", "traceback": "...\nModuleNotFoundError: No module named \"numpy\"\n"} + ) + self.assertEqual(name, "numpy") + + def test_returns_none_when_no_missing_module(self) -> None: + proc = _make_proc() + self.assertIsNone(proc._extract_missing_module({"message": "some other error"})) + + +class AutoRepairPackageTests(unittest.TestCase): + """Safety: only known modules map to a package; never guess arbitrary names.""" + + def test_maps_known_module_to_package(self) -> None: + proc = _make_proc() + self.assertEqual(proc._resolve_auto_repair_package("PIL"), "Pillow") + + def test_maps_known_module_via_root_package(self) -> None: + proc = _make_proc() + self.assertEqual(proc._resolve_auto_repair_package("PIL.Image"), "Pillow") + + def test_returns_none_for_unknown_module(self) -> None: + proc = _make_proc() + self.assertIsNone(proc._resolve_auto_repair_package("totally_unknown_pkg")) + + +class RecvTests(unittest.TestCase): + def test_returns_message_from_queue(self) -> None: + proc = _make_proc() + proc._queue.put({"type": "ready"}) + self.assertEqual(proc._recv(timeout=1.0), {"type": "ready"}) + + def test_none_sentinel_raises_runtime_error(self) -> None: + proc = _make_proc() + proc._queue.put(None) + with self.assertRaises(RuntimeError): + proc._recv(timeout=1.0) + + def test_empty_queue_raises_timeout_error(self) -> None: + proc = _make_proc() + with self.assertRaises(TimeoutError): + proc._recv(timeout=0.05) + + if __name__ == "__main__": unittest.main() diff --git a/api/tests/test_runner.py b/api/tests/test_runner.py index ada9dfc..f11a9c2 100644 --- a/api/tests/test_runner.py +++ b/api/tests/test_runner.py @@ -1,7 +1,11 @@ import unittest import os +import io +import sys +import json import tempfile import importlib +from contextlib import redirect_stdout from pathlib import Path @@ -63,6 +67,92 @@ def test_apply_manifest_metadata_prefers_node_specific_values(self) -> None: self.assertEqual(gen.download_check, "node/file") self.assertEqual(gen._params_schema, [{"id": "node"}]) + def test_apply_manifest_metadata_falls_back_to_manifest_when_node_empty(self) -> None: + gen = type("Gen", (), {})() + manifest = { + "hf_repo": "top/repo", + "hf_skip_prefixes": ["top/"], + "download_check": "top/file", + "params_schema": [{"id": "top"}], + } + + _apply_manifest_metadata(gen, manifest, {}) + + self.assertEqual(gen.hf_repo, "top/repo") + self.assertEqual(gen.hf_skip_prefixes, ["top/"]) + self.assertEqual(gen.download_check, "top/file") + self.assertEqual(gen._params_schema, [{"id": "top"}]) + + +class SelectNodeTests(unittest.TestCase): + def test_returns_empty_dict_when_manifest_has_no_nodes(self) -> None: + self.assertEqual(_select_node({}, ""), {}) + + def test_falls_back_to_first_node_when_override_matches_nothing(self) -> None: + manifest = {"nodes": [{"id": "a"}, {"id": "b"}]} + self.assertEqual(_select_node(manifest, str(Path("/tmp/ext/zzz")))["id"], "a") + + def test_returns_first_node_when_no_override(self) -> None: + manifest = {"nodes": [{"id": "a"}, {"id": "b"}]} + self.assertEqual(_select_node(manifest, "")["id"], "a") + + +class ResolveReadySchemaTests(unittest.TestCase): + def test_uses_generator_classmethod_when_available(self) -> None: + class GenClass: + @classmethod + def params_schema(cls): + return [{"id": "from-class"}] + + schema = _resolve_ready_schema(GenClass, {"params_schema": [{"id": "node"}]}, {}) + self.assertEqual(schema, [{"id": "from-class"}]) + + def test_falls_back_to_manifest_when_node_has_no_schema(self) -> None: + class GenClass: + @classmethod + def params_schema(cls): + raise RuntimeError("unavailable") + + schema = _resolve_ready_schema(GenClass, {}, {"params_schema": [{"id": "manifest"}]}) + self.assertEqual(schema, [{"id": "manifest"}]) + + +class ProtocolTests(unittest.TestCase): + """recv()/send() implement the newline-delimited JSON wire protocol.""" + + def setUp(self) -> None: + self._stdin = sys.stdin + + def tearDown(self) -> None: + sys.stdin = self._stdin + + def test_recv_parses_lines_and_skips_blank_lines(self) -> None: + sys.stdin = io.StringIO('{"a": 1}\n\n \n{"b": 2}\n') + self.assertEqual(list(runner.recv()), [{"a": 1}, {"b": 2}]) + + def test_recv_skips_invalid_json_without_crashing_and_logs_error(self) -> None: + sys.stdin = io.StringIO('not json\n{"ok": 1}\n') + out = io.StringIO() + with redirect_stdout(out): + messages = list(runner.recv()) + + self.assertEqual(messages, [{"ok": 1}]) + logged = [json.loads(line) for line in out.getvalue().splitlines() if line.strip()] + self.assertTrue(any( + entry.get("level") == "error" and "invalid JSON" in entry.get("message", "") + for entry in logged + )) + + def test_send_writes_single_json_line(self) -> None: + out = io.StringIO() + with redirect_stdout(out): + runner.send({"type": "ready", "params_schema": []}) + + written = out.getvalue() + self.assertTrue(written.endswith("\n")) + self.assertEqual(written.count("\n"), 1) + self.assertEqual(json.loads(written), {"type": "ready", "params_schema": []}) + if __name__ == "__main__": unittest.main() diff --git a/electron/main/artifact-registry-service.test.ts b/electron/main/artifact-registry-service.test.ts index 91ba259..882120d 100644 --- a/electron/main/artifact-registry-service.test.ts +++ b/electron/main/artifact-registry-service.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import test from 'node:test' diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 88fe240..0da5027 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -1093,7 +1093,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe }) // Install a local extension by creating a symlink/junction to a local folder - ipcMain.handle('extensions:installFromLocal', async (event) => { + ipcMain.handle('extensions:installFromLocal', async () => { const win = getWindow() const emit = (data: object) => win?.webContents.send('extensions:installProgress', data) diff --git a/electron/main/updater.ts b/electron/main/updater.ts index b0326e5..e18c9e0 100644 --- a/electron/main/updater.ts +++ b/electron/main/updater.ts @@ -5,7 +5,6 @@ import { logger } from './logger' type WindowGetter = () => BrowserWindow | null export function initAutoUpdater(getWindow: WindowGetter): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any autoUpdater.logger = logger as any autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..acc2a48 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,63 @@ +import js from '@eslint/js' +import tseslint from 'typescript-eslint' +import reactHooks from 'eslint-plugin-react-hooks' +import globals from 'globals' + +export default tseslint.config( + { + ignores: [ + 'dist/**', + 'out/**', + 'node_modules/**', + 'resources/**', + 'api/**', + 'arch/**', + 'docs/**', + '**/*.test.mjs' + ] + }, + js.configs.recommended, + ...tseslint.configs.recommended, + // Renderer (React, browser environment) + { + files: ['src/**/*.{ts,tsx}'], + plugins: { 'react-hooks': reactHooks }, + languageOptions: { + globals: { ...globals.browser } + }, + rules: { + // Proven, high-value hook rules. The v7 "recommended" set adds many + // opinionated React-Compiler-era rules that flood a legacy codebase. + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn' + } + }, + // Main / preload / scripts (Node environment) + { + files: ['electron/**/*.{ts,mts,mjs}', 'scripts/**/*.{js,mjs,cjs}', 'tools/**/*.{ts,mjs}'], + languageOptions: { + globals: { ...globals.node } + } + }, + // CommonJS files (Node, require/module) + { + files: ['scripts/**/*.{js,cjs}', 'tailwind.config.js', 'postcss.config.js', '**/*.cjs'], + languageOptions: { + sourceType: 'commonjs', + globals: { ...globals.node } + } + }, + // Project-wide rule tuning + { + rules: { + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'off', + // require() is used intentionally in Electron main and Node build scripts + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-unused-expressions': ['error', { allowShortCircuit: true, allowTernary: true }], + // Stripping ANSI escape codes requires control chars in regex + 'no-control-regex': 'off', + 'no-empty': ['warn', { allowEmptyCatch: true }] + } + } +) diff --git a/package.json b/package.json index e4f205e..bf0c382 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,11 @@ "dev": "node scripts/build-builtins.mjs && electron-vite dev", "build": "node scripts/build-builtins.mjs && electron-vite build", "preview": "electron-vite preview", + "prepare": "node scripts/setup-hooks.mjs", "prepare-resources": "node scripts/download-python-embed.js", - "test": "cd api && python3 -m unittest discover -s tests && cd .. && node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs", + "test": "npm run test:py && npm run test:node", + "test:py": "node scripts/run-pytests.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." @@ -33,6 +36,7 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@eslint/js": "^9.39.4", "@types/node": "^22.10.1", "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", @@ -45,9 +49,12 @@ "electron-builder": "^24.13.3", "electron-vite": "^2.3.0", "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.6.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", + "typescript-eslint": "^8.61.1", "vite": "^5.4.0" }, "build": { diff --git a/scripts/run-pytests.mjs b/scripts/run-pytests.mjs new file mode 100644 index 0000000..acb1e72 --- /dev/null +++ b/scripts/run-pytests.mjs @@ -0,0 +1,37 @@ +// Portable Python test runner. +// `python3` is the macOS/Linux name but does not exist on Windows (where the +// interpreter is `python` or the `py` launcher). Try each candidate until one +// actually runs, then forward unittest's exit code. +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const apiDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'api') + +const candidates = [ + ['python3', []], + ['python', []], + ['py', ['-3']], +] + +function works(cmd, prefix) { + try { + const r = spawnSync(cmd, [...prefix, '--version'], { stdio: 'ignore' }) + return r.status === 0 + } catch { + return false + } +} + +const found = candidates.find(([cmd, prefix]) => works(cmd, prefix)) +if (!found) { + console.error('[run-pytests] No Python interpreter found (tried python3, python, py -3).') + process.exit(1) +} + +const [cmd, prefix] = found +const result = spawnSync(cmd, [...prefix, '-m', 'unittest', 'discover', '-s', 'tests'], { + cwd: apiDir, + stdio: 'inherit', +}) +process.exit(result.status ?? 1) diff --git a/scripts/setup-hooks.mjs b/scripts/setup-hooks.mjs new file mode 100644 index 0000000..f252943 --- /dev/null +++ b/scripts/setup-hooks.mjs @@ -0,0 +1,11 @@ +// Wire git to the version-controlled hooks in .githooks (runs via `prepare` on +// every `npm install`). Failures are swallowed so installing outside a git +// checkout (e.g. CI from a tarball) never breaks the install. +import { spawnSync } from 'node:child_process' + +const r = spawnSync('git', ['config', 'core.hooksPath', '.githooks'], { stdio: 'ignore' }) +if (r.status === 0) { + console.log('[setup-hooks] git hooks enabled (core.hooksPath = .githooks)') +} else { + console.log('[setup-hooks] skipped (not a git checkout) — hooks not wired') +} diff --git a/src/App.tsx b/src/App.tsx index 6409b76..1855186 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ export default function App(): JSX.Element { window.electron.app.offError() window.electron.updater.offMajorMinorAvailable() } + // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount; store actions are stable }, []) // Apply before paint to avoid a flash of default font/size on launch. @@ -38,6 +39,7 @@ export default function App(): JSX.Element { useEffect(() => { if (setupStatus === 'done') initApp() + // eslint-disable-next-line react-hooks/exhaustive-deps -- react to setup transition only; initApp is stable }, [setupStatus]) useEffect(() => { diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index 643c7e1..521935a 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -637,6 +637,7 @@ export default function GeneratePage(): JSX.Element { useEffect(() => { if (openPanel !== 'library' || libraryLoaded || libraryLoading) return void loadLibraryEntries() + // eslint-disable-next-line react-hooks/exhaustive-deps -- lazy-load guarded by loaded/loading flags }, [openPanel, libraryLoaded, libraryLoading]) async function handleUnloadAll() { diff --git a/src/areas/generate/assetLibraryUi.ts b/src/areas/generate/assetLibraryUi.ts index fb7e581..5e83e65 100644 --- a/src/areas/generate/assetLibraryUi.ts +++ b/src/areas/generate/assetLibraryUi.ts @@ -24,8 +24,6 @@ export interface AssetLibraryOpenSelection { job: GenerationJob } -const WORKSPACE_URL_PREFIX = '/workspace/' - const ASSET_LIBRARY_CAPABILITY_SECTIONS = [ { capability: 'mesh', label: 'Mesh' }, { capability: 'rigged-mesh', label: 'Rigged mesh' }, diff --git a/src/areas/generate/components/ChatPanel.tsx b/src/areas/generate/components/ChatPanel.tsx index 9ba89ed..7618f75 100644 --- a/src/areas/generate/components/ChatPanel.tsx +++ b/src/areas/generate/components/ChatPanel.tsx @@ -318,6 +318,7 @@ export default function ChatPanel(): JSX.Element { // Send automatic follow-up to agent const completionCtx = `Workflow '${wf.name}' just completed.${runState.outputUrl ? ` Output mesh: ${runState.outputUrl}` : ''} Ask the user what they'd like to do next.` callAgent(messagesRef.current, { workflowCompletion: completionCtx }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- fire on run-status transition; error/outputUrl read atomically }, [runState.status, pendingWorkflow]) useEffect(() => { diff --git a/src/areas/generate/components/GenerationOptions.tsx b/src/areas/generate/components/GenerationOptions.tsx index a3ac34c..41841de 100644 --- a/src/areas/generate/components/GenerationOptions.tsx +++ b/src/areas/generate/components/GenerationOptions.tsx @@ -316,6 +316,7 @@ export default function GenerationOptions(): JSX.Element { initDefaults(params) }) .catch(() => setSchema([])) + // eslint-disable-next-line react-hooks/exhaustive-deps -- refetch only when model/apiUrl change; initDefaults is local }, [generationOptions.modelId, apiUrl]) function initDefaults(params: ParamSchema[]) { @@ -356,6 +357,7 @@ export default function GenerationOptions(): JSX.Element { } }) .catch(() => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps -- load once backend is ready; getAllModelsStatus is re-created each render (would loop) }, [apiUrl]) return ( diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx index f7b241a..8cff822 100644 --- a/src/areas/generate/components/Viewer3D.tsx +++ b/src/areas/generate/components/Viewer3D.tsx @@ -76,6 +76,7 @@ function CanvasCapture({ const { gl } = useThree() useEffect(() => { domRef.current = gl.domElement + // eslint-disable-next-line react-hooks/exhaustive-deps -- domRef is a stable ref }, [gl]) return null } @@ -245,6 +246,7 @@ function SceneMeshModel({ }) const roundedTriangles = Math.round(triangles) onStats({ vertices: Math.round(vertices), triangles: roundedTriangles }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute on scene change only; onStats is a stable callback }, [scene]) // Thumbnail capture (kept for future use) @@ -846,6 +848,7 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo setSelected(false) setViewMode('solid') setStoreMeshStats(null) + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when the model changes; setters are stable }, [modelUrl]) // Clear the shared selection when the viewer unmounts — the store would @@ -863,6 +866,7 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) + // eslint-disable-next-line react-hooks/exhaustive-deps -- setSelected is a stable store setter }, [selected, setCurrentJob]) const handleScreenshot = () => { diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index 5d6c14f..427ba17 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -446,8 +446,8 @@ function EmbeddedCanvas({ workflow, allExtensions }: { workflow: Workflow allExtensions: ReturnType }) { - const [nodes, setNodes, onNodesChange] = useNodesState(workflow.nodes as FlowNode[]) - const [edges, setEdges, onEdgesChange] = useEdgesState(workflow.edges as FlowEdge[]) + const [nodes, setNodes] = useNodesState(workflow.nodes as FlowNode[]) + const [edges] = useEdgesState(workflow.edges as FlowEdge[]) const { updateNodeData } = useReactFlow() const { navigate } = useNavStore() @@ -468,6 +468,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: { if (runState.status !== 'done' || !runState.outputUrl) return const out = nodes.find((n) => n.type === 'outputNode') if (out) updateNodeData(out.id, { params: { outputUrl: runState.outputUrl } }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- react to run completion; nodes/updateNodeData read at that point }, [runState.status, runState.outputUrl]) const preflightIssues = useMemo(() => { @@ -606,6 +607,7 @@ export default function WorkflowPanel() { [modelExtensions, processExtensions], ) + // eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount useEffect(() => { load(); loadExtensions() }, []) // Sync when navigated here from the workflow editor (activeId set externally) @@ -615,6 +617,7 @@ export default function WorkflowPanel() { useEffect(() => { if (!selectedId && workflows.length > 0) setSelectedId(workflows[0].id) + // eslint-disable-next-line react-hooks/exhaustive-deps -- default selection reacts to workflows list only }, [workflows]) const workflow = workflows.find((w) => w.id === selectedId) ?? null diff --git a/src/areas/models/ModelsPage.tsx b/src/areas/models/ModelsPage.tsx index 8460ede..b2ff664 100644 --- a/src/areas/models/ModelsPage.tsx +++ b/src/areas/models/ModelsPage.tsx @@ -42,7 +42,10 @@ export default function ModelsPage(): JSX.Element { const reloadExtensions = useExtensionsStore((s) => s.reload) const clearInstall = useExtensionsStore((s) => s.clearInstallState) - const allExtensions: AnyExtension[] = [...modelExtensions, ...processExtensions] + const allExtensions: AnyExtension[] = useMemo( + () => [...modelExtensions, ...processExtensions], + [modelExtensions, processExtensions], + ) // Model weight state (needed for node install status + uninstall cleanup) const [installedVariantIds, setInstalledVariantIds] = useState([]) @@ -134,6 +137,7 @@ export default function ModelsPage(): JSX.Element { } }) return () => window.electron.model.offProgress() + // eslint-disable-next-line react-hooks/exhaustive-deps -- register the progress listener once on mount }, []) useEffect(() => { diff --git a/src/areas/models/utils.test.mjs b/src/areas/models/utils.test.mjs new file mode 100644 index 0000000..e35b0f2 --- /dev/null +++ b/src/areas/models/utils.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +function loadModule() { + const outfile = join(mkdtempSync(join(tmpdir(), 'modly-modelutils-test-')), 'utils.cjs') + const require = createRequire(import.meta.url) + const result = buildSync({ + entryPoints: [resolve('src/areas/models/utils.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + }) + writeFileSync(outfile, result.outputFiles[0].text, 'utf8') + return require(outfile) +} + +const { formatModelName } = loadModule() + +test('formatModelName turns a hyphenated id into a Title-cased label', () => { + assert.equal(formatModelName('trellis'), 'Trellis') + assert.equal(formatModelName('stable-fast-3d'), 'Stable Fast 3d') + assert.equal(formatModelName('hunyuan-3d-2'), 'Hunyuan 3d 2') +}) + +test('formatModelName only capitalizes word-initial chars (digits left as-is)', () => { + // \b\w upper-cases the first char of each space-separated word; the "d" after + // a digit is mid-word and stays lower-case. + assert.equal(formatModelName('a-b-c'), 'A B C') + assert.equal(formatModelName(''), '') +}) diff --git a/src/areas/setup/FirstRunSetup.tsx b/src/areas/setup/FirstRunSetup.tsx index 2f959d8..034a131 100644 --- a/src/areas/setup/FirstRunSetup.tsx +++ b/src/areas/setup/FirstRunSetup.tsx @@ -63,6 +63,7 @@ function ChoosePathPanel({ // Sync if defaultPath arrives after mount (async IPC) useEffect(() => { if (defaultPath && !selectedPath) setSelectedPath(defaultPath) + // eslint-disable-next-line react-hooks/exhaustive-deps -- only sync when defaultPath arrives, not on user edits }, [defaultPath]) async function handleBrowse() { diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index 2f5c849..cbda971 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -16,10 +16,9 @@ import { } from '@xyflow/react' import { useWorkflowsStore } from '@shared/stores/workflowsStore' import { useExtensionsStore } from '@shared/stores/extensionsStore' -import { useNavStore } from '@shared/stores/navStore' import { useAppStore } from '@shared/stores/appStore' import type { Workflow, WFNode, WFEdge, WFNodeData } from '@shared/types/electron.d' -import { buildAllWorkflowExtensions, getWorkflowExtension } from './mockExtensions' +import { buildAllWorkflowExtensions } from './mockExtensions' import type { WorkflowExtension } from './mockExtensions' import { useWorkflowRunStore } from './workflowRunStore' import { validateWorkflowPreflight } from './preflight' @@ -66,87 +65,6 @@ function newWorkflow(): Workflow { return { id: newId(), name: 'New Workflow', description: '', nodes: [], edges: [], createdAt: now, updatedAt: now } } -function newWorkflowFromTemplate(): Workflow { - const now = new Date().toISOString() - const imageNodeId = newId() - const outputNodeId = newId() - return { - id: newId(), - name: 'New Workflow', - description: '', - nodes: [ - { id: imageNodeId, type: 'imageNode', position: { x: 150, y: 180 }, data: { enabled: true, params: {}, showInGenerate: true } }, - { id: outputNodeId, type: 'outputNode', position: { x: 500, y: 180 }, data: { enabled: true, params: {} } }, - ], - edges: [ - { id: newId(), source: imageNodeId, target: outputNodeId, type: 'workflowEdge' }, - ], - createdAt: now, - updatedAt: now, - } -} - -// ─── New workflow modal ─────────────────────────────────────────────────────── - -function NewWorkflowModal({ onBlank, onTemplate, onClose }: { - onBlank: () => void - onTemplate: () => void - onClose: () => void -}) { - return ( -
-
e.stopPropagation()} - > -
-

New Workflow

-

Choose how to start

-
- -
- {/* Blank */} - - - {/* Starter template */} - -
-
-
- ) -} - // ─── Extensions panel ──────────────────────────────────────────────────────── const PANEL_MIN = 240 @@ -195,7 +113,7 @@ function ExtensionsPanel({ allExtensions, open }: { allExtensions: WorkflowExten const onMove = (e: MouseEvent) => { if (!dragging.current) return const delta = startX.current - e.clientX - setWidth((w) => Math.min(PANEL_MAX, Math.max(PANEL_MIN, startW.current + delta))) + setWidth(() => Math.min(PANEL_MAX, Math.max(PANEL_MIN, startW.current + delta))) } const onUp = () => { dragging.current = false; document.body.style.cursor = '' } document.addEventListener('mousemove', onMove) @@ -479,6 +397,7 @@ function NodePalette({ } return { groups, totalItems: flatIdx } + // eslint-disable-next-line react-hooks/exhaustive-deps -- isExpanded only reads `collapsed`, already a dep }, [q, allExtensions, nonBuiltinMap, collapsed]) useEffect(() => { setActiveIndex(0) }, [query]) @@ -781,7 +700,7 @@ function WorkflowCanvasInner({ onNew: () => void onImport: () => void }) { - const { screenToFlowPosition, updateNodeData, getNode } = useReactFlow() + const { screenToFlowPosition, getNode } = useReactFlow() const { runState, run: runWorkflow, cancel } = useWorkflowRunStore() const currentMeshUrl = useAppStore((s) => s.currentJob?.outputUrl) const showToast = useAppStore((s) => s.showToast) @@ -818,6 +737,7 @@ function WorkflowCanvasInner({ histIdxRef.current = 0 setHistIdx(0) skipPushRef.current = true + // eslint-disable-next-line react-hooks/exhaustive-deps -- re-sync only when the workflow switches; adding name/nodes/edges would reset the editor on every keystroke }, [workflow.id]) // Auto-save + history push debounced @@ -845,6 +765,7 @@ function WorkflowCanvasInner({ skipPushRef.current = false }, 500) return () => { if (saveTimer.current) clearTimeout(saveTimer.current) } + // eslint-disable-next-line react-hooks/exhaustive-deps -- debounce on editable state; latest workflow/onSave read in the timeout }, [nodes, edges, name]) const preflightIssues = useMemo(() => { @@ -902,7 +823,7 @@ function WorkflowCanvasInner({ const canUndo = histIdx > 0 const canRedo = histIdx < historyRef.current.length - 1 - const isValidConnection = useCallback((connection: Connection) => { + const isValidConnection = useCallback((connection: Edge | Connection) => { const srcType = getNodeOutputType(getNode(connection.source) as Node, allExtensions) const tgtType = getNodeInputType(getNode(connection.target) as Node, connection.targetHandle, allExtensions) if (srcType && tgtType && srcType !== tgtType) return false // type mismatch (unknown types allowed) @@ -922,7 +843,7 @@ function WorkflowCanvasInner({ return true }, [getNode, allExtensions, edges]) - const onConnectStart = useCallback((_: React.MouseEvent | React.TouchEvent, params: OnConnectStartParams) => { + const onConnectStart = useCallback((_: MouseEvent | TouchEvent, params: OnConnectStartParams) => { pendingConnectionRef.current = params connectionCompletedRef.current = false }, []) @@ -1252,6 +1173,7 @@ export default function WorkflowsPage(): JSX.Element { [modelExtensions, processExtensions], ) + // eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount useEffect(() => { load(); loadExtensions() }, []) // Auto-select first workflow when none is active or the active id no longer exists @@ -1260,6 +1182,7 @@ export default function WorkflowsPage(): JSX.Element { if (workflows.length === 0) return if (activeId && workflows.find((w) => w.id === activeId)) return setActive(workflows[0].id) + // eslint-disable-next-line react-hooks/exhaustive-deps -- setActive is a stable store setter }, [workflows, loading, activeId]) const activeWorkflow = workflows.find((w) => w.id === activeId) ?? null diff --git a/src/areas/workflows/nodeBehaviors.test.mjs b/src/areas/workflows/nodeBehaviors.test.mjs new file mode 100644 index 0000000..2685ba4 --- /dev/null +++ b/src/areas/workflows/nodeBehaviors.test.mjs @@ -0,0 +1,109 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +// nodeBehaviors.ts only type-imports from electron.d, so esbuild erases it. +function loadModule() { + const outfile = join(mkdtempSync(join(tmpdir(), 'modly-nodebehaviors-test-')), 'nodeBehaviors.cjs') + const require = createRequire(import.meta.url) + const result = buildSync({ + entryPoints: [resolve('src/areas/workflows/nodeBehaviors.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + }) + writeFileSync(outfile, result.outputFiles[0].text, 'utf8') + return require(outfile) +} + +const { + isPassthrough, isBranchStarter, isSceneOutput, isBranchConsumer, + resolveDataSource, nearestUpstreamWaits, reachesSceneOutput, +} = loadModule() + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const node = (id, type) => ({ id, type, position: { x: 0, y: 0 }, data: {} }) +const edge = (source, target) => ({ id: `${source}->${target}`, source, target }) +const mapOf = (...nodes) => new Map(nodes.map((n) => [n.id, n])) + +// ─── Behavior predicates ─────────────────────────────────────────────────────── + +test('predicates read the behavior table and tolerate unknown/undefined types', () => { + assert.equal(isPassthrough('waitNode'), true) + assert.equal(isBranchStarter('waitNode'), true) + assert.equal(isSceneOutput('outputNode'), true) + assert.equal(isBranchConsumer('outputNode'), true) + assert.equal(isBranchConsumer('extensionNode'), true) + + assert.equal(isPassthrough('extensionNode'), false) + assert.equal(isSceneOutput('waitNode'), false) + assert.equal(isPassthrough(undefined), false) + assert.equal(isBranchStarter('ghostNode'), false) +}) + +// ─── resolveDataSource ───────────────────────────────────────────────────────── + +test('resolveDataSource walks back through passthrough nodes to the real source', () => { + const nodes = mapOf(node('img', 'imageNode'), node('wait', 'waitNode'), node('proc', 'extensionNode')) + const edges = [edge('img', 'wait'), edge('wait', 'proc')] + + assert.equal(resolveDataSource('wait', edges, nodes), 'img') // hop over the Wait + assert.equal(resolveDataSource('img', edges, nodes), 'img') // non-passthrough returns itself +}) + +test('resolveDataSource returns undefined when a passthrough has no incoming edge', () => { + const nodes = mapOf(node('wait', 'waitNode')) + assert.equal(resolveDataSource('wait', [], nodes), undefined) +}) + +test('resolveDataSource terminates on a passthrough cycle (no infinite loop)', () => { + const nodes = mapOf(node('w1', 'waitNode'), node('w2', 'waitNode')) + const edges = [edge('w2', 'w1'), edge('w1', 'w2')] + assert.equal(resolveDataSource('w1', edges, nodes), 'w1') +}) + +// ─── nearestUpstreamWaits ────────────────────────────────────────────────────── + +test('nearestUpstreamWaits finds the first Wait on each incoming path', () => { + const nodes = mapOf(node('img', 'imageNode'), node('wait', 'waitNode'), node('proc', 'extensionNode'), node('out', 'outputNode')) + const edges = [edge('img', 'wait'), edge('wait', 'proc'), edge('proc', 'out')] + assert.deepEqual([...nearestUpstreamWaits('out', edges, nodes)], ['wait']) +}) + +test('nearestUpstreamWaits reports >1 when a node merges two branches', () => { + const nodes = mapOf(node('wa', 'waitNode'), node('wb', 'waitNode'), node('merge', 'outputNode')) + const edges = [edge('wa', 'merge'), edge('wb', 'merge')] + assert.equal(nearestUpstreamWaits('merge', edges, nodes).size, 2) +}) + +test('nearestUpstreamWaits is empty with no upstream Wait', () => { + const nodes = mapOf(node('img', 'imageNode'), node('proc', 'extensionNode')) + const edges = [edge('img', 'proc')] + assert.equal(nearestUpstreamWaits('proc', edges, nodes).size, 0) +}) + +// ─── reachesSceneOutput ──────────────────────────────────────────────────────── + +test('reachesSceneOutput follows forward paths to a scene output', () => { + const nodes = mapOf(node('proc', 'extensionNode'), node('out', 'outputNode')) + const edges = [edge('proc', 'out')] + assert.equal(reachesSceneOutput('proc', edges, nodes), true) +}) + +test('reachesSceneOutput stops at a Wait boundary (output gated behind a branch)', () => { + const nodes = mapOf(node('proc', 'extensionNode'), node('wait', 'waitNode'), node('out', 'outputNode')) + const edges = [edge('proc', 'wait'), edge('wait', 'out')] + assert.equal(reachesSceneOutput('proc', edges, nodes), false) +}) + +test('reachesSceneOutput is false when no path reaches an output', () => { + const nodes = mapOf(node('proc', 'extensionNode'), node('img', 'imageNode')) + const edges = [edge('proc', 'img')] + assert.equal(reachesSceneOutput('proc', edges, nodes), false) +}) diff --git a/src/areas/workflows/nodes/AddToSceneNode.tsx b/src/areas/workflows/nodes/AddToSceneNode.tsx index 9150595..f28cff2 100644 --- a/src/areas/workflows/nodes/AddToSceneNode.tsx +++ b/src/areas/workflows/nodes/AddToSceneNode.tsx @@ -1,5 +1,5 @@ import { useCallback } from 'react' -import { Handle, Position, useReactFlow } from '@xyflow/react' +import { Handle, Position } from '@xyflow/react' import { useAppStore } from '@shared/stores/appStore' import { useNavStore } from '@shared/stores/navStore' import type { WFNodeData } from '@shared/types/electron.d' diff --git a/src/areas/workflows/nodes/mesh-exporter/processor.ts b/src/areas/workflows/nodes/mesh-exporter/processor.ts index 604803e..4c18856 100644 --- a/src/areas/workflows/nodes/mesh-exporter/processor.ts +++ b/src/areas/workflows/nodes/mesh-exporter/processor.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ import path = require('path') import fs = require('fs') diff --git a/src/areas/workflows/nodes/mesh-optimizer/processor.ts b/src/areas/workflows/nodes/mesh-optimizer/processor.ts index 0dddc01..f6f16d4 100644 --- a/src/areas/workflows/nodes/mesh-optimizer/processor.ts +++ b/src/areas/workflows/nodes/mesh-optimizer/processor.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ import path = require('path') interface ProcessInput { filePath?: string; text?: string } diff --git a/src/areas/workflows/preflight.test.mjs b/src/areas/workflows/preflight.test.mjs new file mode 100644 index 0000000..75b09d7 --- /dev/null +++ b/src/areas/workflows/preflight.test.mjs @@ -0,0 +1,133 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +// Bundle preflight.ts (and its real dependency mockExtensions.ts) into CJS. +// All @shared/* imports in those files are type-only, so esbuild erases them +// and no path-alias resolution is required. +function loadModule() { + const outfile = join(mkdtempSync(join(tmpdir(), 'modly-preflight-test-')), 'preflight.cjs') + const require = createRequire(import.meta.url) + const result = buildSync({ + entryPoints: [resolve('src/areas/workflows/preflight.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + }) + writeFileSync(outfile, result.outputFiles[0].text, 'utf8') + return require(outfile) +} + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +function ext(overrides = {}) { + return { + id: 'pack/process-node', + extensionId: 'pack', + extensionName: 'Pack', + extensionAuthor: 'tester', + nodeId: 'process-node', + name: 'Process Node', + description: '', + input: 'image', + output: 'mesh', + params: [], + builtin: false, + type: 'process', + ...overrides, + } +} + +function wf(nodes, edges = []) { + return { id: 'wf', name: 'wf', description: '', nodes, edges, createdAt: '', updatedAt: '' } +} + +const imageNode = (id = 'img') => ({ id, type: 'imageNode', position: { x: 0, y: 0 }, data: {} }) +const textNode = (id = 'txt') => ({ id, type: 'textNode', position: { x: 0, y: 0 }, data: {} }) + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +test('valid graph (image → extension expecting image) produces no issues', () => { + const { validateWorkflowPreflight } = loadModule() + const extensions = [ext()] + const workflow = wf( + [imageNode('img'), { id: 'proc', type: 'extensionNode', position: { x: 0, y: 0 }, data: { extensionId: 'pack/process-node' } }], + [{ id: 'e1', source: 'img', target: 'proc' }], + ) + + assert.deepEqual(validateWorkflowPreflight(workflow, extensions), []) +}) + +test('extension node with no matching incoming connection is flagged', () => { + const { validateWorkflowPreflight } = loadModule() + const extensions = [ext()] + const workflow = wf( + [{ id: 'proc', type: 'extensionNode', position: { x: 0, y: 0 }, data: { extensionId: 'pack/process-node' } }], + [], + ) + + const issues = validateWorkflowPreflight(workflow, extensions) + assert.equal(issues.length, 1) + assert.equal(issues[0].key, 'proc:missing:image') + assert.match(issues[0].message, /needs an incoming image connection/) +}) + +test('type mismatch on an incoming edge is flagged', () => { + const { validateWorkflowPreflight } = loadModule() + const extensions = [ext()] // expects image + const workflow = wf( + [textNode('txt'), { id: 'proc', type: 'extensionNode', position: { x: 0, y: 0 }, data: { extensionId: 'pack/process-node' } }], + [{ id: 'e1', source: 'txt', target: 'proc' }], + ) + + const issues = validateWorkflowPreflight(workflow, extensions) + // missing required image input AND a text→image type mismatch + assert.ok(issues.some((i) => i.key === 'proc:missing:image')) + assert.ok(issues.some((i) => i.key === 'proc:type:e1' && /outputs text/.test(i.message))) +}) + +test('unknown extension id is reported as unavailable', () => { + const { validateWorkflowPreflight } = loadModule() + const workflow = wf( + [{ id: 'proc', type: 'extensionNode', position: { x: 0, y: 0 }, data: { extensionId: 'ghost/node' } }], + [], + ) + + const issues = validateWorkflowPreflight(workflow, []) + assert.equal(issues.length, 1) + assert.equal(issues[0].key, 'proc:missing-extension') + assert.match(issues[0].message, /unavailable/) +}) + +test('meshNode set to current scene flags when no mesh is loaded', () => { + const { validateWorkflowPreflight } = loadModule() + const workflow = wf([ + { id: 'mesh', type: 'meshNode', position: { x: 0, y: 0 }, data: { params: { source: 'current' } } }, + ]) + + const without = validateWorkflowPreflight(workflow, [], { currentMeshUrl: null }) + assert.equal(without.length, 1) + assert.equal(without[0].key, 'mesh:current-mesh') + + const withMesh = validateWorkflowPreflight(workflow, [], { currentMeshUrl: '/tmp/mesh.glb' }) + assert.deepEqual(withMesh, []) +}) + +test('multi-input extension requires every declared input type', () => { + const { validateWorkflowPreflight } = loadModule() + const extensions = [ext({ inputs: ['image', 'text'] })] + const workflow = wf( + [imageNode('img'), { id: 'proc', type: 'extensionNode', position: { x: 0, y: 0 }, data: { extensionId: 'pack/process-node' } }], + [{ id: 'e1', source: 'img', target: 'proc' }], + ) + + const issues = validateWorkflowPreflight(workflow, extensions) + // image satisfied, text still missing + assert.ok(!issues.some((i) => i.key === 'proc:missing:image')) + assert.ok(issues.some((i) => i.key === 'proc:missing:text')) +}) diff --git a/src/shared/components/ui/Toast.tsx b/src/shared/components/ui/Toast.tsx index 7ad2e70..5a40f5b 100644 --- a/src/shared/components/ui/Toast.tsx +++ b/src/shared/components/ui/Toast.tsx @@ -8,6 +8,7 @@ export function Toast(): JSX.Element | null { if (!toast) return const timer = window.setTimeout(() => hideToast(), toast.durationMs ?? 2800) return () => window.clearTimeout(timer) + // eslint-disable-next-line react-hooks/exhaustive-deps -- key off id/duration, not the toast object identity }, [toast?.id, toast?.durationMs, hideToast]) if (!toast) return null diff --git a/src/shared/hooks/useApi.test.mjs b/src/shared/hooks/useApi.test.mjs new file mode 100644 index 0000000..45afc06 --- /dev/null +++ b/src/shared/hooks/useApi.test.mjs @@ -0,0 +1,134 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +// Bundle useApi.ts with stubbed dependencies: +// - axios → records requests and returns canned responses +// - appStore → useAppStore returns a fixed apiUrl (no React runtime) +// The stub modules communicate with the test through globalThis. +function loadUseApi() { + const dir = mkdtempSync(join(tmpdir(), 'modly-useapi-test-')) + + const axiosStub = join(dir, 'axios-stub.mjs') + writeFileSync(axiosStub, ` + function record(base, method, url, body) { + const full = (base ?? '') + url + globalThis.__calls.push({ method, url: full, body }) + const r = globalThis.__responses[full] + return Promise.resolve({ data: r ?? {} }) + } + export default { + create: (cfg) => ({ + get: (url) => record(cfg?.baseURL, 'get', url), + post: (url, body) => record(cfg?.baseURL, 'post', url, body), + }), + } + `, 'utf8') + + const storeStub = join(dir, 'store-stub.mjs') + writeFileSync(storeStub, ` + export const useAppStore = (sel) => sel({ apiUrl: 'http://test.local' }) + export const GenerationOptions = {} + `, 'utf8') + + const outfile = join(dir, 'useApi.cjs') + const require = createRequire(import.meta.url) + const result = buildSync({ + entryPoints: [resolve('src/shared/hooks/useApi.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + alias: { + axios: axiosStub, + '@shared/stores/appStore': storeStub, + }, + }) + writeFileSync(outfile, result.outputFiles[0].text, 'utf8') + return require(outfile).useApi +} + +function reset() { + globalThis.__calls = [] + globalThis.__responses = {} +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +test('exposes every method consumed by the app (regression: getAllModelsStatus)', () => { + reset() + const api = loadUseApi()() + for (const name of [ + 'generateFromImage', 'pollJobStatus', 'cancelJob', 'getModelStatus', + 'getAllModelsStatus', 'downloadModel', 'optimizeMesh', 'smoothMesh', 'importMesh', + ]) { + assert.equal(typeof api[name], 'function', `useApi() must expose ${name}`) + } +}) + +test('getAllModelsStatus hits /model/all and returns the payload', async () => { + reset() + globalThis.__responses['http://test.local/model/all'] = [{ id: 'm1', name: 'M1', downloaded: true }] + const api = loadUseApi()() + + const result = await api.getAllModelsStatus() + + assert.deepEqual(globalThis.__calls, [{ method: 'get', url: 'http://test.local/model/all', body: undefined }]) + assert.deepEqual(result, [{ id: 'm1', name: 'M1', downloaded: true }]) +}) + +test('pollJobStatus maps output_url → outputUrl', async () => { + reset() + globalThis.__responses['http://test.local/generate/status/job1'] = { + status: 'done', progress: 100, output_url: '/workspace/out.glb', + } + const api = loadUseApi()() + + const result = await api.pollJobStatus('job1') + + assert.equal(result.status, 'done') + assert.equal(result.outputUrl, '/workspace/out.glb') +}) + +test('optimizeMesh maps face_count → faceCount and posts target_faces', async () => { + reset() + globalThis.__responses['http://test.local/optimize/mesh'] = { url: '/o.glb', face_count: 5000 } + const api = loadUseApi()() + + const result = await api.optimizeMesh('/in.glb', 5000) + + const call = globalThis.__calls[0] + assert.equal(call.url, 'http://test.local/optimize/mesh') + assert.deepEqual(call.body, { path: '/in.glb', target_faces: 5000 }) + assert.deepEqual(result, { url: '/o.glb', faceCount: 5000 }) +}) + +test('cancelJob posts to the cancel endpoint and swallows errors', async () => { + reset() + const api = loadUseApi()() + await api.cancelJob('job9') + assert.equal(globalThis.__calls[0].url, 'http://test.local/generate/cancel/job9') +}) + +test('generateFromImage posts multipart and maps job_id → jobId', async () => { + reset() + globalThis.__responses['http://test.local/generate/from-image'] = { job_id: 'job42' } + // generateFromImage builds a Blob/FormData from base64 image data. + const api = loadUseApi()() + + const options = { + modelId: 'model-x', remesh: 'none', enableTexture: false, + textureResolution: 1024, modelParams: {}, + } + const result = await api.generateFromImage('/img.png', options, btoa('fake-png-bytes')) + + const call = globalThis.__calls[0] + assert.equal(call.url, 'http://test.local/generate/from-image') + assert.ok(call.body instanceof FormData) + assert.equal(call.body.get('model_id'), 'model-x') + assert.equal(result.jobId, 'job42') +}) diff --git a/src/shared/hooks/useApi.ts b/src/shared/hooks/useApi.ts index c98ac65..f013f1c 100644 --- a/src/shared/hooks/useApi.ts +++ b/src/shared/hooks/useApi.ts @@ -34,7 +34,7 @@ export function useApi() { } async function pollJobStatus(jobId: string): Promise<{ - status: 'pending' | 'running' | 'done' | 'error' + status: 'pending' | 'running' | 'done' | 'error' | 'cancelled' progress: number step?: string outputUrl?: string @@ -129,5 +129,5 @@ export function useApi() { return { url: data.url } } - return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, downloadModel, optimizeMesh, smoothMesh, importMesh, transformMesh } -} \ No newline at end of file + return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, getAllModelsStatus, downloadModel, optimizeMesh, smoothMesh, importMesh, transformMesh } +} diff --git a/src/shared/hooks/useGeneration.ts b/src/shared/hooks/useGeneration.ts index 8031789..c58a869 100644 --- a/src/shared/hooks/useGeneration.ts +++ b/src/shared/hooks/useGeneration.ts @@ -54,6 +54,7 @@ export function useGeneration() { }) } }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- useApi re-creates its fns each render, so this re-memoizes anyway (values stay fresh) [generateFromImage, pollJobStatus, cancelJob, setCurrentJob, updateCurrentJob] ) diff --git a/src/shared/stores/workflowsStore.test.mjs b/src/shared/stores/workflowsStore.test.mjs new file mode 100644 index 0000000..964e5d5 --- /dev/null +++ b/src/shared/stores/workflowsStore.test.mjs @@ -0,0 +1,148 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +// Bundle the store with its real zustand dependency; the only other import is a +// type-only one (electron.d) which esbuild erases. The store reads the global +// `window.electron.workflows.*` bridge, which we stub per test. +function loadStore() { + const outfile = join(mkdtempSync(join(tmpdir(), 'modly-wfstore-test-')), 'workflowsStore.cjs') + const require = createRequire(import.meta.url) + const result = buildSync({ + entryPoints: [resolve('src/shared/stores/workflowsStore.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + }) + writeFileSync(outfile, result.outputFiles[0].text, 'utf8') + return require(outfile).useWorkflowsStore +} + +function stubBridge(workflows = {}) { + globalThis.window = { electron: { workflows } } +} + +const migrated = (id, over = {}) => ({ + id, name: id.toUpperCase(), description: '', nodes: [], edges: [], + createdAt: '', updatedAt: '', ...over, +}) + +const legacyBlocks = (id) => ({ + id, name: id.toUpperCase(), description: '', input: 'image', + blocks: [{ id: `${id}-blk`, extension: 'pack/x', enabled: true, params: {} }], + createdAt: '', updatedAt: '', +}) + +// ─── load(): dedupe + migrate ────────────────────────────────────────────────── + +test('load dedupes workflows that share an id, keeping the first', async () => { + const useStore = loadStore() + stubBridge({ + list: async () => [migrated('a', { name: 'FIRST' }), migrated('a', { name: 'SECOND' }), migrated('b')], + }) + + await useStore.getState().load() + const { workflows, loading } = useStore.getState() + + assert.equal(loading, false) + assert.deepEqual(workflows.map((w) => w.id), ['a', 'b']) + assert.equal(workflows[0].name, 'FIRST') // the duplicate is dropped, first wins +}) + +test('load migrates legacy block-format workflows into nodes + edges', async () => { + const useStore = loadStore() + stubBridge({ list: async () => [legacyBlocks('w')] }) + + await useStore.getState().load() + const wf = useStore.getState().workflows[0] + + assert.deepEqual(wf.nodes.map((n) => n.type), ['inputNode', 'extensionNode']) + assert.equal(wf.nodes[0].id, 'input-w') + assert.equal(wf.nodes[1].id, 'w-blk') + assert.equal(wf.edges.length, 1) + assert.deepEqual({ source: wf.edges[0].source, target: wf.edges[0].target }, { source: 'input-w', target: 'w-blk' }) +}) + +test('load flips loading back to false when the bridge throws', async () => { + const useStore = loadStore() + stubBridge({ list: async () => { throw new Error('disk error') } }) + + await useStore.getState().load() + assert.equal(useStore.getState().loading, false) +}) + +// ─── save(): insert vs update ────────────────────────────────────────────────── + +test('save inserts a new workflow at the front, updates an existing one in place', async () => { + const useStore = loadStore() + stubBridge({ save: async () => ({ success: true }) }) + useStore.setState({ workflows: [migrated('a'), migrated('b')] }) + + await useStore.getState().save(migrated('c')) + assert.deepEqual(useStore.getState().workflows.map((w) => w.id), ['c', 'a', 'b']) + + await useStore.getState().save(migrated('b', { name: 'B2' })) + const wf = useStore.getState().workflows.find((w) => w.id === 'b') + assert.equal(wf.name, 'B2') + assert.equal(useStore.getState().workflows.length, 3) // no duplicate +}) + +test('save does not touch state when the bridge reports failure', async () => { + const useStore = loadStore() + stubBridge({ save: async () => ({ success: false, error: 'nope' }) }) + useStore.setState({ workflows: [migrated('a')] }) + + const result = await useStore.getState().save(migrated('z')) + assert.equal(result.success, false) + assert.deepEqual(useStore.getState().workflows.map((w) => w.id), ['a']) +}) + +// ─── remove(): filter + active reset ──────────────────────────────────────────── + +test('remove drops the workflow and clears activeId when it was the active one', async () => { + const useStore = loadStore() + stubBridge({ delete: async () => ({ success: true }) }) + useStore.setState({ workflows: [migrated('a'), migrated('b')], activeId: 'a' }) + + await useStore.getState().remove('a') + assert.deepEqual(useStore.getState().workflows.map((w) => w.id), ['b']) + assert.equal(useStore.getState().activeId, null) +}) + +test('remove keeps activeId when a different workflow is deleted', async () => { + const useStore = loadStore() + stubBridge({ delete: async () => ({ success: true }) }) + useStore.setState({ workflows: [migrated('a'), migrated('b')], activeId: 'b' }) + + await useStore.getState().remove('a') + assert.equal(useStore.getState().activeId, 'b') +}) + +// ─── importFile() + setActive() ──────────────────────────────────────────────── + +test('importFile migrates, moves the workflow to the front, and makes it active', async () => { + const useStore = loadStore() + stubBridge({ import: async () => ({ success: true, workflow: legacyBlocks('imp') }) }) + useStore.setState({ workflows: [migrated('a')], activeId: 'a' }) + + await useStore.getState().importFile() + const { workflows, activeId } = useStore.getState() + + assert.deepEqual(workflows.map((w) => w.id), ['imp', 'a']) + assert.equal(activeId, 'imp') + assert.deepEqual(workflows[0].nodes.map((n) => n.type), ['inputNode', 'extensionNode']) +}) + +test('setActive updates only activeId', () => { + const useStore = loadStore() + stubBridge() + useStore.setState({ workflows: [migrated('a')], activeId: null }) + + useStore.getState().setActive('a') + assert.equal(useStore.getState().activeId, 'a') +}) diff --git a/src/shared/stores/workflowsStore.ts b/src/shared/stores/workflowsStore.ts index 35ea60c..02ee94f 100644 --- a/src/shared/stores/workflowsStore.ts +++ b/src/shared/stores/workflowsStore.ts @@ -9,7 +9,7 @@ interface WorkflowsStore { load: () => Promise save: (workflow: Workflow) => Promise<{ success: boolean; error?: string }> remove: (id: string) => Promise<{ success: boolean; error?: string }> - importFile: () => Promise<{ success: boolean; error?: string }> + importFile: () => Promise<{ success: boolean; error?: string; workflow?: Workflow }> exportFile: (workflow: Workflow) => Promise<{ success: boolean; error?: string }> setActive: (id: string | null) => void } @@ -89,7 +89,17 @@ export const useWorkflowsStore = create((set) => ({ set({ loading: true }) try { const raw = await window.electron.workflows.list() - const list = (raw as LegacyWorkflow[]).map(migrateWorkflow) + // Dedupe by id: two files on disk can share an internal id (e.g. a copied + // workflow), which would produce duplicate React keys. Keep the first + // (the list arrives sorted by updatedAt desc, so the most recent wins). + const seen = new Set() + const list: Workflow[] = [] + for (const entry of raw as LegacyWorkflow[]) { + const wf = migrateWorkflow(entry) + if (seen.has(wf.id)) continue + seen.add(wf.id) + list.push(wf) + } set({ workflows: list, loading: false }) } catch { set({ loading: false }) diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index 182ed32..6be6fdd 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -88,6 +88,8 @@ export interface WFNodeData { enabled: boolean showInGenerate?: boolean params: Record + // React Flow requires node data to satisfy Record. + [key: string]: unknown } export interface WFNode { @@ -165,6 +167,7 @@ declare global { model: { export: (args: { outputUrl: string; format: string }) => Promise<{ success: boolean; error?: string }> listDownloaded: () => Promise<{ id: string; name: string; size_gb: number }[]> + activeDownloads: () => Promise<{ modelId: string; percent: number; file?: string; fileIndex?: number; totalFiles?: number }[]> isDownloaded: (modelId: string, downloadCheck?: string) => Promise download: (repoId: string, modelId: string, skipPrefixes?: string[], includePrefixes?: string[]) => Promise<{ success: boolean; error?: string }> pauseDownload: (modelId: string) => Promise<{ success: boolean; error?: string }> diff --git a/src/shared/types/gaussian-splats-3d.d.ts b/src/shared/types/gaussian-splats-3d.d.ts new file mode 100644 index 0000000..292e935 --- /dev/null +++ b/src/shared/types/gaussian-splats-3d.d.ts @@ -0,0 +1,3 @@ +// The package ships a UMD bundle (gaussian-splats-3d.umd.cjs) without type +// declarations. Declare it as an untyped module so the renderer type-checks. +declare module '@mkkellogg/gaussian-splats-3d' diff --git a/src/shared/utils/format.test.mjs b/src/shared/utils/format.test.mjs new file mode 100644 index 0000000..7fa3fb8 --- /dev/null +++ b/src/shared/utils/format.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +// format.ts has no imports — bundle it straight to CJS and require. +function loadModule() { + const outfile = join(mkdtempSync(join(tmpdir(), 'modly-format-test-')), 'format.cjs') + const require = createRequire(import.meta.url) + const result = buildSync({ + entryPoints: [resolve('src/shared/utils/format.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + }) + writeFileSync(outfile, result.outputFiles[0].text, 'utf8') + return require(outfile) +} + +const { formatBytes, formatPoly, formatTime, formatDate } = loadModule() + +// ─── formatBytes ─────────────────────────────────────────────────────────────── + +test('formatBytes picks the GB/MB/KB tier and rounds', () => { + assert.equal(formatBytes(1.2e9), '1.2 GB') + assert.equal(formatBytes(1e9), '1.0 GB') // exact GB boundary + assert.equal(formatBytes(5e8), '500 MB') + assert.equal(formatBytes(1e6), '1 MB') // exact MB boundary + assert.equal(formatBytes(128e3), '128 KB') + assert.equal(formatBytes(0), '0 KB') +}) + +// ─── formatPoly ──────────────────────────────────────────────────────────────── + +test('formatPoly abbreviates millions/thousands and leaves small counts raw', () => { + assert.equal(formatPoly(1_500_000), '1.5M') + assert.equal(formatPoly(1_000_000), '1.0M') // exact M boundary + assert.equal(formatPoly(10_500), '10.5k') + assert.equal(formatPoly(1_000), '1.0k') // exact k boundary + assert.equal(formatPoly(999), '999') // just below k + assert.equal(formatPoly(0), '0') +}) + +// ─── formatTime / formatDate (locale/TZ-robust: assert shape, not exact text) ─── + +test('formatTime returns an hour:minute-ish string', () => { + assert.match(formatTime(Date.parse('2025-03-08T14:32:00')), /\d/) +}) + +test('formatDate labels today and yesterday, and shows the year for old dates', () => { + const now = Date.now() + assert.equal(formatDate(now), 'Today') + assert.equal(formatDate(now - 24 * 60 * 60 * 1000), 'Yesterday') + + const old = new Date() + old.setFullYear(old.getFullYear() - 2) + assert.match(formatDate(old.getTime()), new RegExp(String(old.getFullYear()))) +}) diff --git a/tsconfig.web.json b/tsconfig.web.json index c1be672..2b2dbc6 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -9,11 +9,10 @@ "strict": true, "skipLibCheck": true, "paths": { - "@/*": ["./src/*"], - "@components/*": ["./src/components/*"], - "@hooks/*": ["./src/hooks/*"], - "@stores/*": ["./src/stores/*"], - "@styles/*": ["./src/styles/*"] + "@/*": ["./src/*"], + "@areas/*": ["./src/areas/*"], + "@shared/*": ["./src/shared/*"], + "@styles/*": ["./src/styles/*"] } }, "include": ["src/**/*"],