diff --git a/.github/workflows/quickstart-lifecycle.yml b/.github/workflows/quickstart-lifecycle.yml index e3943d5e1..b1b592bd5 100644 --- a/.github/workflows/quickstart-lifecycle.yml +++ b/.github/workflows/quickstart-lifecycle.yml @@ -38,7 +38,7 @@ jobs: python scripts/quickstart_lifecycle.py --launcher-wheel "lifecycle-dist/*.whl" --work-dir "runs/launcher-lifecycle" - --browser-with-deps + --browser-system-deps --source-revision "${{ github.sha }}" - name: Upload lifecycle evidence diff --git a/README.md b/README.md index bc197d3e4..8e5748351 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,27 @@ the run tool: ```bash python -m pip install --upgrade openadapt +openadapt doctor --backend web openadapt flow tutorial openadapt-agent serve --allow-run ``` -Python 3.10 through 3.12. No account, no API key, no extra. Chromium downloads -itself the first time a browser action runs. `openadapt-agent` is in the base -install. +Python 3.10 through 3.12. The local tutorial needs no account or API key. The +base install includes Playwright and `openadapt-agent`. On the first browser +action, OpenAdapt asks Playwright to download its matching Chromium build. That +download needs network access. + +Minimal Linux hosts can lack Chromium's shared libraries. `openadapt doctor` +lists the missing libraries before any browser download. If it finds any, run: + +```bash +python -m playwright install-deps chromium +openadapt doctor --backend web +``` + +Playwright asks for administrator access when the package manager needs it. +Use the interpreter-specific command from `doctor` if you installed OpenAdapt +with an isolated tool runner. `openadapt flow tutorial` is the launcher spelling of `openadapt-flow tutorial`. It records and compiles a task in MockMed, a synthetic practice-management diff --git a/docs/cli.md b/docs/cli.md index 0b025ad7e..1cb09399b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -94,9 +94,25 @@ openadapt doctor --backend web openadapt deploy --backend web ``` -`doctor` checks the local capability dependencies. `deploy` performs a -read-only deployment preflight and prints the applicable Flow and Desktop -path. Neither command certifies a customer workflow. +`doctor` checks the required launcher packages. For the default and `web` +checks, it also uses Flow's Chromium library probe. A missing core package or +Chromium system library returns a nonzero exit status. On Linux, install missing +browser libraries with: + +```bash +python -m playwright install-deps chromium +``` + +Playwright asks for administrator access when the package manager needs it. +The command printed by `doctor` uses the exact Python interpreter that runs the +launcher, including an interpreter inside an isolated tool environment. + +An absent Chromium binary does not fail the check when its host libraries are +ready. OpenAdapt will ask Playwright to download the matching build on the first +browser action, so that action needs network access. + +`deploy` performs a read-only deployment preflight and prints the applicable +Flow and Desktop path. Neither command certifies a customer workflow. ## Hosted connection diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 601908467..9203ecc24 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -13,12 +13,26 @@ Install the launcher: ```bash python -m pip install --upgrade openadapt +openadapt doctor --backend web openadapt flow tutorial ``` The launcher installs the compatible `openadapt-flow` engine. Do not install -the launcher and engine separately. The first browser action downloads the -matching Chromium build once. +the launcher and engine separately. On the first browser action, OpenAdapt asks +Playwright to download its matching Chromium build. The download needs network +access. + +On Linux, `doctor` checks Chromium's system libraries before that download. If +it lists missing libraries, install them and run the check again: + +```bash +python -m playwright install-deps chromium +openadapt doctor --backend web +``` + +Playwright asks for administrator access when the package manager needs it. +Use the interpreter-specific command from `doctor` if an isolated tool runner +installed OpenAdapt. The tutorial uses the bundled synthetic MockMed application. It records, compiles, certifies, and replays one workflow. A separate read-only interface @@ -28,8 +42,9 @@ profile with no model or Cloud call. ## Capability-specific installs The base install includes the launcher, the Flow engine, and the Playwright -driver for the browser tutorial. Chromium downloads only on the first browser -action. Add the applicable capability for a native or remote workflow: +driver for the browser tutorial. OpenAdapt requests the Chromium download only +when a browser action needs it. Add the applicable capability for a native or +remote workflow: ```bash python -m pip install 'openadapt[capture]' # local human demonstration diff --git a/openadapt/cli.py b/openadapt/cli.py index e7ae9b902..0fa3bc785 100644 --- a/openadapt/cli.py +++ b/openadapt/cli.py @@ -27,6 +27,7 @@ import platform import re +import shlex import sys from pathlib import Path from typing import Optional @@ -88,6 +89,28 @@ def main(): # Flow Commands (the demonstration compiler — flagship path) # ============================================================================= +_FLOW_X11_SONAME_CASE = { + "xcomposite": "Xcomposite", + "xdamage": "Xdamage", + "xfixes": "Xfixes", + "xrandr": "Xrandr", +} + + +def _correct_released_flow_x11_sonames() -> None: + """Correct the four case-sensitive X11 probes in released Flow builds.""" + if sys.platform != "linux": + return + try: + import openadapt_flow._browser_setup as browser_setup + except ImportError: + return + sonames = getattr(browser_setup, "_LINUX_CHROMIUM_SONAMES", None) + if not isinstance(sonames, (list, tuple)): + return + corrected = tuple(_FLOW_X11_SONAME_CASE.get(name, name) for name in sonames) + browser_setup._LINUX_CHROMIUM_SONAMES = corrected + def _invoke_flow(argv: list[str]) -> int: """Invoke the canonical engine once and return its exit code.""" @@ -99,6 +122,7 @@ def _invoke_flow(argv: list[str]) -> int: click.echo("Engine only: pip install openadapt-flow", err=True) return 1 + _correct_released_flow_x11_sonames() return int(flow_main(argv)) @@ -283,6 +307,9 @@ def quickstart( _SECRET_REFERENCE = re.compile(r"^(?:env:[A-Z][A-Z0-9_]*|keychain:[^/\s]+/[^/\s]+)$") _SUPPORTED_FLOW_RANGE = ">=1.29.0,<2.0.0" _RDP_INSTALL_COMMAND = "python -m pip install 'openadapt[rdp]'" +_CHROMIUM_SYSTEM_LIBS_COMMAND = ( + f"{shlex.quote(sys.executable)} -m playwright install-deps chromium" +) def _supported_flow_version(value: str) -> bool: @@ -1045,24 +1072,68 @@ def doctor(backend: str | None): else: playwright = find_spec("playwright") is not None if not playwright: + failures.append("playwright") click.echo( " [MISSING] Browser: the base install does not contain " "Playwright. Run `python -m pip install --upgrade openadapt`." ) else: + _correct_released_flow_x11_sonames() try: - from openadapt_flow._browser_setup import _chromium_present - - chromium = _chromium_present() - except Exception: - chromium = False - if chromium: - click.echo(" [OK] Browser: Playwright and Chromium are ready.") - else: + from openadapt_flow._browser_setup import ( + _chromium_present, + _missing_chromium_system_libs, + ) + except (ImportError, AttributeError): + failures.append("browser-diagnostics") click.echo( - " [READY] Browser: Playwright is installed; the matching " - "Chromium downloads automatically on the first web action." + " [MISSING] Browser: the installed Flow version cannot check " + "Chromium dependencies. Run `python -m pip install --upgrade " + "openadapt`." ) + else: + try: + missing_system_libs = _missing_chromium_system_libs() + except Exception: + failures.append("browser-system-library-check") + click.echo( + " [ERROR] Browser: OpenAdapt could not check Chromium's " + "system libraries. Reinstall OpenAdapt, then run this " + "command again." + ) + else: + if missing_system_libs: + failures.append("browser-system-libraries") + click.echo( + " [MISSING] Browser: Chromium needs these system " + f"libraries: {', '.join(missing_system_libs)}" + ) + click.echo( + " Install them once: " + _CHROMIUM_SYSTEM_LIBS_COMMAND + ) + else: + try: + chromium = _chromium_present() + except Exception: + failures.append("chromium-check") + click.echo( + " [ERROR] Browser: OpenAdapt could not inspect " + "the installed Chromium build. Reinstall " + "OpenAdapt, then run this command again." + ) + else: + if chromium: + click.echo( + " [OK] Browser: Playwright, Chromium, and its " + "system libraries are ready." + ) + else: + click.echo( + " [READY] Browser: Playwright and Chromium's " + "system libraries are ready. OpenAdapt will try " + "to download the matching Chromium build on the " + "first web action." + ) # Core packages: installed by the base `pip install openadapt`. Only # these are treated as required; a missing one is a real problem. @@ -1079,8 +1150,9 @@ def doctor(backend: str | None): if find_spec(pkg) is not None: click.echo(f" [OK] {pkg}") else: + failures.append(f"core:{pkg}") click.echo( - f" [MISSING] {pkg} (core dependency — reinstall with " + f" [MISSING] {pkg} (core dependency; reinstall with " f"`pip install openadapt`)" ) @@ -1102,7 +1174,7 @@ def doctor(backend: str | None): click.echo(f" [OK] {pkg}") else: click.echo( - f" [--] {pkg} (optional — install with " + f" [--] {pkg} (optional; install with " f"`pip install openadapt[{extra}]`)" ) diff --git a/production-lifecycle-source.json b/production-lifecycle-source.json index ca10ab83a..68e68135e 100644 --- a/production-lifecycle-source.json +++ b/production-lifecycle-source.json @@ -1,16 +1,16 @@ { "schema_version": "openadapt.production-readme-source/v1", "repository": "OpenAdaptAI/openadapt-ops", - "source_commit": "9e7bec32d9165a075828f28f00aa27888b775db4", + "source_commit": "db99c71dc3e105d0bd74f0a1e8aa0ee0464e18ee", "files": { "projection": { "path": "docs/production-lifecycle.json", - "url": "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-ops/9e7bec32d9165a075828f28f00aa27888b775db4/docs/production-lifecycle.json", - "sha256": "sha256:0fedb6fa123646ce779d0ed68a1d2e34acb3f0d0806fa123842ea18093f59a58" + "url": "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-ops/db99c71dc3e105d0bd74f0a1e8aa0ee0464e18ee/docs/production-lifecycle.json", + "sha256": "sha256:da7d59c495ae503f7a15329a6375276ced7b5a6fe606bffa560bd077eb58491b" }, "schema": { "path": "docs/schemas/production-lifecycle-public.schema.json", - "url": "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-ops/9e7bec32d9165a075828f28f00aa27888b775db4/docs/schemas/production-lifecycle-public.schema.json", + "url": "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-ops/db99c71dc3e105d0bd74f0a1e8aa0ee0464e18ee/docs/schemas/production-lifecycle-public.schema.json", "sha256": "sha256:c6db48d6089314d745c2cf1af7bced511f359c7fee9cacc2e35b758fc22d7073" } } diff --git a/pyproject.toml b/pyproject.toml index 76a1d8b61..0d6634c4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,8 +30,10 @@ classifiers = [ # Base install: the CLI, the flagship demonstration compiler, and the browser # driver used by the bundled tutorial. `pip install openadapt` must make the # public `openadapt quickstart` command work without a second package install. -# Playwright downloads Chromium lazily on the first browser action. All other -# capabilities (capture, ml, evals, ...) remain opt-in extras. +# OpenAdapt asks Playwright to download Chromium on the first browser action. +# Linux system libraries remain host prerequisites; `openadapt doctor` and Flow +# check them before the browser download. All other capabilities (capture, ml, +# evals, ...) remain opt-in extras. dependencies = [ "click>=8.0.0", "openadapt-flow[browser,hosted]>=1.29.0,<2.0.0", @@ -45,7 +47,8 @@ dependencies = [ browser = [ # Kept as a compatibility alias for existing install commands. The base # launcher now selects this Flow contract because the bundled quickstart - # uses the browser. Chromium remains lazy on first use. + # uses the browser. OpenAdapt requests Chromium on first browser use after + # the host dependency check passes. "openadapt-flow[browser]>=1.29.0,<2.0.0", ] # Local human desktop recording. Flow owns the supported capture adapter diff --git a/scripts/quickstart_lifecycle.py b/scripts/quickstart_lifecycle.py index 9af1726df..2300109c8 100644 --- a/scripts/quickstart_lifecycle.py +++ b/scripts/quickstart_lifecycle.py @@ -18,6 +18,7 @@ "Task was destroyed but it is pending!", "Future exception was never retrieved", ) +_LAZY_BROWSER_INSTALL_NOTICE = "Downloading the Chromium browser OpenAdapt needs" def _resolve_wheel(pattern: str, distribution: str) -> Path: @@ -94,6 +95,55 @@ def _load_object(path: Path) -> dict: return value +def _browser_present( + python: Path, + *, + cwd: Path, + env: dict[str, str], + log: Path, +) -> bool: + """Inspect Flow's exact Playwright browser without launching it.""" + result = _run( + [ + str(python), + "-c", + ( + "from openadapt_flow._browser_setup import _chromium_present; " + "print('present' if _chromium_present() else 'absent')" + ), + ], + cwd=cwd, + env=env, + log=log, + ) + state = result.stdout.strip() + if state not in {"present", "absent"}: + raise AssertionError(f"unexpected Chromium probe output: {state!r}") + return state == "present" + + +def _install_browser_system_dependencies( + python: Path, + *, + cwd: Path, + env: dict[str, str], + log: Path, +) -> None: + """Install Linux packages only, leaving Chromium for the public command.""" + _run( + [ + str(python), + "-m", + "playwright", + "install-deps", + "chromium", + ], + cwd=cwd, + env=env, + log=log, + ) + + def _inspect_quickstart(root: Path) -> dict[str, object]: run = root / "run" report = _load_object(run / "report.json") @@ -140,7 +190,7 @@ def run_lifecycle( work_dir: Path, *, flow_wheel: Path | None, - browser_with_deps: bool, + browser_system_deps: bool, source_revision: str | None, ) -> dict[str, object]: if work_dir.exists(): @@ -159,6 +209,7 @@ def run_lifecycle( env["PYTHONUTF8"] = "1" env["PYTHONIOENCODING"] = "utf-8" env["OPENADAPT_FLOW_SCRUB"] = "off" + env["PLAYWRIGHT_BROWSERS_PATH"] = str(work_dir / "playwright-browsers") summary: dict[str, object] = { "launcher_wheel": launcher_wheel.name, @@ -167,7 +218,17 @@ def run_lifecycle( "flow_wheel_sha256": _sha256(flow_wheel) if flow_wheel else None, "platform": sys.platform, "source_revision": source_revision or "local-unbound", + "browser_preflight": { + "system_dependencies": "not-requested", + "doctor": "not-run", + "chromium_present_before_quickstart": None, + "chromium_present_after_quickstart": None, + "lazy_install_performed": False, + "lazy_install_notice_seen": False, + }, } + browser_preflight = summary["browser_preflight"] + assert isinstance(browser_preflight, dict) installed = False try: if flow_wheel is not None: @@ -211,34 +272,67 @@ def run_lifecycle( env=env, log=logs / "04-flow-help.log", ) - if browser_with_deps: - _run( - [ - str(python), - "-m", - "playwright", - "install", - "--with-deps", - "chromium", - ], + if browser_system_deps: + _install_browser_system_dependencies( + python, cwd=artifacts, env=env, log=logs / "05-browser-host-deps.log", ) + browser_preflight["system_dependencies"] = "installed-via-playwright" + + chromium_before = _browser_present( + python, + cwd=artifacts, + env=env, + log=logs / "06-browser-before.log", + ) + browser_preflight["chromium_present_before_quickstart"] = chromium_before + if chromium_before: + raise AssertionError( + "the isolated browser cache already contains Chromium; " + "the lifecycle cannot prove a lazy install" + ) - quickstart = artifacts / "openadapt-quickstart" _run( + [str(console), "doctor", "--backend", "web"], + cwd=artifacts, + env=env, + log=logs / "07-doctor.log", + ) + browser_preflight["doctor"] = "passed-before-browser-download" + + quickstart = artifacts / "openadapt-quickstart" + quickstart_result = _run( [str(console), "quickstart", "--out", str(quickstart)], cwd=artifacts, env=env, - log=logs / "06-quickstart.log", + log=logs / "08-quickstart.log", ) + chromium_after = _browser_present( + python, + cwd=artifacts, + env=env, + log=logs / "09-browser-after.log", + ) + browser_preflight["chromium_present_after_quickstart"] = chromium_after + browser_preflight["lazy_install_performed"] = ( + not chromium_before and chromium_after + ) + browser_preflight["lazy_install_notice_seen"] = ( + _LAZY_BROWSER_INSTALL_NOTICE in quickstart_result.stdout + ) + if not chromium_after: + raise AssertionError("quickstart did not install Chromium") + if not browser_preflight["lazy_install_notice_seen"]: + raise AssertionError("quickstart did not report its lazy Chromium install") + summary.update(_inspect_quickstart(quickstart)) _run( [str(console), "flow", "lint", str(quickstart / "bundle")], cwd=artifacts, env=env, - log=logs / "07-lint.log", + log=logs / "10-lint.log", ) finally: if installed: @@ -254,7 +348,7 @@ def run_lifecycle( ], cwd=artifacts, env=env, - log=logs / "08-uninstall.log", + log=logs / "11-uninstall.log", ) _run( [ @@ -268,7 +362,7 @@ def run_lifecycle( ], cwd=artifacts, env=env, - log=logs / "09-uninstall-probe.log", + log=logs / "12-uninstall-probe.log", ) summary["uninstall_verified"] = True (work_dir / "summary.json").write_text( @@ -289,9 +383,9 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--work-dir", required=True) parser.add_argument( - "--browser-with-deps", + "--browser-system-deps", action="store_true", - help="Pre-provision Chromium and Linux host dependencies", + help="Install Linux browser system packages without downloading Chromium", ) parser.add_argument("--source-revision", default=None) return parser @@ -305,7 +399,7 @@ def main(argv: Sequence[str] | None = None) -> int: launcher_wheel, Path(args.work_dir).resolve(), flow_wheel=flow_wheel, - browser_with_deps=args.browser_with_deps, + browser_system_deps=args.browser_system_deps, source_revision=args.source_revision, ) return 0 diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index cae3423bf..dc631a8d1 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -32,6 +32,7 @@ import pytest from click.testing import CliRunner +from openadapt.cli import _correct_released_flow_x11_sonames, _supported_flow_version from openadapt.cli import main as cli_main try: @@ -226,11 +227,23 @@ def test_distribution_metadata_matches_engine_python_range(): assert {term.strip() for term in actual.split(",")} == {">=3.10", "<3.13"} -def test_doctor_lists_quickstart_dependencies_as_core_not_extras(): +def test_doctor_lists_quickstart_dependencies_as_core_not_extras(monkeypatch): """`openadapt doctor` must treat Flow and Playwright as base dependencies. The other capabilities stay optional and must not cause a failure. """ + core = {"openadapt_flow", "openadapt_agent", "playwright"} + monkeypatch.setattr( + "importlib.util.find_spec", + lambda name: object() if name in core else None, + ) + monkeypatch.setattr( + "openadapt_flow._browser_setup._missing_chromium_system_libs", lambda: [] + ) + monkeypatch.setattr( + "openadapt_flow._browser_setup._chromium_present", lambda: False + ) + runner = CliRunner() result = runner.invoke(cli_main, ["doctor"]) assert result.exit_code == 0, result.output @@ -299,8 +312,45 @@ def test_launcher_flow_and_substrate_extras_metadata(): ] +def test_released_flow_x11_sonames_are_case_corrected(monkeypatch): + import openadapt_flow._browser_setup as browser_setup + + monkeypatch.setattr( + browser_setup, + "_LINUX_CHROMIUM_SONAMES", + ("nss3", "xcomposite", "xdamage", "xfixes", "xrandr", "gbm"), + raising=False, + ) + monkeypatch.setattr(sys, "platform", "linux") + + _correct_released_flow_x11_sonames() + + assert browser_setup._LINUX_CHROMIUM_SONAMES == ( + "nss3", + "Xcomposite", + "Xdamage", + "Xfixes", + "Xrandr", + "gbm", + ) + + +@pytest.mark.parametrize("version", ["1.29.0", "1.35.1", "1.99.0"]) +def test_launcher_accepts_flow_versions_in_the_supported_range(version): + assert _supported_flow_version(version) + + +@pytest.mark.parametrize("version", ["1.28.9", "2.0.0", "invalid"]) +def test_launcher_rejects_flow_versions_outside_the_supported_range(version): + assert not _supported_flow_version(version) + + def test_doctor_does_not_require_browser_for_citrix(monkeypatch): - monkeypatch.setattr("importlib.util.find_spec", lambda _name: None) + core = {"openadapt_flow", "openadapt_agent", "playwright"} + monkeypatch.setattr( + "importlib.util.find_spec", + lambda name: object() if name in core else None, + ) result = CliRunner().invoke(cli_main, ["doctor", "--backend", "citrix"]) assert result.exit_code == 0, result.output assert "browser support is not required" in result.output @@ -319,9 +369,10 @@ def test_doctor_rdp_fails_without_transport_dependency(monkeypatch): def test_doctor_rdp_reports_transport_ready(monkeypatch): + installed = {"aardwolf", "openadapt_flow", "openadapt_agent", "playwright"} monkeypatch.setattr( "importlib.util.find_spec", - lambda name: object() if name in {"aardwolf", "openadapt_flow"} else None, + lambda name: object() if name in installed else None, ) result = CliRunner().invoke(cli_main, ["doctor", "--backend", "rdp"]) @@ -330,6 +381,92 @@ def test_doctor_rdp_reports_transport_ready(monkeypatch): assert "RDP transport dependency is installed" in result.output +@pytest.mark.parametrize("backend_args", [[], ["--backend", "web"]]) +def test_doctor_fails_for_missing_chromium_system_libraries(monkeypatch, backend_args): + """Default and web checks must stop before a Chromium download can fail.""" + monkeypatch.setattr("importlib.util.find_spec", lambda _name: object()) + monkeypatch.setattr( + "openadapt_flow._browser_setup._missing_chromium_system_libs", + lambda: ["Xcomposite", "Xdamage", "Xfixes", "Xrandr"], + ) + monkeypatch.setattr( + "openadapt_flow._browser_setup._chromium_present", + lambda: pytest.fail("Chromium must not be inspected before its libraries"), + ) + + result = CliRunner().invoke(cli_main, ["doctor", *backend_args]) + + assert result.exit_code != 0 + assert "Xcomposite, Xdamage, Xfixes, Xrandr" in result.output + assert "-m playwright install-deps chromium" in result.output + assert "System check failed" in result.output + + +def test_doctor_reports_lazy_browser_download_after_host_is_ready(monkeypatch): + monkeypatch.setattr("importlib.util.find_spec", lambda _name: object()) + monkeypatch.setattr( + "openadapt_flow._browser_setup._missing_chromium_system_libs", lambda: [] + ) + monkeypatch.setattr( + "openadapt_flow._browser_setup._chromium_present", lambda: False + ) + + result = CliRunner().invoke(cli_main, ["doctor", "--backend", "web"]) + + assert result.exit_code == 0, result.output + assert "system libraries are ready" in result.output + assert "will try to download" in result.output + + +def test_doctor_fails_when_chromium_cannot_be_inspected(monkeypatch): + monkeypatch.setattr("importlib.util.find_spec", lambda _name: object()) + monkeypatch.setattr( + "openadapt_flow._browser_setup._missing_chromium_system_libs", lambda: [] + ) + + def fail_inspection(): + raise RuntimeError("driver failed") + + monkeypatch.setattr( + "openadapt_flow._browser_setup._chromium_present", fail_inspection + ) + + result = CliRunner().invoke(cli_main, ["doctor", "--backend", "web"]) + + assert result.exit_code != 0 + assert "could not inspect the installed Chromium build" in result.output + assert "System check failed" in result.output + + +def test_doctor_fails_without_playwright_for_web(monkeypatch): + installed = {"openadapt_flow", "openadapt_agent"} + monkeypatch.setattr( + "importlib.util.find_spec", + lambda name: object() if name in installed else None, + ) + + result = CliRunner().invoke(cli_main, ["doctor", "--backend", "web"]) + + assert result.exit_code != 0 + assert "base install does not contain Playwright" in result.output + assert "System check failed" in result.output + + +@pytest.mark.parametrize("missing", ["openadapt_flow", "openadapt_agent", "playwright"]) +def test_doctor_fails_when_a_core_package_is_missing(monkeypatch, missing): + core = {"openadapt_flow", "openadapt_agent", "playwright"} + monkeypatch.setattr( + "importlib.util.find_spec", + lambda name: object() if name in core - {missing} else None, + ) + + result = CliRunner().invoke(cli_main, ["doctor", "--backend", "citrix"]) + + assert result.exit_code != 0 + assert f"[MISSING] {missing} (core dependency" in result.output + assert "System check failed" in result.output + + def test_deploy_preflight_composes_existing_flow_interfaces_without_secrets( monkeypatch, ): @@ -347,7 +484,7 @@ def test_deploy_preflight_composes_existing_flow_interfaces_without_secrets( else None ), ) - monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0") + monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.35.0") result = CliRunner().invoke( cli_main, ["deploy", "--backend", "rdp", "--secret-ref", "env:BYOC_CONNECTOR_TOKEN"], @@ -370,19 +507,19 @@ def test_deploy_base_hosted_install_gives_conditional_console_setup(monkeypatch) "importlib.util.find_spec", lambda name: object() if name in {"openadapt_flow", "aardwolf"} else None, ) - monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0") + monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.35.0") result = CliRunner().invoke(cli_main, ["deploy", "--backend", "rdp"]) assert result.exit_code == 0, result.output assert "optional local operator console is not installed" in result.output - assert "python -m pip install 'openadapt-flow[console]==1.29.0'" in result.output + assert "python -m pip install 'openadapt-flow[console]==1.35.0'" in result.output assert "openadapt flow console --bundles" not in result.output assert "Re-run this preflight" in result.output def test_deploy_console_requires_openadapt_types(monkeypatch): - """Flow 1.30 imports openadapt-types when the operator console starts.""" + """The supported Flow console imports openadapt-types when it starts.""" monkeypatch.setattr( "importlib.util.find_spec", lambda name: ( @@ -391,13 +528,13 @@ def test_deploy_console_requires_openadapt_types(monkeypatch): else None ), ) - monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.30.0") + monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.35.0") result = CliRunner().invoke(cli_main, ["deploy", "--backend", "rdp"]) assert result.exit_code == 0, result.output assert "optional local operator console is not installed" in result.output - assert "python -m pip install 'openadapt-flow[console]==1.30.0'" in result.output + assert "python -m pip install 'openadapt-flow[console]==1.35.0'" in result.output assert "openadapt flow console --bundles" not in result.output @@ -422,7 +559,7 @@ def test_deploy_preflight_fails_without_web_runtime(monkeypatch): "importlib.util.find_spec", lambda name: object() if name == "openadapt_flow" else None, ) - monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0") + monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.35.0") result = CliRunner().invoke(cli_main, ["deploy", "--backend", "web"]) @@ -440,7 +577,7 @@ def test_deploy_preflight_fails_without_rdp_transport(monkeypatch): "importlib.util.find_spec", lambda name: object() if name == "openadapt_flow" else None, ) - monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.29.0") + monkeypatch.setattr("importlib.metadata.version", lambda _name: "1.35.0") result = CliRunner().invoke(cli_main, ["deploy", "--backend", "rdp"]) diff --git a/tests/test_quickstart_lifecycle.py b/tests/test_quickstart_lifecycle.py index cc321509a..64ebef1be 100644 --- a/tests/test_quickstart_lifecycle.py +++ b/tests/test_quickstart_lifecycle.py @@ -85,6 +85,119 @@ def test_zero_exit_with_an_unhandled_async_error_fails(tmp_path, monkeypatch): ) +def test_browser_dependency_setup_does_not_download_chromium(tmp_path, monkeypatch): + lifecycle = _module() + calls = [] + + def capture(command, **_kwargs): + calls.append(command) + return subprocess.CompletedProcess(command, 0, stdout="") + + monkeypatch.setattr(lifecycle, "_run", capture) + lifecycle._install_browser_system_dependencies( + Path("/venv/bin/python"), + cwd=tmp_path, + env={}, + log=tmp_path / "browser-deps.log", + ) + + assert calls == [ + [ + "/venv/bin/python", + "-m", + "playwright", + "install-deps", + "chromium", + ] + ] + + +@pytest.mark.parametrize( + ("output", "expected"), [("absent\n", False), ("present\n", True)] +) +def test_browser_probe_reports_exact_install_state( + tmp_path, monkeypatch, output, expected +): + lifecycle = _module() + monkeypatch.setattr( + lifecycle, + "_run", + lambda command, **_kwargs: subprocess.CompletedProcess( + command, 0, stdout=output + ), + ) + + assert ( + lifecycle._browser_present( + Path("/venv/bin/python"), + cwd=tmp_path, + env={}, + log=tmp_path / "browser-probe.log", + ) + is expected + ) + + +def test_lifecycle_summary_proves_preflight_and_lazy_install(tmp_path, monkeypatch): + lifecycle = _module() + launcher_wheel = tmp_path / "openadapt.whl" + launcher_wheel.write_bytes(b"launcher") + work_dir = tmp_path / "run" + + class FakeEnvironment: + def create(self, root): + lifecycle._venv_python(root).parent.mkdir(parents=True) + lifecycle._venv_python(root).touch() + lifecycle._console(root).touch() + + monkeypatch.setattr( + lifecycle.venv, + "EnvBuilder", + lambda **_kwargs: FakeEnvironment(), + ) + + commands = [] + + def successful_run(command, **_kwargs): + commands.append(command) + output = ( + lifecycle._LAZY_BROWSER_INSTALL_NOTICE if "quickstart" in command else "" + ) + return subprocess.CompletedProcess(command, 0, stdout=output) + + monkeypatch.setattr(lifecycle, "_run", successful_run) + browser_states = iter([False, True]) + monkeypatch.setattr( + lifecycle, "_browser_present", lambda *_a, **_k: next(browser_states) + ) + monkeypatch.setattr( + lifecycle, + "_inspect_quickstart", + lambda _root: {"outcome": "VERIFIED"}, + ) + + summary = lifecycle.run_lifecycle( + launcher_wheel, + work_dir, + flow_wheel=None, + browser_system_deps=True, + source_revision="abc123", + ) + + assert summary["browser_preflight"] == { + "system_dependencies": "installed-via-playwright", + "doctor": "passed-before-browser-download", + "chromium_present_before_quickstart": False, + "chromium_present_after_quickstart": True, + "lazy_install_performed": True, + "lazy_install_notice_seen": True, + } + written = json.loads((work_dir / "summary.json").read_text(encoding="utf-8")) + assert written["browser_preflight"] == summary["browser_preflight"] + assert any("install-deps" in command for command in commands) + assert not any("--with-deps" in command for command in commands) + + def test_workflow_runs_the_public_command_in_one_bounded_weekly_job(): document = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) triggers = document[True] @@ -95,7 +208,9 @@ def test_workflow_runs_the_public_command_in_one_bounded_weekly_job(): steps = jobs["quickstart"]["steps"] run = next(step["run"] for step in steps if step.get("name", "").startswith("Run")) assert "scripts/quickstart_lifecycle.py" in run - assert "--browser-with-deps" in run + assert "--browser-system-deps" in run + assert "--browser-with-deps" not in run + assert jobs["quickstart"]["runs-on"] == "ubuntu-latest" def test_workflow_pins_actions_to_full_commit_shas():