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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
71 changes: 69 additions & 2 deletions api/tests/test_extension_process.py
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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()
90 changes: 90 additions & 0 deletions api/tests/test_runner.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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()
2 changes: 1 addition & 1 deletion electron/main/artifact-registry-service.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 0 additions & 1 deletion electron/main/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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 }]
}
}
)
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ."
Expand All @@ -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",
Expand All @@ -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": {
Expand Down
37 changes: 37 additions & 0 deletions scripts/run-pytests.mjs
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions scripts/setup-hooks.mjs
Original file line number Diff line number Diff line change
@@ -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')
}
Loading