diff --git a/docs/docs.json b/docs/docs.json index ed30fdccd..b72e559c8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -370,6 +370,7 @@ "group": "Release Notes", "pages": [ "releases/index", + "releases/v0.17.1", "releases/v0.17.0", "releases/v0.16.1", "releases/v0.16.0", @@ -414,7 +415,7 @@ "navbar": { "links": [ { - "label": "v0.17.0 \u00b7 Lemonade 10.0.0", + "label": "v0.17.1 \u00b7 Lemonade 10.0.0", "href": "https://github.com/amd/gaia/releases" }, { diff --git a/docs/guides/agent-ui.mdx b/docs/guides/agent-ui.mdx index 6464a14b1..58f0ae0c5 100644 --- a/docs/guides/agent-ui.mdx +++ b/docs/guides/agent-ui.mdx @@ -6,6 +6,10 @@ icon: "desktop" GAIA Agent UI is a desktop interface for running AI agents **100% locally** on your AMD hardware. Use agents to analyze documents, generate code, answer questions, and accomplish tasks on your PC — all without sending data to the cloud. + + This guide assumes you have completed the [Quickstart](/quickstart) and have GAIA installed. + + **Tested Configuration:** The Agent UI has been tested exclusively on **AMD Ryzen AI MAX+ 395** processors running the **Qwen3.5-35B-A3B-GGUF** model via Lemonade Server. Other hardware or model combinations may work but are not officially verified. @@ -99,6 +103,14 @@ Choose one of the two install paths: The Agent UI starts on [http://localhost:4200](http://localhost:4200). Open this URL in your browser. + + **Source/dev installs:** The frontend is built during `gaia init`. If you see a JSON response instead of the UI, run `gaia init` or manually build (from the repo root): + + ```bash + cd src/gaia/apps/webui && npm install && npm run build + ``` + + **Options:** | Flag | Description | @@ -166,17 +178,11 @@ See the [Agent UI MCP Server guide](/guides/mcp/agent-ui) for setup instructions - ```bash - lemonade-server serve - ``` - - If not installed, run `gaia init --profile minimal` or follow the [Setup Guide](/setup). + Start Lemonade with `lemonade-server serve`. If Lemonade is not installed, follow the initialization steps in the [Quickstart](/quickstart#cli-install). - ```bash - gaia download --agent chat - ``` + Download models with `gaia init --profile chat`. See the [Quickstart](/quickstart#cli-install) for details. @@ -198,6 +204,22 @@ See the [Agent UI MCP Server guide](/guides/mcp/agent-ui) for setup instructions - Keep file size under 100MB - For PDF image extraction, download the VLM model: `gaia download --agent chat` + + + The Agent UI frontend has not been built. + + **npm install (`gaia-ui`):** This is handled automatically — `gaia-ui` tells the Python server where to find the pre-built frontend. If you still see this error, try reinstalling: `npm install -g @amd-gaia/agent-ui@latest` Then restart with `gaia-ui`. + + **Source/dev installs (git clone):** Run `gaia init` to build it automatically, or manually (from the repo root): + + ```bash + cd src/gaia/apps/webui && npm install && npm run build + ``` + + Then restart `gaia chat --ui`. + + **pip/PyPI installs (without gaia-ui):** Use the [npm install path](#install-and-launch) — the pip package does not include frontend source files. + --- diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 62c35ab33..cf1729f95 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -4,6 +4,10 @@ description: "Run AI agents locally in minutes, or build your first custom agent icon: "rocket" --- + + This guide has two paths: **Agent UI** (npm, handles everything automatically) and **CLI / Manual Install** (requires [Setup](/setup) prerequisites first). Pick the one that fits your workflow. + + ## Agent UI (Fastest) Run AI agents **100% locally** on your AMD hardware — analyze documents, generate code, answer questions, and accomplish tasks on your PC without sending data to the cloud. diff --git a/docs/releases/v0.17.1.mdx b/docs/releases/v0.17.1.mdx new file mode 100644 index 000000000..c70051db7 --- /dev/null +++ b/docs/releases/v0.17.1.mdx @@ -0,0 +1,83 @@ +--- +title: "v0.17.1" +description: "Agent UI works out of the box — automatic frontend build on gaia init, no manual npm steps" +--- + +# GAIA v0.17.1 Release Notes + +Agent UI now works from the moment you finish `gaia init` — no manual frontend build steps, no hunting for the right `npm` command. This patch release makes the first-install experience reliable and cleans up how you point to a custom frontend dist directory. + +```bash +npm install -g @amd-gaia/agent-ui +gaia-ui +``` + +**Why upgrade:** +- **Agent UI works immediately after `gaia init`** — The frontend build now runs automatically during initialization on dev/source installs, so `gaia chat --ui` is ready to go without any extra steps +- **Cleaner configuration with `--ui-dist`** — A new CLI flag replaces the opaque `GAIA_WEBUI_DIST` environment variable, making it obvious how to point to a custom frontend dist directory +- **More reliable publishing pipeline** — The `@amd-gaia/agent-ui` npm package now publishes via OIDC trusted publishing, eliminating NPM token rotation issues that could delay releases + +Get started with the [Agent UI guide](/guides/agent-ui). + +--- + +## What's New + +### Agent UI First-Install Reliability + +Previously, after running `gaia init` on a dev or source install, launching `gaia chat --ui` would fail because the React frontend hadn't been built yet. Users had to manually run `cd src/gaia/apps/webui && npm install && npm run build` — a step easy to miss and not clearly documented (commit f89810a3). + +**What you can do:** +- Run `gaia init` once and immediately launch `gaia chat --ui` — the frontend is built automatically +- Skip the manual build step entirely on dev/source installs + +**Under the hood:** +- Extracted `_ensure_webui_built()` into a shared `gaia/ui/build.py` module used by both `gaia init` and `gaia chat --ui` +- `gaia chat --ui` retains its staleness check and auto-rebuild as a safety net +- Added unit tests for all new build code paths +- Fixed Agent UI docs to clarify the Setup → Quickstart → Agent UI prerequisite chain + +--- + +### `--ui-dist` Configuration Flag + +The `GAIA_WEBUI_DIST` environment variable for pointing to a custom frontend dist directory has been replaced with a `--ui-dist` CLI argument (commits f3af9b29, 1fa20969). This follows GAIA's CLI-first configuration pattern and makes the option discoverable via `--help`. + +**What you can do:** +- `gaia chat --ui --ui-dist /path/to/dist` — point to a custom frontend build +- `gaia-ui` npm launcher passes this automatically — no manual env var setup needed + +**Under the hood:** +- `--ui-dist` accepted by both `gaia chat --ui` and the UI server directly +- Code review advisory items A1–A5 addressed (commit b6114a42) + +--- + +## Infrastructure + +- **OIDC trusted publishing** — `@amd-gaia/agent-ui` npm package now publishes via OIDC, removing the NPM_TOKEN secret requirement and making the publishing pipeline more reliable (PRs #638, #639) +- **Merge queue fix** — Resolved phantom failures in the merge-queue-notify CI workflow (PR #640) + +--- + +## Upgrade + +```bash +npm install -g @amd-gaia/agent-ui@latest +``` + +--- + +## Full Changelog + +**7 commits** since v0.17.0: + +- `1fa20969` - fix: replace GAIA_WEBUI_DIST env var with --ui-dist CLI argument +- `b6114a42` - fix: address code review advisory items A1-A5 +- `f3af9b29` - fix: pass GAIA_WEBUI_DIST env var from gaia-ui to Python server +- `f89810a3` - fix: build Agent UI frontend during gaia init and fix doc prerequisites +- `334b011c` - fix: remove registry-url to enable OIDC trusted publishing (#639) +- `776dc34a` - fix: resolve merge-queue-notify phantom failures (#640) +- `83a4db1b` - fix: switch npm publish to OIDC trusted publishing (#638) + +Full Changelog: [v0.17.0...v0.17.1](https://github.com/amd/gaia/compare/v0.17.0...v0.17.1) diff --git a/src/gaia/apps/webui/bin/gaia-ui.mjs b/src/gaia/apps/webui/bin/gaia-ui.mjs index a881f0821..14db191b9 100755 --- a/src/gaia/apps/webui/bin/gaia-ui.mjs +++ b/src/gaia/apps/webui/bin/gaia-ui.mjs @@ -369,9 +369,8 @@ function openBrowser(url) { function startBackend(gaiaBin, port) { console.log(`Starting GAIA backend on port ${port}...`); - const child = spawn(gaiaBin, ["chat", "--ui", "--ui-port", String(port)], { + const child = spawn(gaiaBin, ["chat", "--ui", "--ui-port", String(port), "--ui-dist", join(ROOT_DIR, "dist")], { stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, detached: false, }); diff --git a/src/gaia/apps/webui/package.json b/src/gaia/apps/webui/package.json index 4bf054d8d..ea9499aa8 100644 --- a/src/gaia/apps/webui/package.json +++ b/src/gaia/apps/webui/package.json @@ -1,6 +1,6 @@ { "name": "@amd-gaia/agent-ui", - "version": "0.17.1-rc.1", + "version": "0.17.1", "type": "module", "productName": "GAIA Agent UI", "description": "Privacy-first agentic AI interface with document Q&A - runs 100% locally on AMD Ryzen AI", diff --git a/src/gaia/cli.py b/src/gaia/cli.py index bad0fd4c8..787b698da 100644 --- a/src/gaia/cli.py +++ b/src/gaia/cli.py @@ -685,99 +685,16 @@ def run_cli(action, **kwargs): def _ensure_webui_built(log=None): - """Rebuild the Agent UI frontend if source files are newer than dist. + """Rebuild the Agent UI frontend if source files are newer than dist.""" + from gaia.ui.build import ensure_webui_built - Only runs in dev mode (editable install) where the webui src/ directory - exists. Silently skips in installed-package mode or when node/npm are - not available. - """ - if log is None: - log = get_logger(__name__) - - webui_dir = Path(__file__).resolve().parent / "apps" / "webui" - src_dir = webui_dir / "src" - dist_index = webui_dir / "dist" / "index.html" - - # Gate 1 — dev mode only (src/ absent in pip-installed package) - if not src_dir.is_dir(): - return - - # Gate 2 — staleness check - newest_src = 0.0 - for pattern in ("*.ts", "*.tsx", "*.css", "*.html"): - for path in src_dir.rglob(pattern): - mtime = path.stat().st_mtime - if mtime > newest_src: - newest_src = mtime - for root_file in ("index.html", "vite.config.ts", "tsconfig.json"): - p = webui_dir / root_file - if p.exists(): - newest_src = max(newest_src, p.stat().st_mtime) - - if dist_index.exists() and newest_src <= dist_index.stat().st_mtime: - log.debug("Agent UI frontend is up to date") - return - - if dist_index.exists(): - log.info("Agent UI frontend source is newer than built output") - else: - log.info("Agent UI frontend has not been built yet") - - # Gate 3 — node/npm availability - if not shutil.which("node"): - print("Warning: Node.js not found. Cannot auto-rebuild Agent UI frontend.") - print(" The UI may be stale. Install Node.js from https://nodejs.org/") - return - if not shutil.which("npm"): - print("Warning: npm not found. Cannot auto-rebuild Agent UI frontend.") - return - - # On Windows, npm is a .cmd batch file requiring shell execution - _shell = sys.platform == "win32" - - # Step 1 — npm install (only if node_modules/ missing) - if not (webui_dir / "node_modules").is_dir(): - print("Installing Agent UI frontend dependencies...") - try: - subprocess.run( - ["npm", "install"], - cwd=str(webui_dir), - check=True, - capture_output=True, - text=True, - shell=_shell, - ) - except subprocess.CalledProcessError as e: - log.error("npm install failed: %s", e.stderr) - print(f"Warning: npm install failed: {e.stderr}") - print(" Continuing with existing dist/ (may be stale).") - return - except FileNotFoundError: - print("Warning: npm not found. Skipping frontend rebuild.") - return - - # Step 2 — npm run build (stream output so user sees progress) - print("Building Agent UI frontend...", flush=True) - try: - subprocess.run( - ["npm", "run", "build"], - cwd=str(webui_dir), - check=True, - shell=_shell, - ) - print("Agent UI frontend built successfully.") - except subprocess.CalledProcessError as e: - log.error("Frontend build failed (exit code %d)", e.returncode) - print(f"Warning: Frontend build failed (exit code {e.returncode}).") - if dist_index.exists(): - print(" Continuing with existing (possibly stale) build.") - else: - print(" No existing build found. The UI will show a build hint.") - except FileNotFoundError: - print("Warning: npm not found. Skipping frontend rebuild.") + ensure_webui_built( + log_fn=log.info if log else print, + warn_fn=log.warning if log else print, + ) -def _launch_agent_ui(port=4200, base_url=None, log=None, debug=False): +def _launch_agent_ui(port=4200, base_url=None, log=None, debug=False, webui_dist=None): """Launch the Agent UI server (FastAPI + uvicorn). Reused by top-level --ui, gaia chat --ui, and the interactive menu. @@ -811,7 +728,7 @@ def _launch_agent_ui(port=4200, base_url=None, log=None, debug=False): import uvicorn - app = create_app() + app = create_app(webui_dist=webui_dist) uvicorn.run( app, host="127.0.0.1", @@ -967,6 +884,11 @@ def main(): default=4200, help="Port for the Agent UI server (default: 4200, used with --ui)", ) + parser.add_argument( + "--ui-dist", + default=None, + help="Path to pre-built Agent UI frontend dist directory (used with --ui)", + ) parser.add_argument( "--cli", action="store_true", @@ -1139,6 +1061,11 @@ def main(): default=4200, help="Port for the Agent UI server (default: 4200)", ) + chat_parser.add_argument( + "--ui-dist", + default=None, + help="Path to pre-built Agent UI frontend dist directory (used with --ui)", + ) talk_parser = subparsers.add_parser( "talk", help="Start voice conversation with Gaia", parents=[parent_parser] ) @@ -2710,6 +2637,7 @@ def main(): base_url=getattr(args, "base_url", None), log=log, debug=getattr(args, "debug", False), + webui_dist=getattr(args, "ui_dist", None), ) return @@ -2724,6 +2652,7 @@ def main(): base_url=getattr(args, "base_url", None), log=log, debug=getattr(args, "debug", False), + webui_dist=getattr(args, "ui_dist", None), ) return @@ -2743,6 +2672,7 @@ def main(): base_url=getattr(args, "base_url", None), log=log, debug=getattr(args, "debug", False), + webui_dist=getattr(args, "ui_dist", None), ) return diff --git a/src/gaia/installer/init_command.py b/src/gaia/installer/init_command.py index 55e9088f6..db02d80e7 100644 --- a/src/gaia/installer/init_command.py +++ b/src/gaia/installer/init_command.py @@ -17,6 +17,7 @@ import subprocess import sys from dataclasses import dataclass +from pathlib import Path from typing import Callable, Optional # Rich imports for better CLI formatting @@ -441,9 +442,14 @@ def run(self) -> int: profile_config = INIT_PROFILES[self.profile] has_pip_extras = bool(profile_config.get("pip_extras")) + _webui_src = Path(__file__).resolve().parent.parent / "apps" / "webui" / "src" + _is_dev_install = _webui_src.is_dir() + total_steps = 4 if not self.skip_models else 3 if has_pip_extras: total_steps += 1 + if _is_dev_install: + total_steps += 1 try: # Step 1: Check/Install Lemonade (skip for remote servers or CI) @@ -502,6 +508,22 @@ def run(self) -> int: ) self._install_pip_extras() + # Build Agent UI frontend (dev/source installs only) + if _is_dev_install: + step_num += 1 + self._print("") + self._print_step(step_num, total_steps, "Building Agent UI frontend...") + try: + from gaia.ui.build import ensure_webui_built + + built = ensure_webui_built( + log_fn=self._print, warn_fn=self._print_warning + ) + if built: + self._print_success("Agent UI frontend ready") + except Exception as e: + self._print_warning(f"Frontend build skipped: {e}") + # Final step: Verify setup step_num += 1 self._print("") @@ -1516,8 +1538,6 @@ def _verify_setup(self) -> bool: # Show path for each failed model hf_cache = os.path.expanduser("~/.cache/huggingface/hub") - from pathlib import Path - for model_id, error in models_failed: # Find actual model directory (may have org prefix like ggml-org/model-name) # Search for directories containing the model name diff --git a/src/gaia/ui/build.py b/src/gaia/ui/build.py new file mode 100644 index 000000000..9a68d0855 --- /dev/null +++ b/src/gaia/ui/build.py @@ -0,0 +1,125 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +""" +Shared utility for building the Agent UI frontend. + +Extracted from cli.py so that init_command.py can call it without +creating a circular import through the full CLI module. +""" + +import shutil +import subprocess +import sys +from pathlib import Path + + +def ensure_webui_built(log_fn=print, warn_fn=None, _webui_dir=None): + """Rebuild the Agent UI frontend if source files are newer than dist. + + Only runs in dev mode (editable install) where the webui src/ directory + exists. Silently skips in installed-package mode or when node/npm are + not available. + + Args: + log_fn: Callable used for informational output. Defaults to ``print``. + Pass ``logger.info`` or ``self._print`` to integrate with your + own output mechanism. + warn_fn: Callable used for warning/error output. Defaults to + ``log_fn`` when not provided. Pass ``logger.warning`` or + ``self._print_warning`` to route warnings separately from + informational messages. + _webui_dir: Override the webui directory path (used in tests only). + + Returns: + True — build succeeded, or dist is already up-to-date. + False — build was skipped or failed (warn_fn already reported why). + """ + if warn_fn is None: + warn_fn = log_fn + + webui_dir = ( + _webui_dir + if _webui_dir is not None + else Path(__file__).resolve().parent.parent / "apps" / "webui" + ) + src_dir = webui_dir / "src" + dist_index = webui_dir / "dist" / "index.html" + + # Gate 1 — dev mode only (src/ absent in pip-installed package) + if not src_dir.is_dir(): + return False + + # Gate 2 — staleness check + newest_src = 0.0 + for pattern in ("*.ts", "*.tsx", "*.css", "*.html"): + for path in src_dir.rglob(pattern): + mtime = path.stat().st_mtime + if mtime > newest_src: + newest_src = mtime + for root_file in ("index.html", "vite.config.ts", "tsconfig.json"): + p = webui_dir / root_file + if p.exists(): + newest_src = max(newest_src, p.stat().st_mtime) + + if dist_index.exists() and newest_src <= dist_index.stat().st_mtime: + return True + + if dist_index.exists(): + log_fn("Agent UI frontend source is newer than built output") + else: + log_fn("Agent UI frontend has not been built yet") + + # Gate 3 — node/npm availability + if not shutil.which("node"): + warn_fn("Warning: Node.js not found. Cannot auto-rebuild Agent UI frontend.") + warn_fn(" The UI may be stale. Install Node.js from https://nodejs.org/") + return False + if not shutil.which("npm"): + warn_fn("Warning: npm not found. Cannot auto-rebuild Agent UI frontend.") + return False + + # On Windows, npm is a .cmd batch file requiring shell execution + _shell = sys.platform == "win32" + + # Step 1 — npm install (only if node_modules/ missing) + if not (webui_dir / "node_modules").is_dir(): + log_fn("Installing Agent UI frontend dependencies...") + try: + subprocess.run( + ["npm", "install"], + cwd=str(webui_dir), + check=True, + capture_output=True, + text=True, + shell=_shell, + ) + except subprocess.CalledProcessError as e: + warn_fn(f"Warning: npm install failed: {e.stderr}") + warn_fn(" Continuing with existing dist/ (may be stale).") + return False + except FileNotFoundError: + warn_fn("Warning: npm not found. Skipping frontend rebuild.") + return False + + # Step 2 — npm run build (stream output so user sees progress) + log_fn("Building Agent UI frontend...") + try: + subprocess.run( + ["npm", "run", "build"], + cwd=str(webui_dir), + check=True, + shell=_shell, + ) + log_fn("Agent UI frontend built successfully.") + return True + except subprocess.CalledProcessError as e: + warn_fn(f"Warning: Frontend build failed (exit code {e.returncode}).") + if dist_index.exists(): + warn_fn(" Continuing with existing (possibly stale) build.") + else: + warn_fn(" No existing build found. The UI will show a build hint.") + return False + except FileNotFoundError: + warn_fn("Warning: npm not found. Skipping frontend rebuild.") + return False diff --git a/src/gaia/ui/server.py b/src/gaia/ui/server.py index 6ecfd88c9..97076445e 100644 --- a/src/gaia/ui/server.py +++ b/src/gaia/ui/server.py @@ -127,11 +127,13 @@ async def dispatch(self, request: Request, call_next): # ── Application Factory ──────────────────────────────────────────────────── -def create_app(db_path: str = None) -> FastAPI: +def create_app(db_path: str = None, webui_dist: str = None) -> FastAPI: """Create and configure the FastAPI application. Args: db_path: Path to SQLite database. None for default, ":memory:" for testing. + webui_dist: Path to the pre-built frontend dist directory. When None, + falls back to the default location relative to this package. Returns: Configured FastAPI application. @@ -295,7 +297,8 @@ async def _global_exception_handler(request: Request, exc: Exception): # ── Serve Frontend Static Files ────────────────────────────────────── # Look for built frontend assets in the webui dist directory - _webui_dist = Path(__file__).resolve().parent.parent / "apps" / "webui" / "dist" + _default_dist = Path(__file__).resolve().parent.parent / "apps" / "webui" / "dist" + _webui_dist = Path(webui_dist) if webui_dist else _default_dist if _webui_dist.is_dir(): logger.info("Serving frontend from %s", _webui_dist) @@ -375,11 +378,16 @@ def main(): "--port", type=int, default=DEFAULT_PORT, help=f"Port (default: {DEFAULT_PORT})" ) parser.add_argument("--debug", action="store_true", help="Enable debug logging") + parser.add_argument( + "--ui-dist", + default=None, + help="Path to pre-built Agent UI frontend dist directory", + ) args = parser.parse_args() log_level = "debug" if args.debug else "info" print(f"Starting GAIA Agent UI server on http://{args.host}:{args.port}") - server_app = create_app() + server_app = create_app(webui_dist=args.ui_dist) uvicorn.run( server_app, host=args.host, diff --git a/src/gaia/version.py b/src/gaia/version.py index ae3291e33..e5e24ec15 100644 --- a/src/gaia/version.py +++ b/src/gaia/version.py @@ -6,7 +6,7 @@ import subprocess from importlib.metadata import version as get_package_version_metadata -__version__ = "0.17.1-rc.1" +__version__ = "0.17.1" # Lemonade version used across CI and installer LEMONADE_VERSION = "10.0.0" diff --git a/tests/unit/test_server_webui_dist.py b/tests/unit/test_server_webui_dist.py new file mode 100644 index 000000000..bbc048a76 --- /dev/null +++ b/tests/unit/test_server_webui_dist.py @@ -0,0 +1,49 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +""" +Unit tests verifying that the GAIA Agent UI server uses the webui_dist +parameter of create_app() to locate the pre-built frontend. + +When installed via npm (`gaia-ui`), the launcher passes --ui-dist so the +Python server can find the dist/ folder inside the npm package rather than +looking in the (absent) PyPI package tree. +""" + +import tempfile +import unittest +from pathlib import Path + + +class TestServerWebuiDist(unittest.TestCase): + """Tests for webui_dist parameter handling in gaia.ui.server.create_app().""" + + # ------------------------------------------------------------------ + # Test 1: server uses webui_dist parameter and serves index.html from it + # ------------------------------------------------------------------ + + def test_server_uses_provided_webui_dist(self): + """ + When webui_dist is passed to create_app(), the server serves index.html + from that directory instead of returning the JSON API banner. + """ + from fastapi.testclient import TestClient + + from gaia.ui.server import create_app + + with tempfile.TemporaryDirectory() as tmpdir: + dist_dir = Path(tmpdir) + (dist_dir / "assets").mkdir() + (dist_dir / "index.html").write_text( + "GAIA Agent UI" + ) + + app = create_app(webui_dist=str(dist_dir), db_path=":memory:") + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/") + self.assertEqual(response.status_code, 200) + self.assertIn("GAIA Agent UI", response.text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_webui_build.py b/tests/unit/test_webui_build.py new file mode 100644 index 000000000..cc9e96a23 --- /dev/null +++ b/tests/unit/test_webui_build.py @@ -0,0 +1,314 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +""" +Unit tests for gaia.ui.build.ensure_webui_built and the gaia init +frontend build step. + +Tests use real temp directories for path logic and patch only subprocess +and shutil.which so no actual npm/node invocations happen. +""" + +import subprocess +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + + +class TestEnsureWebuiBuilt(unittest.TestCase): + """Tests for gaia.ui.build.ensure_webui_built.""" + + def _call( + self, webui_dir, which_return="/usr/bin/node", run_side_effect=None, log=None + ): + """Helper: call ensure_webui_built with controlled environment.""" + from gaia.ui.build import ensure_webui_built + + msgs = [] + log_fn = log if log is not None else msgs.append + + with ( + patch("gaia.ui.build.shutil.which", return_value=which_return), + patch( + "gaia.ui.build.subprocess.run", + side_effect=run_side_effect, + ) as mock_run, + ): + result = ensure_webui_built(log_fn=log_fn, _webui_dir=webui_dir) + + return msgs, mock_run, result + + # ------------------------------------------------------------------ + # Test 1: skip when src/ is absent (pip install, no source tree) + # ------------------------------------------------------------------ + + def test_skips_pip_install(self): + """ensure_webui_built returns early when src/ directory is absent.""" + with tempfile.TemporaryDirectory() as tmpdir: + webui_dir = Path(tmpdir) + # src/ deliberately NOT created + + msgs, mock_run, result = self._call(webui_dir) + + mock_run.assert_not_called() + self.assertFalse(result, "Expected False (silent skip) when src/ is absent") + + # ------------------------------------------------------------------ + # Test 2: skip when dist is fresh (staleness check) + # ------------------------------------------------------------------ + + def test_staleness_skip(self): + """No build when dist/index.html is newer than all source files.""" + with tempfile.TemporaryDirectory() as tmpdir: + webui_dir = Path(tmpdir) + src_dir = webui_dir / "src" + src_dir.mkdir() + # Write a source file with an old mtime + src_file = src_dir / "app.ts" + src_file.write_text("const x = 1;") + + # Create dist/index.html with a NEWER mtime + dist_dir = webui_dir / "dist" + dist_dir.mkdir() + dist_index = dist_dir / "index.html" + dist_index.write_text("") + + # Force dist to appear newer than src + old_time = time.time() - 60 + import os + + os.utime(str(src_file), (old_time, old_time)) + new_time = time.time() + os.utime(str(dist_index), (new_time, new_time)) + + msgs, mock_run, result = self._call(webui_dir) + + mock_run.assert_not_called() + self.assertTrue(result, "Expected True when dist is already up-to-date") + + # ------------------------------------------------------------------ + # Test 3: node missing — logs warning, no exception + # ------------------------------------------------------------------ + + def test_node_missing(self): + """Log a warning and return gracefully when Node.js is not found.""" + with tempfile.TemporaryDirectory() as tmpdir: + webui_dir = Path(tmpdir) + (webui_dir / "src").mkdir() + # No dist/index.html — build is needed + + msgs, mock_run, result = self._call(webui_dir, which_return=None) + + mock_run.assert_not_called() + self.assertFalse(result, "Expected False when Node.js is missing") + self.assertTrue( + any("Node.js not found" in m for m in msgs), + f"Expected 'Node.js not found' in log output, got: {msgs}", + ) + + # ------------------------------------------------------------------ + # Test 4: happy path — builds when dist is absent, node/npm available + # ------------------------------------------------------------------ + + def test_builds_frontend(self): + """subprocess.run called with ['npm', 'run', 'build'] when dist absent.""" + with tempfile.TemporaryDirectory() as tmpdir: + webui_dir = Path(tmpdir) + src_dir = webui_dir / "src" + src_dir.mkdir() + src_file = src_dir / "app.ts" + src_file.write_text("const x = 1;") + # node_modules present so npm install is skipped + (webui_dir / "node_modules").mkdir() + # No dist/index.html — build needed + + msgs, mock_run, result = self._call(webui_dir) + + called_cmds = [c.args[0] for c in mock_run.call_args_list] + self.assertTrue( + any(c == ["npm", "run", "build"] for c in called_cmds), + f"Expected ['npm', 'run', 'build'] call, got: {called_cmds}", + ) + self.assertTrue(result, "Expected True when build succeeds") + + # ------------------------------------------------------------------ + # Test 5: npm install failure — no exception propagated + # ------------------------------------------------------------------ + + def test_npm_install_failure_continues(self): + """CalledProcessError from npm install does not propagate.""" + with tempfile.TemporaryDirectory() as tmpdir: + webui_dir = Path(tmpdir) + (webui_dir / "src").mkdir() + # node_modules absent — triggers npm install + # No dist/index.html + + def fail_install(cmd, **kwargs): + if "install" in cmd: + raise subprocess.CalledProcessError(1, cmd, stderr="ERR") + return MagicMock(returncode=0) + + try: + msgs, mock_run, result = self._call( + webui_dir, run_side_effect=fail_install + ) + except Exception as e: + self.fail(f"ensure_webui_built raised unexpectedly: {e}") + + self.assertFalse(result, "Expected False when npm install fails") + + # ------------------------------------------------------------------ + # Test 6: npm run build failure — caught, returns False, no exception + # ------------------------------------------------------------------ + + def test_build_step_failure_continues(self): + """npm run build CalledProcessError is caught; returns False without raising.""" + from gaia.ui.build import ensure_webui_built + + with tempfile.TemporaryDirectory() as tmpdir: + webui_dir = Path(tmpdir) + (webui_dir / "src").mkdir() + # node_modules present so npm install is skipped + (webui_dir / "node_modules").mkdir() + # No dist/index.html — build is needed + + def fail_build(cmd, **kwargs): + if "build" in cmd: + raise subprocess.CalledProcessError(1, cmd) + return MagicMock(returncode=0) + + warnings = [] + result = None + try: + with ( + patch("gaia.ui.build.shutil.which", return_value="/usr/bin/node"), + patch("gaia.ui.build.subprocess.run", side_effect=fail_build), + ): + result = ensure_webui_built( + _webui_dir=webui_dir, warn_fn=warnings.append + ) + except Exception as e: + self.fail(f"ensure_webui_built raised unexpectedly: {e}") + + self.assertFalse(result, "Expected False when build step fails") + self.assertTrue( + any( + "build failed" in w.lower() or "Frontend build failed" in w + for w in warnings + ), + f"Expected build-failure warning, got: {warnings}", + ) + + # ------------------------------------------------------------------ + # Test 7: node found, npm missing — warns and skips build + # ------------------------------------------------------------------ + + def test_npm_missing_warns_and_skips(self): + """If node is present but npm is missing, warn_fn is called and build is skipped.""" + from gaia.ui.build import ensure_webui_built + + with tempfile.TemporaryDirectory() as tmpdir: + webui_dir = Path(tmpdir) + (webui_dir / "src").mkdir() + # No dist/index.html — build would be needed + + warnings = [] + + def fake_which(cmd): + return "/usr/bin/node" if cmd == "node" else None + + with ( + patch("gaia.ui.build.shutil.which", side_effect=fake_which), + patch("gaia.ui.build.subprocess.run") as mock_run, + ): + result = ensure_webui_built( + _webui_dir=webui_dir, warn_fn=warnings.append + ) + + mock_run.assert_not_called() + self.assertFalse(result, "Expected False when npm is missing") + self.assertTrue( + any("npm" in w.lower() for w in warnings), + f"Expected npm warning in warn_fn output, got: {warnings}", + ) + + +class TestInitCommandWebuiBuild(unittest.TestCase): + """Tests for the gaia init frontend build integration.""" + + def _run_init_with_src_dir_mock(self, src_is_dir: bool): + """ + Run InitCommand.run() with all heavy operations mocked. + + Returns the mock for ensure_webui_built so caller can assert on it. + """ + from gaia.installer.init_command import InitCommand + from gaia.installer.lemonade_installer import LemonadeInstaller + + # Fake src path whose .is_dir() is controlled by the caller + fake_src = MagicMock() + fake_src.is_dir.return_value = src_is_dir + + # Build Path chain via MagicMock's auto-chaining of __truediv__.return_value. + # Path(__file__).resolve().parent.parent / "apps" / "webui" / "src" = fake_src + # Each / uses __truediv__.return_value on the previous mock. + mock_path = MagicMock() + ( + mock_path.return_value.resolve.return_value.parent.parent.__truediv__.return_value.__truediv__.return_value.__truediv__.return_value # / "apps" # / "webui" # / "src" + ) + # Now override the final .return_value to be fake_src + ( + mock_path.return_value.resolve.return_value.parent.parent.__truediv__.return_value.__truediv__.return_value.__truediv__ + ).return_value = fake_src + + mock_installer = MagicMock(spec=LemonadeInstaller) + + with ( + patch("gaia.installer.init_command.Path", mock_path), + patch("gaia.ui.build.ensure_webui_built") as mock_ensure_built, + patch.object(InitCommand, "_print_header"), + patch.object(InitCommand, "_print"), + patch.object(InitCommand, "_print_step"), + patch.object(InitCommand, "_print_success"), + patch.object(InitCommand, "_print_completion"), + patch.object(InitCommand, "_ensure_lemonade_installed", return_value=True), + patch.object(InitCommand, "_ensure_server_running", return_value=True), + patch.object(InitCommand, "_verify_setup", return_value=True), + ): + cmd = InitCommand.__new__(InitCommand) + cmd.profile = "minimal" + cmd.skip_models = True + cmd.skip_lemonade = True + cmd.remote = False + cmd.verbose = False + cmd.force_reinstall = False + cmd._lemonade_base_url = None + cmd.installer = mock_installer + cmd.console = MagicMock() + cmd.run() + + return mock_ensure_built + + # ------------------------------------------------------------------ + # Test 6: init calls ensure_webui_built in dev mode + # ------------------------------------------------------------------ + + def test_init_calls_build_in_dev_mode(self): + """ensure_webui_built is called when webui src/ exists (dev install).""" + mock_ensure_built = self._run_init_with_src_dir_mock(src_is_dir=True) + mock_ensure_built.assert_called_once() + + # ------------------------------------------------------------------ + # Test 7: init skips build for pip installs (no src/) + # ------------------------------------------------------------------ + + def test_init_skips_build_for_pip(self): + """ensure_webui_built is NOT called when webui src/ is absent (pip install).""" + mock_ensure_built = self._run_init_with_src_dir_mock(src_is_dir=False) + mock_ensure_built.assert_not_called() + + +if __name__ == "__main__": + unittest.main()