diff --git a/README.md b/README.md
index 1bc72d6..6245d5c 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ for selection keys, automation, shared dependencies, and recovery.
## Documentation
- [Installation, prerequisites, supported systems, and migration](docs/installation.md)
+- [Developer workflow and project command reference](docs/developer-workflow.md)
- [Selective uninstall, cache choices, and recovery](docs/uninstall.md)
- [Troubleshooting and development findings](docs/troubleshooting.md)
- [Development, tests, and maintaining pinned versions](docs/development.md)
@@ -166,6 +167,8 @@ reliably without knowing the terminal emulator.
| `kip PORT [PORT ...]` | Confirm and send SIGTERM to the listeners on explicit ports |
| `serve [PORT] [DIR]` | Serve a directory on localhost (default port 8000) |
| `db [ENV_FILE]` / `dbm` | Interactive PostgreSQL database/schema manager |
+| `dev-check [PATH]` | Read-only project dependency, Git, runtime, and Docker checks |
+| `dev-browser doctor` | Check a project's Playwright/browser setup |
| `pathlist` | Print one PATH entry per line |
| `linux-doctor` / `dev-doctor` | Check installed tools and shell integration |
| `linuxsetup` / `devsetup` | Enter this repository |
@@ -185,6 +188,21 @@ tmux keeps **Ctrl-B** as the prefix. After the prefix, press `|` or `-` to split
detach. `work` names sessions by project directory plus a short path hash, so two
projects with the same directory name do not collide. Existing sessions are kept.
+## Project checks and browser tests
+
+```sh
+dev-check # Inspect this checkout; --network opts into registry probes
+dev-browser doctor # Inspect the project's Playwright/browser setup
+dev-browser install # Download its pinned Chromium version
+dev-browser run -- pnpm test:e2e
+```
+
+On Ubuntu/Debian, `dev-browser install --user-deps` can also extract required
+APT libraries into a user cache without sudo. `run` applies those libraries only
+to its child command. Project runtime versions, task names, dependencies, and
+credentials remain project-owned. See the [developer workflow guide](docs/developer-workflow.md)
+for the full recipe, optional aliases, environment checks, and database tools.
+
## Ports and local servers
```sh
@@ -222,9 +240,9 @@ server. It uses the database role and permissions in the selected connection.
The server's `postgres` database is used for database-level operations by default.
Your role needs CONNECT there and the appropriate CREATE/ownership privileges.
-Recognized variables include `DB_URL`, `DATABASE_URL`, `TEST_DB_URL`, `DB_URLS`,
-and `DATABASE_URLS`. Use `--var NAME` for any other name. Multiple connections can
-be a JSON array or a named JSON object on a single line:
+Recognized variables include `DB_URL`, `DATABASE_URL`, `TEST_DB_URL`,
+`TEST_DATABASE_URL`, `DB_URLS`, and `DATABASE_URLS`. Use `--var NAME` for any other
+name. Multiple connections can be a JSON array or a named JSON object on a single line:
```dotenv
DATABASE_URL=postgresql://app:password@localhost:5432/my_project
diff --git a/bin/dev-browser b/bin/dev-browser
new file mode 100755
index 0000000..e4b046e
--- /dev/null
+++ b/bin/dev-browser
@@ -0,0 +1,10 @@
+#!/usr/bin/env python3
+from pathlib import Path
+import sys
+
+sys.dont_write_bytecode = True
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+from browser import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/bin/dev-check b/bin/dev-check
new file mode 100755
index 0000000..1959eae
--- /dev/null
+++ b/bin/dev-check
@@ -0,0 +1,10 @@
+#!/usr/bin/env python3
+from pathlib import Path
+import sys
+
+sys.dont_write_bytecode = True
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+from project_check import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/docs/developer-workflow.md b/docs/developer-workflow.md
new file mode 100644
index 0000000..49ae9fb
--- /dev/null
+++ b/docs/developer-workflow.md
@@ -0,0 +1,146 @@
+# Developer workflow
+
+Run project commands from the relevant checkout. This setup supplies shared
+tools; each project keeps its own runtime version, package manager, dependencies,
+tasks, and credentials.
+
+## Activate new helpers in an existing installation
+
+After the changes are merged, update your persistent installation checkout
+and refresh its configuration links:
+
+```sh
+cd ~/linux-setup # Use your actual installation checkout
+git pull --ff-only
+./install.sh --config-only --skip-mcp
+exec zsh
+```
+
+Use the persistent checkout for installation, rather than a temporary review
+worktree. Configuration-only installation keeps existing tools and their versions.
+
+## Check a checkout
+
+```sh
+dev-check # Read-only checks for the current project
+dev-check ../another-project # Check another checkout
+dev-check --json # Machine-readable results
+dev-check --network # Also probe registry reachability
+linux-doctor # Check the terminal setup itself
+```
+
+`dev-check` inspects manifests, lockfiles, dependency markers, Git checkout status, and
+tool availability. A Compose filename enables a local Docker check. It does not
+install packages, start services, source dotenv files, or run project tasks.
+The optional npm registry probe requires `--network`.
+
+JSON `ready: true` and exit status 0 mean no failure was detected. Review warnings
+and skipped checks: PnP dependencies and remote Docker contexts can remain
+unverified. Follow the reported next steps in the project's own setup guide.
+
+## Find and review work
+
+| Command | Use |
+| --- | --- |
+| `rg --files` | List project files using ignore rules |
+| `fd config` | Find paths matching a name |
+| `rg -n 'pattern' src tests` | Search selected source directories |
+| `jq '.scripts' package.json` | Inspect JavaScript project tasks |
+| `git status --short --branch` | Review branch and local changes |
+| `git diff`, `git diff --staged` | Review pending and staged changes |
+| `git worktree list` | Locate other checkouts of the repository |
+| `lg` | Open the installed Git interface |
+
+## Run project tasks
+
+Use the repository's pinned runtime and package manager. `vp` supports the
+JavaScript workflow; projects can also call their selected manager directly.
+Examples below assume those task names exist:
+
+```sh
+vp install # Install the project's dependencies
+vp run dev
+vp run test
+vp run typecheck
+pnpm env:check # When the project defines this Varlock task
+uv sync # Install a Python project's locked environment
+uv run pytest # When pytest belongs to that project
+```
+
+Keep Varlock schemas and application-specific scripts in their projects. A
+shared shell helper should not choose new dependency versions or copy a
+project's secrets into global configuration. In this repository, the Python
+check is `python3 -m unittest discover -s tests -v`; see
+[development checks](development.md) for the complete maintenance workflow.
+
+## Prepare browser tests
+
+Install the project's dependencies first so its pinned Playwright package is
+available. From that project, use:
+
+```sh
+dev-browser doctor
+dev-browser install --user-deps
+dev-browser run -- pnpm test:e2e
+```
+
+`doctor` reports installed paths and launches and closes headless Chromium to
+check that it works. `install` downloads the Chromium version selected by the project's Playwright package;
+it does not install or upgrade Playwright globally. Omit `--user-deps` when the
+host already provides the required libraries. The initial supported browser
+workflow is headless Chromium.
+
+On Ubuntu/Debian, `--user-deps` explicitly downloads and extracts APT packages
+into `${XDG_CACHE_HOME:-~/.cache}/linux-setup/browser-deps`. It does not use sudo
+or update APT indexes, so package indexes must already be available. Chromium
+itself uses Playwright's normal browser cache. Extracted libraries and fonts
+apply only to commands launched by the helper; your shell's global
+library configuration stays unchanged. `run` returns that command's exit code.
+
+Rootless installation accepts only the recognized Playwright dependency-report
+format. An unfamiliar or older dry-run format stops before APT downloads;
+follow the displayed administrator-assisted `playwright install-deps chromium`
+guidance instead. The helper does not upgrade Playwright to change its output.
+
+Replace `pnpm test:e2e` with the project's actual browser-test command. Use
+`dev-browser --project /path/to/project doctor` to target another checkout.
+The browser helper does not read dotenv files itself; it executes the project's
+installed Playwright and your chosen test command. On hosts without the supported
+APT workflow, use the administrator-managed system dependencies documented by
+[Playwright](https://playwright.dev/docs/browsers#install-system-dependencies).
+The optional browser dependency cache can be selected for removal
+in the uninstaller; ordinary uninstall keeps it.
+
+## Inspect local services
+
+```sh
+lip 3001 5173 # List listeners for an API and web server
+kip 5173 # Confirm before stopping the matching process
+docker compose ps # Inspect this project's Compose services
+db --connections # List redacted .env targets without connecting
+db .env.local --connections # Select a different dotenv file explicitly
+db --var TEST_DATABASE_URL # Open the interactive manager for that target
+```
+
+The database helper discovers `TEST_DATABASE_URL` and `TEST_DB_URL` alongside
+the usual `DATABASE_URL`/`DB_URL` names. It parses the selected file as data and
+does not source shell commands. `db --connections` is an inspection command;
+the interactive manager also offers database changes with confirmation. Keep
+test URLs pointed at disposable databases. Installing `psql` does not start a
+database server.
+
+## Optional personal shortcuts
+
+Add only the shortcuts you want to your private
+`~/.config/linux-setup-local/zshrc`, then open a new shell:
+
+```sh
+alias dcheck='dev-check'
+alias dbr='dev-browser'
+alias vr='vp run'
+```
+
+Keep credentials in the appropriate project's ignored files or configured
+secret provider. Paid UI registry keys belong only in their component-install
+workflow, with no `VITE_` prefix and no browser import. Use the project's
+redacted configuration checks when sharing diagnostics.
diff --git a/docs/uninstall.md b/docs/uninstall.md
index 8d15c56..7abe045 100644
--- a/docs/uninstall.md
+++ b/docs/uninstall.md
@@ -37,6 +37,7 @@ Node/Python runtimes, project dependencies, shell history, or databases.
./uninstall.sh --keep-tools
./uninstall.sh --remove bat --remove fd
./uninstall.sh --keep-tools --cache setup-downloads --dry-run
+./uninstall.sh --keep-tools --cache browser-deps --dry-run
./uninstall.sh --all-tools --dry-run
./uninstall.sh --all-tools
```
@@ -58,6 +59,7 @@ owned by the setup:
| --- | --- |
| `setup-downloads` | Verified archives downloaded by the installer |
| `setup-test-artifacts` | Extracted disposable PostgreSQL development-test binaries, if present |
+| `browser-deps` | Libraries and fonts extracted by `dev-browser install --user-deps`, if present |
| `zsh` | Known completion dump files; shell history is preserved |
| `uv` | The known uv dependency cache; installed Python versions and tools are preserved |
| `oh-my-posh` | Known prompt cache directory |
@@ -70,6 +72,13 @@ cache is not offered because its temporary/configured location cannot be inferre
safely from the setup's ownership records; see its
[cache configuration](https://yazi-rs.github.io/docs/configuration/yazi/#cache_dir).
+`browser-deps` refers only to
+`${XDG_CACHE_HOME:-~/.cache}/linux-setup/browser-deps`. It stays unchecked by
+default and can be removed independently of installed programs. Playwright's
+`ms-playwright` browser cache is preserved. Removing these extracted dependencies
+may require running `dev-browser install --user-deps` again for projects that use
+them; no legacy browser-cache paths are inferred or migrated for removal.
+
For a selected uv cache, the uninstaller runs `uv cache clean` against the listed
directory before removing uv itself. This coordinates access with other uv
processes; its directory/lock may remain after the cached entries are cleared.
diff --git a/scripts/browser.py b/scripts/browser.py
new file mode 100644
index 0000000..93b4ff4
--- /dev/null
+++ b/scripts/browser.py
@@ -0,0 +1,319 @@
+#!/usr/bin/env python3
+"""Use project-pinned Playwright with optional user-local Chromium libraries."""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import signal
+import subprocess
+import sys
+import tempfile
+from xml.sax.saxutils import escape
+
+
+class BrowserError(Exception):
+ pass
+
+
+PACKAGE = re.compile(r"[a-z0-9][a-z0-9+.-]*(?::[a-z0-9-]+)?\Z")
+VERSION = re.compile(r"[A-Za-z0-9.+:~_-]+\Z")
+RESOLVE = r"""
+const fs = require('node:fs');
+const path = require('node:path');
+const {createRequire} = require('node:module');
+const local = createRequire(path.join(process.cwd(), 'package.json'));
+const roots = [];
+for (let p = process.cwd();; p = path.dirname(p)) {
+ if (fs.existsSync(path.join(p, 'package.json')))
+ roots.push(path.join(p, 'node_modules') + path.sep);
+ if (p === path.dirname(p)) break;
+}
+for (const name of ['@playwright/test', 'playwright']) {
+ try {
+ const cli = local.resolve(name + '/cli');
+ const api = local.resolve(name);
+ if (![cli, api].every(p => roots.some(root => p.startsWith(root)))) continue;
+ const version = local(name + '/package.json').version;
+ const executable = local(name).chromium.executablePath();
+ console.log(JSON.stringify({cli, api, version, executable}));
+ process.exit(0);
+ } catch {}
+}
+process.exit(1);
+"""
+PROBE = r"""
+(async () => {
+ let browser;
+ try {
+ browser = await require(process.argv[1]).chromium.launch({headless: true, timeout: 15000});
+ await browser.close();
+ } catch (e) {
+ // Do not expose inherited environment or full browser command lines.
+ console.log(/Executable doesn't exist/.test(String(e)) ? 'missing-browser' : 'launch-failed');
+ process.exitCode = 1;
+ }
+})();
+"""
+
+
+def capture(args, cwd=None, env=None, timeout=60):
+ try:
+ process = subprocess.Popen(args, cwd=cwd, env=env, text=True, encoding="utf-8", errors="replace",
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ stdin=subprocess.DEVNULL, start_new_session=True)
+ except OSError:
+ raise BrowserError("A required command could not start; check Node, APT, and file permissions.") from None
+ try:
+ stdout, stderr = process.communicate(timeout=timeout)
+ return subprocess.CompletedProcess(args, process.returncode, stdout, stderr)
+ except (subprocess.TimeoutExpired, KeyboardInterrupt) as error:
+ try:
+ os.killpg(process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ try:
+ process.communicate(timeout=1)
+ except subprocess.TimeoutExpired:
+ process.stdout.close()
+ process.stderr.close()
+ if isinstance(error, KeyboardInterrupt):
+ raise
+ raise BrowserError("A browser setup check timed out; retry or inspect the host tools.") from None
+
+
+def resolve_project(project):
+ project = project.expanduser().resolve()
+ if not project.is_dir():
+ raise BrowserError("Project directory does not exist.")
+ node = shutil.which("node")
+ if not node:
+ raise BrowserError("Node is unavailable. Activate the project's pinned Node runtime first.")
+ result = capture([node, "-e", RESOLVE], cwd=project, timeout=20)
+ try:
+ info = json.loads(result.stdout)
+ if result.returncode or not all(isinstance(info[k], str) and info[k] for k in ("cli", "api", "version", "executable")):
+ raise ValueError
+ if not VERSION.fullmatch(info["version"]):
+ raise ValueError
+ except (ValueError, KeyError, TypeError):
+ raise BrowserError("No usable project-local Playwright found. Install this project's locked dependencies first.") from None
+ return dict(info, node=node, project=project)
+
+
+def dependency_packages(output, returncode):
+ """Fail closed when Playwright changes its human-readable dry-run format."""
+ if returncode == 0 and output.strip() == "All system dependencies are installed.":
+ return []
+ lines = output.strip().splitlines()
+ match = re.fullmatch(r"Missing system dependencies \(([1-9][0-9]*)\):", lines[0]) if lines else None
+ packages = [line[2:] for line in lines[1:] if line.startswith(" ")]
+ if (returncode != 1 or not match or len(packages) != int(match[1]) or
+ len(lines) != len(packages) + 1 or len(set(packages)) != len(packages) or
+ not all(PACKAGE.fullmatch(p) for p in packages)):
+ raise BrowserError("Cannot interpret this Playwright dependency report. Use the project's Playwright install-deps chromium with administrator support; no packages were installed.")
+ return packages
+
+
+def apt_plan(output):
+ specs = []
+ for line in output.splitlines():
+ if line.startswith("Remv "):
+ raise BrowserError("APT would remove packages. Repair the host package state before continuing.")
+ if not line.startswith("Inst "):
+ continue
+ match = re.match(r"^Inst (\S+)(?: \[[^\]]+\])? \((\S+) ", line)
+ if not match or not PACKAGE.fullmatch(match[1]) or not VERSION.fullmatch(match[2]):
+ raise BrowserError("Cannot interpret the APT package plan.")
+ if match[1].split(":")[0] in ("libc6", "libc-bin", "libc6-dev", "libc-dev-bin"):
+ raise BrowserError("These dependencies require a host glibc update. Ask the host administrator to install the system dependencies.")
+ specs.append(match[1] + "=" + match[2])
+ return sorted(set(specs))
+
+
+def cache_directory(info):
+ try:
+ distro = Path("/etc/os-release").read_bytes()
+ except OSError:
+ distro = b"unknown"
+ identity = hashlib.sha256(distro + os.uname().machine.encode() + info["version"].encode()).hexdigest()[:20]
+ root = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))).expanduser().absolute()
+ return root / "linux-setup" / "browser-deps" / identity
+
+
+def checked_path(path):
+ # Refuse redirected cache ancestors, including dangling symlinks.
+ if any(p.is_symlink() for p in [path, *path.parents]):
+ raise BrowserError("Browser dependency cache contains a symlink; choose a regular XDG_CACHE_HOME directory.")
+
+
+def active_runtime(directory):
+ checked_path(directory)
+ active = directory / "active.json"
+ if not active.exists():
+ if active.is_symlink():
+ raise BrowserError("Invalid browser dependency cache selector.")
+ return None
+ checked_path(active)
+ try:
+ entry = json.loads(active.read_text())
+ digest = entry["runtime"]
+ if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
+ raise ValueError
+ runtime = directory / digest
+ checked_path(runtime)
+ marker = runtime / "ready.json"
+ checked_path(marker)
+ manifest = json.loads(marker.read_text())
+ payload = runtime / "root"
+ checked_path(payload)
+ if not isinstance(manifest, dict) or manifest.get("format") != 1 or not payload.is_dir():
+ raise ValueError
+ return runtime
+ except (OSError, ValueError, TypeError, KeyError):
+ raise BrowserError("Browser dependency cache is incomplete. Inspect it and rerun install --user-deps.") from None
+
+
+def runtime_environment(runtime, original=None):
+ environment = dict(os.environ if original is None else original)
+ if runtime:
+ checked_path(runtime)
+ payload = runtime / "root"
+ directories = [payload / "lib", payload / "usr/lib"]
+ for base in list(directories):
+ directories.extend(sorted(base.glob("*-linux-gnu*")))
+ libraries = [str(p) for p in directories if p.is_dir()]
+ for p in libraries:
+ checked_path(Path(p))
+ if libraries:
+ if environment.get("LD_LIBRARY_PATH"):
+ libraries.append(environment["LD_LIBRARY_PATH"])
+ environment["LD_LIBRARY_PATH"] = ":".join(libraries)
+ fonts = runtime / "fonts.conf"
+ checked_path(fonts)
+ if fonts.is_file():
+ environment["FONTCONFIG_FILE"] = str(fonts)
+ return environment
+
+
+def install_user_dependencies(info, directory):
+ if not all(shutil.which(name) for name in ("apt-get", "dpkg-deb")):
+ raise BrowserError("User dependencies require Ubuntu/Debian APT and dpkg-deb. Use Playwright's system dependency installation on this host.")
+ environment = dict(os.environ, LC_ALL="C")
+ report = capture([info["node"], info["cli"], "install-deps", "--dry-run", "chromium"], cwd=info["project"], env=environment)
+ packages = dependency_packages(report.stdout, report.returncode)
+ if not packages:
+ if active_runtime(directory):
+ (directory / "active.json").unlink()
+ print("System dependencies already installed.")
+ return
+ plan = capture(["apt-get", "-s", "--no-install-recommends", "install", *packages], env=environment)
+ if plan.returncode:
+ raise BrowserError("APT could not resolve dependencies. Ask the host administrator to check package indexes and repositories.")
+ specs = apt_plan(plan.stdout)
+ if not specs:
+ raise BrowserError("APT returned no dependency packages; no cache was changed.")
+ digest = hashlib.sha256("\n".join(specs).encode()).hexdigest()
+ checked_path(directory)
+ directory.mkdir(parents=True, exist_ok=True)
+ target = directory / digest
+ checked_path(target)
+ if target.exists():
+ marker = target / "ready.json"
+ checked_path(marker)
+ try:
+ manifest = json.loads(marker.read_text())
+ except (OSError, ValueError):
+ manifest = {}
+ checked_path(target / "root")
+ if manifest != {"format": 1, "packages": specs} or not (target / "root").is_dir():
+ raise BrowserError("An unrecognized dependency cache already occupies this path; retained without changes.")
+ else:
+ print(f"Preparing {len(specs)} APT packages in the user cacheā¦", flush=True)
+ with tempfile.TemporaryDirectory(prefix=".prepare-", dir=directory) as temporary:
+ staging = Path(temporary)
+ downloads = staging / "downloads"
+ downloads.mkdir()
+ result = capture(["apt-get", "download", *specs], cwd=downloads, env=environment, timeout=600)
+ archives = sorted(downloads.glob("*.deb"))
+ if result.returncode or len(archives) != len(specs):
+ raise BrowserError("APT download failed; the previous runtime was kept. Check repository access and package indexes.")
+ prepared = staging / "runtime"
+ payload = prepared / "root"
+ payload.mkdir(parents=True)
+ for archive in archives:
+ if capture(["dpkg-deb", "-x", str(archive), str(payload)], timeout=60).returncode:
+ raise BrowserError("Package extraction failed; the previous runtime was kept.")
+ # Debian /usr merge packages may contain absolute symlinks. Only use
+ # real library directories in runtime_environment, never those aliases.
+ for base in (payload / "lib", payload / "lib64"):
+ if base.is_symlink():
+ base.unlink()
+ fonts = target / "root/usr/share/fonts"
+ (prepared / "fonts.conf").write_text(
+ '\n'
+ '/etc/fonts/fonts.conf'
+ f'{escape(str(fonts))}{escape(str(target / "font-cache"))}\n')
+ (prepared / "ready.json").write_text(json.dumps({"format": 1, "packages": specs}) + "\n")
+ prepared.rename(target)
+ # Publish a complete runtime only. Unique temporary names also avoid clashes
+ # between independent installs selecting their completed package set.
+ with tempfile.NamedTemporaryFile(mode="w", dir=directory, prefix=".active-", delete=False) as stream:
+ json.dump({"runtime": digest}, stream)
+ temporary = Path(stream.name)
+ temporary.replace(directory / "active.json")
+
+
+def doctor(info, environment):
+ print(f"Project: {info['project']}\nPlaywright: {info['version']}\nCLI: {info['cli']}\nChromium: {info['executable']}")
+ probe = capture([info["node"], "-e", PROBE, info["api"]], cwd=info["project"], env=environment, timeout=25)
+ if probe.returncode:
+ if probe.stdout.strip() == "missing-browser":
+ raise BrowserError("Chromium is missing. Run dev-browser install.")
+ raise BrowserError("Chromium could not launch. Run dev-browser install --user-deps on Ubuntu/Debian, or inspect the host's browser dependencies and sandbox support.")
+ print("PASS: headless Chromium launched and closed successfully.")
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--project", type=Path, default=Path.cwd(), help="project directory (default: current directory)")
+ commands = parser.add_subparsers(dest="action", required=True)
+ commands.add_parser("doctor", help="check the pinned browser with a local headless launch")
+ install = commands.add_parser("install", help="install project-pinned Chromium")
+ install.add_argument("--user-deps", action="store_true", help="extract Ubuntu/Debian dependencies into the user cache")
+ run = commands.add_parser("run", help="run a command with cached browser libraries")
+ run.add_argument("command", nargs=argparse.REMAINDER)
+ args = parser.parse_args(argv)
+ try:
+ info = resolve_project(args.project)
+ directory = cache_directory(info)
+ if args.action == "install":
+ if args.user_deps:
+ install_user_dependencies(info, directory)
+ environment = runtime_environment(active_runtime(directory))
+ result = subprocess.run([info["node"], info["cli"], "install", "chromium"], cwd=info["project"], env=environment, check=False)
+ if result.returncode:
+ return result.returncode if result.returncode > 0 else 128 - result.returncode
+ doctor(info, environment)
+ elif args.action == "doctor":
+ doctor(info, runtime_environment(active_runtime(directory)))
+ else:
+ command = args.command[1:] if args.command[:1] == ["--"] else args.command
+ if not command:
+ parser.error("run requires a command, for example: run -- pnpm test:e2e")
+ environment = runtime_environment(active_runtime(directory))
+ os.chdir(info["project"])
+ os.execvpe(command[0], command, environment)
+ return 0
+ except (BrowserError, OSError) as error:
+ message = str(error) if isinstance(error, BrowserError) else "A command or cache file could not be accessed. Check installed tools and permissions."
+ print("dev-browser: " + message, file=sys.stderr)
+ return 1
+ except KeyboardInterrupt:
+ return 130
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/db_manager.py b/scripts/db_manager.py
index 6a7dd9e..6d1f467 100644
--- a/scripts/db_manager.py
+++ b/scripts/db_manager.py
@@ -160,7 +160,7 @@ def load_connections(path, variables=None):
urls = re.split(r"\s+|,(?=postgres(?:ql)?://)", value)
connections += [parse_connection(key if len(urls) == 1 else f"{key}.{i + 1}", url) for i, url in enumerate(urls)]
if not connections:
- raise DBError("No PostgreSQL URLs found. Set DB_URL, DATABASE_URL, DB_URLS, or use --var NAME")
+ raise DBError("No PostgreSQL URLs found. Set DB_URL, DATABASE_URL, TEST_DB_URL, TEST_DATABASE_URL, DB_URLS, or use --var NAME")
return connections
@@ -422,7 +422,7 @@ def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("file", nargs="?", default=".env", help="Dotenv file (default: .env)")
parser.add_argument("-f", "--env-file", help="Explicit dotenv file path")
- parser.add_argument("--var", action="append", help="Read only this variable; repeat for multiple variables")
+ parser.add_argument("--var", action="append", help="Read only this variable; repeat for multiple variables. By default, discover URL names including DB_URL, DATABASE_URL, TEST_DB_URL, and TEST_DATABASE_URL")
parser.add_argument("--maintenance-db", default="postgres", help="Database for server-level operations")
parser.add_argument("--connections", action="store_true", help="List redacted connection labels without connecting")
args = parser.parse_args(argv)
diff --git a/scripts/project_check.py b/scripts/project_check.py
new file mode 100644
index 0000000..15e7ef8
--- /dev/null
+++ b/scripts/project_check.py
@@ -0,0 +1,338 @@
+"""Read-only checks for a project's local development prerequisites."""
+import argparse
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import signal
+import ssl
+import subprocess
+import sys
+import urllib.error
+import urllib.request
+
+
+COMPOSE_FILES = ("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml")
+LOCKFILES = {
+ "pnpm": ("pnpm-lock.yaml",),
+ "npm": ("package-lock.json", "npm-shrinkwrap.json"),
+ "yarn": ("yarn.lock",),
+ "bun": ("bun.lock", "bun.lockb"),
+}
+PACKAGE_NAME = re.compile(r"^(?:@[a-zA-Z0-9_.-]+/)?[a-zA-Z0-9_][a-zA-Z0-9_.-]*$")
+
+
+def clean(value):
+ return "".join(character if character.isprintable() else "?" for character in str(value))
+
+
+def result(check, status, message, hint=""):
+ return {"check": check, "status": status, "message": message, "hint": hint}
+
+
+def command_environment():
+ """Preserve proxy/CA settings without loading dotenv or redirecting Git."""
+ environment = os.environ.copy()
+ for name in list(environment):
+ if name.startswith("GIT_TRACE") or name in (
+ "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE",
+ "COMPOSE_ENV_FILES", "COMPOSE_FILE", "COMPOSE_PROJECT_NAME",
+ ):
+ environment.pop(name, None)
+ environment["GIT_OPTIONAL_LOCKS"] = "0"
+ environment["COMPOSE_DISABLE_ENV_FILE"] = "true"
+ return environment
+
+
+def run_command(arguments, *, timeout=5, environment=None):
+ """Return (code, bounded output, problem); never return raw stderr."""
+ try:
+ process = subprocess.Popen(
+ arguments, cwd="/", env=environment or command_environment(),
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
+ text=True, encoding="utf-8", errors="replace", start_new_session=True,
+ )
+ except OSError:
+ return None, "", "unavailable"
+ try:
+ output, _ = process.communicate(timeout=timeout)
+ return process.returncode, output[:4096], None
+ except (subprocess.TimeoutExpired, KeyboardInterrupt) as error:
+ try:
+ os.killpg(process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ # Kill the group, including a CLI plugin still holding the output pipe.
+ try:
+ process.communicate(timeout=1)
+ except subprocess.TimeoutExpired:
+ if process.stdout:
+ process.stdout.close()
+ if isinstance(error, KeyboardInterrupt):
+ raise
+ return None, "", "timeout"
+
+
+def git_root(directory, checks):
+ git = shutil.which("git")
+ if not git:
+ checks.append(result("git", "warn", "Git is not on PATH.", "Install Git or restore your shell PATH."))
+ return None
+ code, output, problem = run_command([git, "-c", "core.fsmonitor=false", "-C", str(directory), "rev-parse", "--show-toplevel"])
+ if problem:
+ checks.append(result("git", "fail", "Git inspection timed out or could not run.", "Check the Git executable and checkout permissions."))
+ return None
+ if code == 0:
+ try:
+ root = Path(output.strip()).resolve()
+ if root == directory or root in directory.parents:
+ checks.append(result("git", "pass", "Inside a Git checkout."))
+ return root
+ except (OSError, RuntimeError, ValueError):
+ pass
+ checks.append(result("git", "warn", "No Git checkout was identified.", "Run inside the intended checkout, or initialize version control when appropriate."))
+ return None
+
+
+def read_manifest(path):
+ # A package.json symlink must not turn this check into an environment-file read.
+ if path.resolve().name.startswith(".env"):
+ raise ValueError("Environment-file manifest link")
+ if path.stat().st_size > 2 * 1024 * 1024:
+ raise ValueError("Manifest too large")
+ with path.open(encoding="utf-8") as stream:
+ manifest = json.load(stream)
+ if not isinstance(manifest, dict):
+ raise ValueError("Manifest must be an object")
+ return manifest
+
+
+def find_project(directory, boundary, checks):
+ ancestors = []
+ for candidate in (directory, *directory.parents):
+ ancestors.append(candidate)
+ if candidate == boundary:
+ break
+ package = next((parent / "package.json" for parent in ancestors if (parent / "package.json").is_file()), None)
+ if package is None:
+ checks.append(result("package", "warn", "No package.json found; JavaScript dependency checks are skipped.", "Pass a package or workspace directory if this is a JavaScript project."))
+ return None, boundary or directory, {}
+ try:
+ manifest = read_manifest(package)
+ except (OSError, ValueError, RuntimeError):
+ checks.append(result("package", "fail", "The nearest package.json is unreadable, invalid or unsafe to inspect.", "Fix the JSON object or file permissions; environment-file links are not followed."))
+ return package, package.parent, {}
+ manifests = {package.parent: manifest}
+ workspace = package.parent
+ for parent in ancestors[ancestors.index(package.parent):]:
+ candidate = parent / "package.json"
+ data = manifests.get(parent)
+ if data is None and candidate.is_file():
+ try:
+ data = read_manifest(candidate)
+ except (OSError, ValueError, RuntimeError):
+ checks.append(result("workspace", "fail", "An enclosing package.json could not be inspected.", "Fix its JSON or permissions before relying on workspace discovery."))
+ break
+ if (parent / "pnpm-workspace.yaml").is_file() or (isinstance(data, dict) and data.get("workspaces")):
+ workspace = parent
+ if data is not None:
+ manifests[parent] = data
+ break
+ checks.append(result("package", "pass", "Selected the nearest package and enclosing workspace."))
+ return package, workspace, manifests
+
+
+def tool_check(name, required, checks, directories=()):
+ available = shutil.which(name) or any(
+ (directory / "node_modules" / ".bin" / name).is_file()
+ and os.access(directory / "node_modules" / ".bin" / name, os.X_OK)
+ for directory in directories
+ )
+ if available:
+ checks.append(result(name, "pass", "Executable is available; no toolchain download or version command was attempted."))
+ elif required:
+ checks.append(result(name, "fail", "Required executable is unavailable.", "Install or activate " + name + " using the project's documented toolchain."))
+ else:
+ checks.append(result(name, "skip", "Not available and not required by the selected manifests."))
+
+
+def dependency_checks(package, workspace, manifests, checks):
+ if package is None or not manifests:
+ for name in ("node", "pnpm", "vp"):
+ tool_check(name, False, checks)
+ return
+ owner = manifests.get(workspace, manifests[package.parent])
+ declaration = owner.get("packageManager", "")
+ manager = declaration.split("@", 1)[0] if isinstance(declaration, str) else ""
+ found = {name: [workspace / filename for filename in filenames if (workspace / filename).is_file()]
+ for name, filenames in LOCKFILES.items()}
+ if manager not in LOCKFILES:
+ manager = "pnpm" if (workspace / "pnpm-workspace.yaml").is_file() else next((name for name, paths in found.items() if paths), "npm")
+ locks = found[manager]
+ if not locks or not any(path.stat().st_size for path in locks):
+ checks.append(result("lockfile", "fail", "A nonempty " + manager + " lockfile is missing.", "Run the reviewed dependency install in the workspace and commit its generated lockfile."))
+ else:
+ checks.append(result("lockfile", "pass", "The selected package manager has a nonempty lockfile."))
+ if sum(bool(paths) for paths in found.values()) > 1:
+ checks.append(result("lockfile-selection", "warn", "Multiple package-manager lockfiles exist.", "Confirm the intended manager before installing or changing lockfiles."))
+
+ dependencies = []
+ for parent, manifest in manifests.items():
+ for field in ("dependencies", "devDependencies"):
+ values = manifest.get(field, {})
+ if not isinstance(values, dict) or any(not isinstance(name, str) or not PACKAGE_NAME.fullmatch(name) for name in values):
+ checks.append(result("dependencies", "fail", "Dependency declarations are not valid package-name mappings.", "Correct dependencies/devDependencies in package.json."))
+ return
+ dependencies.extend((parent, name) for name in values)
+ tool_check("node", True, checks)
+ tool_check("pnpm", manager == "pnpm", checks)
+ if manager not in ("pnpm",):
+ tool_check(manager, True, checks)
+ tool_check("vp", any(name == "vite-plus" for _, name in dependencies), checks, (package.parent, workspace))
+ if (workspace / ".pnp.cjs").is_file() or (workspace / ".pnp.js").is_file():
+ checks.append(result("dependencies", "skip", "Yarn PnP was detected; its loader was not executed.", "Use the project's own dependency verification separately."))
+ return
+ if not dependencies:
+ checks.append(result("dependencies", "skip", "No direct dependencies are declared."))
+ return
+ missing = 0
+ for parent, name in dependencies:
+ candidates = []
+ for directory in (parent, *parent.parents):
+ candidates.append(directory / "node_modules" / name / "package.json")
+ if directory == workspace:
+ break
+ if not any(candidate.is_file() for candidate in candidates):
+ missing += 1
+ if missing:
+ checks.append(result("dependencies", "fail", str(missing) + " declared direct package(s) are absent from node_modules.", "Run the reviewed install in the workspace; an empty node_modules directory is insufficient."))
+ else:
+ checks.append(result("dependencies", "pass", "Declared direct packages are present; builds and transitive compatibility were not checked."))
+
+
+def docker_checks(directory, workspace, checks, boundary=None):
+ candidates = []
+ for parent in (directory, *directory.parents):
+ candidates.append(parent)
+ if parent == (boundary or workspace):
+ break
+ compose = next((parent / name for parent in candidates
+ for name in COMPOSE_FILES if (parent / name).is_file()), None)
+ if compose is None:
+ checks.append(result("docker", "skip", "No standard Compose file detected at the project/workspace root."))
+ return
+ checks.append(result("compose-file", "pass", "Compose configuration detected; contents and environment files were not loaded."))
+ docker = shutil.which("docker")
+ if not docker:
+ checks.append(result("docker", "fail", "Docker is unavailable for this Compose project.", "Install Docker/Compose, or use the project's documented external-service alternative."))
+ return
+ environment = command_environment()
+ code, _, problem = run_command([docker, "compose", "version", "--short"], environment=environment)
+ if code != 0 or problem:
+ checks.append(result("compose", "fail", "Docker Compose could not be queried.", "Check the Compose plugin; no Compose configuration was evaluated."))
+ else:
+ checks.append(result("compose", "pass", "Docker Compose is available."))
+ endpoint = environment.get("DOCKER_HOST")
+ if environment.get("DOCKER_CONTEXT") or not endpoint:
+ code, output, problem = run_command([docker, "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"], environment=environment)
+ if code != 0 or problem:
+ checks.append(result("docker-daemon", "fail", "Docker context inspection failed or timed out.", "Inspect your selected Docker context and local socket access."))
+ return
+ endpoint = output.strip()
+ if not endpoint.startswith("unix:///") or any(character.isspace() for character in endpoint):
+ checks.append(result("docker-daemon", "skip", "The selected Docker endpoint is not a local Unix socket; it was not contacted.", "Select a local Docker context to check it without remote access."))
+ return
+ for variable in ("DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_TLS", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH"):
+ environment.pop(variable, None)
+ code, _, problem = run_command([docker, "--host=" + endpoint, "info", "--format", "{{.ServerVersion}}"], timeout=8, environment=environment)
+ if code == 0 and not problem:
+ checks.append(result("docker-daemon", "pass", "The selected local Docker daemon responds."))
+ else:
+ checks.append(result("docker-daemon", "fail", "The local Docker daemon is unavailable, inaccessible or timed out.", "Check socket permissions and daemon health; on WSL enable Docker Desktop integration for this distro."))
+
+
+class NoRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, request, fp, code, message, headers, newurl):
+ return None
+
+
+def registry_probe():
+ """Runs in a separately bounded child so even DNS cannot hang the CLI."""
+ try:
+ context = ssl.create_default_context()
+ for name in ("NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"):
+ if os.environ.get(name):
+ context.load_verify_locations(cafile=os.path.expanduser(os.environ[name]))
+ opener = urllib.request.build_opener(
+ urllib.request.ProxyHandler(), urllib.request.HTTPSHandler(context=context), NoRedirect(),
+ )
+ request = urllib.request.Request("https://registry.npmjs.org/-/ping", headers={"User-Agent": "linux-setup/dev-check"})
+ with opener.open(request, timeout=6) as response:
+ return 0 if response.status == 200 else 1
+ except (OSError, ValueError, urllib.error.URLError):
+ return 1
+
+
+def check_project(path=".", *, network=False):
+ checks = []
+ try:
+ directory = Path(path).expanduser().resolve(strict=True)
+ if directory.is_file():
+ directory = directory.parent
+ if not directory.is_dir():
+ raise ValueError("Not a directory")
+ except (OSError, RuntimeError, ValueError):
+ return {"project": None, "package": None, "workspace": None, "ready": False,
+ "checks": [result("path", "fail", "The requested project path is unavailable.", "Pass an existing readable project directory.")]}
+ boundary = git_root(directory, checks)
+ package, workspace, manifests = find_project(directory, boundary, checks)
+ dependency_checks(package, workspace, manifests, checks)
+ docker_checks(directory, workspace, checks, boundary)
+ if shutil.which("psql"):
+ checks.append(result("psql", "pass", "PostgreSQL client is available; no database connection was attempted."))
+ else:
+ checks.append(result("psql", "warn", "Optional PostgreSQL client is not on PATH.", "Install postgresql-client if you need host-side database inspection."))
+ if network:
+ code, _, problem = run_command([sys.executable, "-I", "-B", str(Path(__file__).resolve()), "--_registry-probe"], timeout=10)
+ checks.append(result("registry", "pass" if code == 0 and not problem else "fail",
+ "npm registry HTTPS probe succeeded." if code == 0 and not problem else "npm registry HTTPS probe failed or timed out.",
+ "" if code == 0 and not problem else "Check DNS, proxy access and configured CA certificates; keep TLS verification enabled."))
+ else:
+ checks.append(result("registry", "skip", "External network probe disabled.", "Use --network to check npm registry HTTPS explicitly."))
+ return {"project": str(directory), "package": str(package) if package else None,
+ "workspace": str(workspace), "ready": not any(item["status"] == "fail" for item in checks), "checks": checks}
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("path", nargs="?", default=".", metavar="PATH")
+ parser.add_argument("--network", action="store_true", help="Explicitly probe npm registry HTTPS using configured proxy/CA trust")
+ parser.add_argument("--json", action="store_true", help="Print structured check results")
+ arguments = parser.parse_args(argv)
+ try:
+ report = check_project(arguments.path, network=arguments.network)
+ except (OSError, RuntimeError, ValueError):
+ report = {"project": None, "package": None, "workspace": None, "ready": False,
+ "checks": [result("inspection", "fail", "Project inspection could not finish.", "Check file permissions and executable availability; no raw command output was printed.")]}
+ except KeyboardInterrupt:
+ print("dev-check: interrupted.", file=sys.stderr)
+ return 130
+ if arguments.json:
+ print(json.dumps(report))
+ else:
+ if report["project"]:
+ print("Project: " + clean(report["project"]))
+ if report["workspace"] != report["project"]:
+ print("Workspace: " + clean(report["workspace"]))
+ for item in report["checks"]:
+ print(f"{item['status'].upper():<4} {item['check']}: {item['message']}")
+ if item["hint"]:
+ print(" " + item["hint"])
+ return 0 if report["ready"] else 1
+
+
+if __name__ == "__main__":
+ if sys.argv[1:] == ["--_registry-probe"]:
+ sys.exit(registry_probe())
+ sys.exit(main())
diff --git a/scripts/smoke-install.py b/scripts/smoke-install.py
index 244f9ec..29711c3 100644
--- a/scripts/smoke-install.py
+++ b/scripts/smoke-install.py
@@ -34,12 +34,18 @@ def run(*command):
subprocess.run(list(map(str, command)), cwd=ROOT, env=environment, check=True)
run(ROOT / "install.sh", "--skip-mcp")
run(ROOT / "install.sh", "--check")
+ run("dev-check", "--help")
+ run("dev-browser", "--help")
run(ROOT / "uninstall.sh", "--keep-tools", "--yes")
+ for helper in ("dev-check", "dev-browser"):
+ assert not (root / ".local/bin" / helper).is_symlink(), "Configuration uninstall left " + helper
# Retained rootless Zsh must load modules without the setup's .zshenv.
run("zsh", "-c", "zmodload zsh/parameter && autoload -Uz compinit && compinit -i -D")
run("uv", "--version")
run("vp", "--version")
run(ROOT / "install.sh", "--config-only", "--skip-mcp")
+ run("dev-check", "--help")
+ run("dev-browser", "--help")
cache = root / ".cache/bat"
cache.mkdir(parents=True, exist_ok=True)
(cache / "smoke-cache").write_text("disposable test cache")
diff --git a/scripts/uninstall.py b/scripts/uninstall.py
index 382a6d7..a0a5950 100755
--- a/scripts/uninstall.py
+++ b/scripts/uninstall.py
@@ -107,6 +107,7 @@ def cache_options(programs):
candidates = {
"setup-downloads": {"paths": [setup_cache / "downloads"], "label": "Verified setup download archives", "shared": False},
"setup-test-artifacts": {"paths": [setup_cache / "postgres-test"], "label": "Disposable PostgreSQL development-test binaries", "shared": False},
+ "browser-deps": {"paths": [setup.CACHE / "browser-deps"], "label": "Extracted browser libraries and fonts (shared by projects)", "shared": True},
}
owned = {name for name, item in programs.items() if item.get("owned")}
for name, directory in [("uv", "uv"), ("oh-my-posh", "oh-my-posh"), ("bat", "bat")]:
@@ -159,6 +160,7 @@ def safe_cache_path(path, cache_id=None):
return False
exact = {
"setup-downloads": setup.CACHE / "downloads", "setup-test-artifacts": setup.CACHE / "postgres-test",
+ "browser-deps": setup.CACHE / "browser-deps",
"uv": root / "uv", "oh-my-posh": root / "oh-my-posh", "bat": root / "bat",
}
if cache_id == "zsh":
diff --git a/tests/test_browser.py b/tests/test_browser.py
new file mode 100644
index 0000000..6b71ef5
--- /dev/null
+++ b/tests/test_browser.py
@@ -0,0 +1,320 @@
+import contextlib
+import hashlib
+import importlib.util
+import io
+import json
+import os
+from pathlib import Path
+import shutil
+import signal
+import subprocess
+import tempfile
+import unittest
+from unittest.mock import Mock, call, patch
+
+
+SPEC = importlib.util.spec_from_file_location("browser", Path(__file__).resolve().parents[1] / "scripts/browser.py")
+browser = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(browser)
+
+
+def result(code=0, stdout="", stderr=""):
+ return subprocess.CompletedProcess([], code, stdout, stderr)
+
+
+class DependencyReportTests(unittest.TestCase):
+ def test_exact_playwright_reports_accept_installed_and_missing_packages(self):
+ self.assertEqual(browser.dependency_packages("All system dependencies are installed.\n", 0), [])
+ self.assertEqual(browser.dependency_packages(
+ "Missing system dependencies (3):\n libasound2t64\n libx11-6:amd64\n fonts-liberation\n", 1),
+ ["libasound2t64", "libx11-6:amd64", "fonts-liberation"])
+
+ def test_changed_counts_packages_format_and_exit_codes_fail_closed(self):
+ reports = [
+ ("", 1),
+ ("Missing system dependencies (0):", 1),
+ ("Missing system dependencies (02):\n liba\n libb", 1),
+ ("Missing system dependencies (2):\n liba", 1),
+ ("Missing system dependencies (1):\n liba\n libb", 1),
+ ("Missing system dependencies (2):\n liba\n liba", 1),
+ ("Missing system dependencies (1):\n --unsafe-option", 1),
+ ("Missing system dependencies (1):\n liba;unexpected", 1),
+ ("Missing system dependencies (1):\n liba extra", 1),
+ ("Missing system dependencies (1):\n liba", 1),
+ ("Missing system dependencies (1):\n liba\nunknown output", 1),
+ ("sudo apt-get install liba", 0),
+ ("All system dependencies are installed.", 1),
+ ("Missing system dependencies (1):\n liba", 0),
+ ("Missing system dependencies (1):\n liba", 2),
+ ]
+ for output, code in reports:
+ with self.subTest(output=output, code=code):
+ with self.assertRaisesRegex(browser.BrowserError, "Cannot interpret"):
+ browser.dependency_packages(output, code)
+
+ def test_apt_fresh_and_upgrade_rows_produce_exact_deduplicated_specs(self):
+ output = (
+ "Reading package lists...\n"
+ "Inst libnss3 [2:3.110-1] (2:3.112-1 Ubuntu:26.04 [amd64])\n"
+ "Inst libatk1.0-0t64 (2.58.0-1 Ubuntu:26.04 [amd64])\n"
+ "Inst libx11-6:amd64 (2:1.8.12-1 Ubuntu:26.04 [amd64])\n"
+ "Inst libatk1.0-0t64 (2.58.0-1 Ubuntu:26.04 [amd64])\n"
+ "Conf libnss3 (2:3.112-1 Ubuntu:26.04 [amd64])\n"
+ )
+ self.assertEqual(browser.apt_plan(output), [
+ "libatk1.0-0t64=2.58.0-1", "libnss3=2:3.112-1", "libx11-6:amd64=2:1.8.12-1"])
+
+ def test_apt_refuses_removal_malformed_rows_and_core_glibc_changes(self):
+ for package in ["libc6", "libc6:amd64", "libc-bin", "libc6-dev", "libc-dev-bin"]:
+ with self.subTest(package=package):
+ with self.assertRaisesRegex(browser.BrowserError, "glibc"):
+ browser.apt_plan(f"Inst {package} [2.41-1] (2.42-1 Ubuntu [amd64])")
+ for row in ["Remv libold [1.0]", "Inst liba", "Inst --unsafe (1.0 Ubuntu [amd64])", "Inst liba (../bad Ubuntu [amd64])"]:
+ with self.subTest(row=row):
+ with self.assertRaises(browser.BrowserError):
+ browser.apt_plan(row)
+
+
+class BrowserFixture(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.root = Path(self.temporary.name)
+ self.directory = self.root / "cache"
+ self.info = {
+ "project": self.root, "node": "/fixture/node", "cli": "/fixture/project/node_modules/playwright/cli.js",
+ "api": "/fixture/project/node_modules/playwright/index.js", "version": "1.64.1", "executable": "/fixture/chromium",
+ }
+ # Mocked subprocess arguments must never contain the developer's real secrets.
+ environment = patch.dict(os.environ, {"PATH": "/fixture/bin", "KEEP_ME": "original"}, clear=True)
+ environment.start()
+ self.addCleanup(environment.stop)
+
+ def ready_runtime(self, digest="a" * 64):
+ runtime = self.directory / digest
+ (runtime / "root/usr/lib").mkdir(parents=True)
+ (runtime / "ready.json").write_text(json.dumps({"format": 1, "packages": ["libold=1.0"]}))
+ (self.directory / "active.json").write_text(json.dumps({"runtime": digest}))
+ return runtime
+
+
+class CacheTests(BrowserFixture):
+ def test_missing_selector_means_no_cached_runtime(self):
+ self.assertIsNone(browser.active_runtime(self.directory))
+
+ def test_active_selector_cannot_escape_cache_or_name_an_incomplete_runtime(self):
+ runtime = self.ready_runtime()
+ active = self.directory / "active.json"
+ for entry in ["invalid json", "[]", "{}", '{"runtime":"../outside"}', '{"runtime":17}', json.dumps({"runtime": "b" * 64})]:
+ with self.subTest(entry=entry):
+ active.write_text(entry)
+ with self.assertRaises(browser.BrowserError):
+ browser.active_runtime(self.directory)
+ active.write_text(json.dumps({"runtime": runtime.name}))
+ for marker in ["invalid json", "[]", "null", "{}", '{"format":2}']:
+ with self.subTest(marker=marker):
+ (runtime / "ready.json").write_text(marker)
+ with self.assertRaises(browser.BrowserError):
+ browser.active_runtime(self.directory)
+
+ def test_ready_marker_without_payload_is_incomplete(self):
+ runtime = self.ready_runtime()
+ shutil.rmtree(runtime / "root")
+ with self.assertRaisesRegex(browser.BrowserError, "incomplete"):
+ browser.active_runtime(self.directory)
+
+ def test_symlinked_selector_runtime_and_cache_ancestor_are_rejected(self):
+ runtime = self.ready_runtime()
+ outside = self.root / "outside"
+ outside.mkdir()
+ selector = self.directory / "active.json"
+ selector.unlink()
+ selector.symlink_to(outside / "missing.json")
+ with self.assertRaises(browser.BrowserError):
+ browser.active_runtime(self.directory)
+ selector.unlink()
+ selector.write_text(json.dumps({"runtime": runtime.name}))
+ shutil.rmtree(runtime)
+ runtime.symlink_to(outside, target_is_directory=True)
+ with self.assertRaises(browser.BrowserError):
+ browser.active_runtime(self.directory)
+ linked_parent = self.root / "linked-cache"
+ linked_parent.symlink_to(self.directory, target_is_directory=True)
+ with self.assertRaises(browser.BrowserError):
+ browser.active_runtime(linked_parent / "child")
+
+ def test_runtime_environment_is_a_scoped_copy_and_preserves_existing_libraries(self):
+ runtime = self.ready_runtime()
+ (runtime / "root/lib").mkdir()
+ multiarch = runtime / "root/usr/lib/x86_64-linux-gnu"
+ multiarch.mkdir()
+ fonts = runtime / "fonts.conf"
+ fonts.write_text("")
+ original = {"KEEP_ME": "original", "LD_LIBRARY_PATH": "/existing/lib", "FONTCONFIG_FILE": "/existing/fonts.conf"}
+ environment = browser.runtime_environment(runtime, original)
+ self.assertIsNot(environment, original)
+ self.assertEqual(environment["LD_LIBRARY_PATH"].split(":"), [
+ str(runtime / "root/lib"), str(runtime / "root/usr/lib"), str(multiarch), "/existing/lib"])
+ self.assertEqual(environment["FONTCONFIG_FILE"], str(fonts))
+ self.assertEqual(environment["KEEP_ME"], "original")
+ self.assertEqual(original["LD_LIBRARY_PATH"], "/existing/lib")
+ self.assertEqual(original["FONTCONFIG_FILE"], "/existing/fonts.conf")
+ self.assertNotIn("LD_LIBRARY_PATH", os.environ)
+ self.assertNotIn("FONTCONFIG_FILE", os.environ)
+
+ def test_symlinked_payload_library_is_not_added_to_child_environment(self):
+ runtime = self.ready_runtime()
+ outside = self.root / "outside-libraries"
+ outside.mkdir()
+ (runtime / "root/usr/lib/x86_64-linux-gnu").symlink_to(outside, target_is_directory=True)
+ with self.assertRaises(browser.BrowserError):
+ browser.runtime_environment(runtime)
+
+
+class InstallTests(BrowserFixture):
+ def test_unrecognized_playwright_report_does_not_run_apt_or_create_cache(self):
+ with patch.object(browser.shutil, "which", return_value="/fixture/tool"), patch.object(browser, "capture", return_value=result(0, "future format")) as capture:
+ with self.assertRaises(browser.BrowserError):
+ browser.install_user_dependencies(self.info, self.directory)
+ self.assertEqual(capture.call_count, 1)
+ self.assertFalse(self.directory.exists())
+
+ def test_glibc_plan_does_not_download_or_create_cache(self):
+ with patch.object(browser.shutil, "which", return_value="/fixture/tool"), patch.object(browser, "capture", side_effect=[
+ result(1, "Missing system dependencies (1):\n libc6"),
+ result(0, "Inst libc6 [2.41-1] (2.42-1 Ubuntu [amd64])"),
+ ]) as capture:
+ with self.assertRaisesRegex(browser.BrowserError, "glibc"):
+ browser.install_user_dependencies(self.info, self.directory)
+ self.assertEqual(capture.call_count, 2)
+ self.assertFalse(self.directory.exists())
+
+ def test_extraction_failure_keeps_previous_runtime_and_selector(self):
+ previous = self.ready_runtime()
+ selector = (self.directory / "active.json").read_bytes()
+
+ def capture(args, **kwargs):
+ if "install-deps" in args:
+ return result(1, "Missing system dependencies (1):\n libfixture")
+ if args[:2] == ["apt-get", "-s"]:
+ return result(0, "Inst libfixture (1.0 Ubuntu [amd64])")
+ if args[:2] == ["apt-get", "download"]:
+ (kwargs["cwd"] / "libfixture_1.0_amd64.deb").write_bytes(b"synthetic archive")
+ return result()
+ self.assertEqual(args[:2], ["dpkg-deb", "-x"])
+ return result(1, stderr="synthetic extraction failure")
+
+ with patch.object(browser.shutil, "which", return_value="/fixture/tool"), patch.object(browser, "capture", side_effect=capture), contextlib.redirect_stdout(io.StringIO()):
+ with self.assertRaisesRegex(browser.BrowserError, "previous runtime was kept"):
+ browser.install_user_dependencies(self.info, self.directory)
+ self.assertEqual((self.directory / "active.json").read_bytes(), selector)
+ self.assertEqual(browser.active_runtime(self.directory), previous)
+ expected_digest = hashlib.sha256(b"libfixture=1.0").hexdigest()
+ self.assertFalse((self.directory / expected_digest).exists())
+ self.assertEqual(list(self.directory.glob(".prepare-*")), [])
+
+
+class CommandTests(BrowserFixture):
+ def main_patches(self, stack):
+ stack.enter_context(patch.object(browser, "resolve_project", return_value=self.info))
+ stack.enter_context(patch.object(browser, "cache_directory", return_value=self.directory))
+ stack.enter_context(patch.object(browser, "active_runtime", return_value=None))
+
+ def test_run_preserves_arguments_and_delegates_process_exit_to_exec(self):
+ command = ["pnpm", "test:e2e", "--", "--project=chromium", "name with spaces"]
+ child_environment = {"PATH": "/fixture/bin", "SCOPED": "browser-only"}
+ with contextlib.ExitStack() as stack:
+ self.main_patches(stack)
+ stack.enter_context(patch.object(browser, "runtime_environment", return_value=child_environment))
+ chdir = stack.enter_context(patch.object(browser.os, "chdir"))
+ execute = stack.enter_context(patch.object(browser.os, "execvpe", side_effect=SystemExit(23)))
+ with self.assertRaises(SystemExit) as exit_status:
+ browser.main(["run", "--", *command])
+ self.assertEqual(exit_status.exception.code, 23)
+ chdir.assert_called_once_with(self.root)
+ execute.assert_called_once_with("pnpm", command, child_environment)
+ self.assertNotIn("SCOPED", os.environ)
+
+ def test_empty_run_is_usage_error_and_cannot_execute(self):
+ with contextlib.ExitStack() as stack:
+ self.main_patches(stack)
+ execute = stack.enter_context(patch.object(browser.os, "execvpe"))
+ stack.enter_context(contextlib.redirect_stderr(io.StringIO()))
+ with self.assertRaises(SystemExit) as exit_status:
+ browser.main(["run", "--"])
+ self.assertEqual(exit_status.exception.code, 2)
+ execute.assert_not_called()
+
+ def test_failed_exec_reports_generic_error_and_interrupt_returns_130(self):
+ for error, expected in [(OSError("synthetic-private-detail"), 1), (KeyboardInterrupt(), 130)]:
+ with self.subTest(expected=expected), contextlib.ExitStack() as stack:
+ self.main_patches(stack)
+ stack.enter_context(patch.object(browser.os, "chdir"))
+ stack.enter_context(patch.object(browser.os, "execvpe", side_effect=error))
+ stderr = stack.enter_context(contextlib.redirect_stderr(io.StringIO()))
+ self.assertEqual(browser.main(["run", "--", "test-command"]), expected)
+ self.assertNotIn("synthetic-private-detail", stderr.getvalue())
+
+ def test_browser_install_preserves_failed_child_exit_and_signal_status(self):
+ for code, expected in [(7, 7), (-15, 143)]:
+ with self.subTest(code=code), contextlib.ExitStack() as stack:
+ self.main_patches(stack)
+ run = stack.enter_context(patch.object(browser.subprocess, "run", return_value=result(code)))
+ doctor = stack.enter_context(patch.object(browser, "doctor"))
+ self.assertEqual(browser.main(["install"]), expected)
+ self.assertEqual(run.call_args.args[0], [self.info["node"], self.info["cli"], "install", "chromium"])
+ doctor.assert_not_called()
+
+ def test_doctor_distinguishes_missing_browser_and_launch_failure_without_raw_errors(self):
+ for output, message in [("missing-browser", "Chromium is missing"), ("launch-failed", "Chromium could not launch")]:
+ with self.subTest(output=output), patch.object(browser, "capture", return_value=result(1, output, "synthetic-private-detail")), contextlib.redirect_stdout(io.StringIO()):
+ with self.assertRaisesRegex(browser.BrowserError, message) as error:
+ browser.doctor(self.info, {"SCOPED": "value"})
+ self.assertNotIn("synthetic-private-detail", str(error.exception))
+
+ def test_doctor_performs_headless_probe_with_scoped_environment(self):
+ environment = {"SCOPED": "value"}
+ with patch.object(browser, "capture", return_value=result()) as capture, contextlib.redirect_stdout(io.StringIO()) as output:
+ browser.doctor(self.info, environment)
+ self.assertIn("headless Chromium launched and closed", output.getvalue())
+ self.assertEqual(capture.call_args.args[0], [self.info["node"], "-e", browser.PROBE, self.info["api"]])
+ self.assertIs(capture.call_args.kwargs["env"], environment)
+ self.assertEqual(capture.call_args.kwargs["cwd"], self.root)
+
+ def test_project_resolution_failures_do_not_dump_subprocess_output(self):
+ for stdout, code in [("synthetic-private-detail", 1), ("[]", 0), (json.dumps({**self.info, "project": str(self.root)}), 1)]:
+ with self.subTest(stdout=stdout), patch.object(browser.shutil, "which", return_value="/fixture/node"), patch.object(browser, "capture", return_value=result(code, stdout, "synthetic-private-detail")):
+ with self.assertRaisesRegex(browser.BrowserError, "project-local Playwright") as error:
+ browser.resolve_project(self.root)
+ self.assertNotIn("synthetic-private-detail", str(error.exception))
+
+ def test_capture_sanitizes_launch_errors(self):
+ with patch.object(browser.subprocess, "Popen", side_effect=OSError("synthetic-private-detail")):
+ with self.assertRaises(browser.BrowserError) as sanitized:
+ browser.capture(["fixture"])
+ self.assertNotIn("synthetic-private-detail", str(sanitized.exception))
+
+ def test_capture_timeout_kills_process_group_and_bounds_cleanup(self):
+ for cleanup_times_out in [False, True]:
+ process = Mock(pid=12345)
+ timeout = subprocess.TimeoutExpired("synthetic-private-detail", 5, output="synthetic-private-detail")
+ cleanup = subprocess.TimeoutExpired("fixture", 1) if cleanup_times_out else ("", "")
+ process.communicate.side_effect = [timeout, cleanup]
+ with self.subTest(cleanup_times_out=cleanup_times_out), patch.object(browser.subprocess, "Popen", return_value=process) as popen, patch.object(browser.os, "killpg") as killpg:
+ with self.assertRaisesRegex(browser.BrowserError, "timed out") as sanitized:
+ browser.capture(["fixture"], timeout=5)
+ self.assertNotIn("synthetic-private-detail", str(sanitized.exception))
+ self.assertTrue(popen.call_args.kwargs["start_new_session"])
+ killpg.assert_called_once_with(12345, signal.SIGKILL)
+ self.assertEqual(process.communicate.call_args_list, [call(timeout=5), call(timeout=1)])
+ self.assertEqual(process.stdout.close.call_count, int(cleanup_times_out))
+ self.assertEqual(process.stderr.close.call_count, int(cleanup_times_out))
+
+ def test_capture_interrupt_cleans_group_before_propagating(self):
+ process = Mock(pid=12345)
+ process.communicate.side_effect = [KeyboardInterrupt(), ("", "")]
+ with patch.object(browser.subprocess, "Popen", return_value=process), patch.object(browser.os, "killpg") as killpg:
+ with self.assertRaises(KeyboardInterrupt):
+ browser.capture(["fixture"], timeout=5)
+ killpg.assert_called_once_with(12345, signal.SIGKILL)
+ self.assertEqual(process.communicate.call_args_list, [call(timeout=5), call(timeout=1)])
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index e2f58b5..aa25aec 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -33,6 +33,12 @@ def test_explicit_variable_and_unrelated_settings(self):
connections = self.load('UNRELATED=${NOT_DEFINED}\nCONNECTION=postgres://localhost/app\n', ["CONNECTION"])
self.assertEqual(connections[0].database, "app")
+ def test_both_test_database_url_names_are_discovered_by_default(self):
+ connections = self.load('TEST_DB_URL=postgresql://fixture:synthetic-secret@localhost/legacy_test\nTEST_DATABASE_URL=postgresql://fixture:synthetic-secret@localhost/app_test\n')
+ self.assertEqual([c.name for c in connections], ["TEST_DB_URL", "TEST_DATABASE_URL"])
+ self.assertEqual([c.database for c in connections], ["legacy_test", "app_test"])
+ self.assertTrue(all("synthetic-secret" not in c.label for c in connections))
+
def test_commands_are_not_executed(self):
with tempfile.TemporaryDirectory() as temp:
marker = Path(temp) / "executed"
diff --git a/tests/test_project_check.py b/tests/test_project_check.py
new file mode 100644
index 0000000..795fe73
--- /dev/null
+++ b/tests/test_project_check.py
@@ -0,0 +1,203 @@
+import importlib.util
+import io
+import json
+import os
+from pathlib import Path
+import signal
+import subprocess
+import tempfile
+import unittest
+from unittest.mock import Mock, patch
+import urllib.error
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location("project_check", ROOT / "scripts/project_check.py")
+project_check = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(project_check)
+
+
+class ProjectCheckTests(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory()
+ self.root = Path(self.temporary.name)
+ self.which = patch.object(project_check.shutil, "which", side_effect=lambda name: "/usr/bin/" + name)
+ self.which.start()
+
+ def tearDown(self):
+ self.which.stop()
+ self.temporary.cleanup()
+
+ def write_package(self, directory=None, **values):
+ directory = directory or self.root
+ directory.mkdir(parents=True, exist_ok=True)
+ (directory / "package.json").write_text(json.dumps(values))
+ return directory
+
+ def commands(self, arguments, **kwargs):
+ if "rev-parse" in arguments:
+ return 0, str(self.root), None
+ if "context" in arguments:
+ return 0, "unix:///var/run/docker.sock", None
+ return 0, "", None
+
+ def check(self, path=None, **kwargs):
+ with patch.object(project_check, "run_command", side_effect=self.commands):
+ return project_check.check_project(path or self.root, **kwargs)
+
+ def statuses(self, report):
+ return {item["check"]: item["status"] for item in report["checks"]}
+
+ def test_missing_lockfile_and_empty_node_modules_are_reported(self):
+ self.write_package(packageManager="pnpm@12.15.0", dependencies={"react": "19.3.1"})
+ (self.root / "node_modules").mkdir()
+ report = self.check()
+ self.assertFalse(report["ready"])
+ self.assertEqual(self.statuses(report)["lockfile"], "fail")
+ self.assertEqual(self.statuses(report)["dependencies"], "fail")
+
+ def test_nested_package_selects_workspace_lock_and_installed_direct_dependencies(self):
+ self.write_package(packageManager="pnpm@12.15.0")
+ (self.root / "pnpm-workspace.yaml").write_text("packages: ['apps/*']\n")
+ (self.root / "pnpm-lock.yaml").write_text("lockfileVersion: '9.0'\n")
+ app = self.write_package(self.root / "apps/web", dependencies={"react": "19.3.1"})
+ dependency = self.root / "node_modules/react"
+ dependency.mkdir(parents=True)
+ (dependency / "package.json").write_text("{}")
+ report = self.check(app)
+ self.assertEqual(report["package"], str(app / "package.json"))
+ self.assertEqual(report["workspace"], str(self.root))
+ self.assertTrue(report["ready"])
+
+ def test_nested_non_javascript_project_checks_compose_at_git_root(self):
+ nested = self.root / "src" / "service"
+ nested.mkdir(parents=True)
+ (self.root / "compose.yaml").write_text("services: {}\n")
+ with patch.object(project_check.shutil, "which", side_effect=lambda name: None if name == "docker" else "/usr/bin/" + name):
+ report = self.check(nested)
+ self.assertEqual(report["project"], str(nested))
+ self.assertEqual(report["workspace"], str(self.root))
+ self.assertEqual(self.statuses(report)["compose-file"], "pass")
+ self.assertEqual(self.statuses(report)["docker"], "fail")
+ self.assertFalse(report["ready"])
+
+ def test_invalid_json_does_not_leak_contents_or_exception(self):
+ (self.root / "package.json").write_text('{"token":"super-private-token", invalid')
+ report = self.check()
+ self.assertFalse(report["ready"])
+ self.assertEqual(self.statuses(report)["package"], "fail")
+ self.assertNotIn("super-private-token", json.dumps(report))
+
+ def test_no_environment_file_reads_or_project_commands(self):
+ self.write_package(scripts={"preinstall": "touch SHOULD_NOT_EXIST"})
+ (self.root / "package-lock.json").write_text("{}")
+ (self.root / ".env").write_text("TOKEN=super-private-token\n")
+ (self.root / "compose.yaml").write_text("services: {}\n")
+ original_open = Path.open
+ opened = []
+
+ def guarded_open(path, *args, **kwargs):
+ self.assertFalse(path.name.startswith(".env"))
+ opened.append(path)
+ return original_open(path, *args, **kwargs)
+
+ with patch.object(Path, "open", guarded_open), patch.object(project_check, "run_command", side_effect=self.commands) as execute:
+ report = project_check.check_project(self.root)
+ self.assertEqual(opened, [self.root / "package.json"])
+ self.assertFalse((self.root / "SHOULD_NOT_EXIST").exists())
+ self.assertNotIn("super-private-token", json.dumps(report))
+ for call in execute.call_args_list:
+ arguments = call.args[0]
+ self.assertNotIn("install", arguments)
+ self.assertNotIn("config", arguments)
+ self.assertNotIn("--_registry-probe", arguments)
+ self.assertNotEqual(Path(arguments[0]).name, "pnpm")
+ self.assertNotEqual(Path(arguments[0]).name, "vp")
+
+ def test_environment_file_manifest_symlink_is_not_read(self):
+ environment = self.root / ".env"
+ environment.write_text('{"private":"secret"}')
+ (self.root / "package.json").symlink_to(environment)
+ with patch.object(Path, "open", side_effect=AssertionError("No file should be opened")):
+ report = self.check()
+ self.assertEqual(self.statuses(report)["package"], "fail")
+
+ def test_local_docker_failure_is_safe_and_actionable(self):
+ (self.root / "compose.yaml").write_text("services: {}\n")
+
+ def commands(arguments, **kwargs):
+ if "info" in arguments:
+ self.assertNotIn("DOCKER_CONTEXT", kwargs["environment"])
+ self.assertEqual(kwargs["environment"]["COMPOSE_DISABLE_ENV_FILE"], "true")
+ return 1, "password=super-private-token", None
+ return self.commands(arguments, **kwargs)
+
+ with patch.dict(os.environ, {"DOCKER_CONTEXT": "local-test", "COMPOSE_ENV_FILES": str(self.root / ".env")}), patch.object(project_check, "run_command", side_effect=commands):
+ report = project_check.check_project(self.root)
+ encoded = json.dumps(report)
+ self.assertNotIn("super-private-token", encoded)
+ self.assertIn("WSL", encoded)
+ self.assertEqual(self.statuses(report)["docker-daemon"], "fail")
+
+ def test_remote_docker_context_is_never_contacted(self):
+ (self.root / "compose.yml").write_text("services: {}\n")
+
+ def commands(arguments, **kwargs):
+ self.assertNotIn("info", arguments)
+ if "context" in arguments:
+ return 0, "ssh://private-user@private-host", None
+ return self.commands(arguments, **kwargs)
+
+ with patch.dict(os.environ, {"DOCKER_CONTEXT": "remote"}), patch.object(project_check, "run_command", side_effect=commands):
+ report = project_check.check_project(self.root)
+ self.assertEqual(self.statuses(report)["docker-daemon"], "skip")
+ self.assertNotIn("private-host", json.dumps(report))
+
+ def test_network_is_opt_in_and_bounded(self):
+ with patch.object(project_check, "run_command", side_effect=self.commands) as execute:
+ project_check.check_project(self.root)
+ self.assertFalse(any("--_registry-probe" in call.args[0] for call in execute.call_args_list))
+ report = project_check.check_project(self.root, network=True)
+ network = [call for call in execute.call_args_list if "--_registry-probe" in call.args[0]]
+ self.assertEqual(len(network), 1)
+ self.assertEqual(network[0].kwargs["timeout"], 10)
+ self.assertEqual(self.statuses(report)["registry"], "pass")
+
+ def test_registry_probe_uses_verified_https_and_does_not_expose_proxy_errors(self):
+ context = Mock()
+ opener = Mock()
+ opener.open.side_effect = urllib.error.URLError("https://user:super-private-token@proxy")
+ with patch.object(project_check.ssl, "create_default_context", return_value=context), patch.object(project_check.urllib.request, "build_opener", return_value=opener), patch.dict(os.environ, {"NODE_EXTRA_CA_CERTS": "/tmp/public-ca.pem"}, clear=True), patch.object(project_check.sys, "stdout", new_callable=io.StringIO) as output:
+ self.assertEqual(project_check.registry_probe(), 1)
+ context.load_verify_locations.assert_called_once_with(cafile="/tmp/public-ca.pem")
+ request = opener.open.call_args.args[0]
+ self.assertEqual(request.full_url, "https://registry.npmjs.org/-/ping")
+ self.assertEqual(opener.open.call_args.kwargs["timeout"], 6)
+ self.assertEqual(output.getvalue(), "")
+
+ def test_subprocess_timeout_kills_owned_group_and_discards_partial_output(self):
+ process = Mock(pid=12345)
+ process.communicate.side_effect = [subprocess.TimeoutExpired(["docker"], 5, output="super-private-token"), ("", None)]
+ with patch.object(project_check.subprocess, "Popen", return_value=process) as launch, patch.object(project_check.os, "killpg") as kill:
+ self.assertEqual(project_check.run_command(["docker", "info"]), (None, "", "timeout"))
+ kill.assert_called_once_with(12345, signal.SIGKILL)
+ self.assertEqual(launch.call_args.kwargs["stderr"], subprocess.DEVNULL)
+ self.assertTrue(launch.call_args.kwargs["start_new_session"])
+
+ def test_tool_availability_does_not_execute_package_manager_shims(self):
+ self.write_package(packageManager="pnpm@12.15.0", devDependencies={"vite-plus": "1.4.0"})
+ with patch.object(project_check.shutil, "which", side_effect=lambda name: None if name in ("node", "pnpm", "vp") else "/usr/bin/" + name):
+ report = self.check()
+ statuses = self.statuses(report)
+ self.assertEqual((statuses["node"], statuses["pnpm"], statuses["vp"]), ("fail", "fail", "fail"))
+
+ def test_json_output_and_nonzero_status_for_failed_checks(self):
+ (self.root / "package.json").write_text("invalid")
+ with patch.object(project_check, "run_command", side_effect=self.commands), patch.object(project_check.sys, "stdout", new_callable=io.StringIO) as output:
+ code = project_check.main(["--json", str(self.root)])
+ self.assertEqual(code, 1)
+ self.assertFalse(json.loads(output.getvalue())["ready"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py
index 9027a65..db33320 100644
--- a/tests/test_uninstall.py
+++ b/tests/test_uninstall.py
@@ -146,6 +146,48 @@ def test_cache_selection_is_independent_and_allowlisted(self):
self.assertIn("bat", self.deploy.state["programs"])
self.assertTrue(report["unresolved"])
+ def test_browser_cache_is_available_only_at_its_current_exact_path(self):
+ cache = uninstall.setup.CACHE / "browser-deps"
+ legacy = uninstall.setup.CACHE.parent / "dev-setup/browser-deps"
+ self.assertNotIn("browser-deps", uninstall.cache_options({}))
+ legacy.mkdir(parents=True)
+ self.assertNotIn("browser-deps", uninstall.cache_options({}))
+ cache.mkdir()
+ self.assertEqual(uninstall.cache_options({})["browser-deps"]["paths"], [cache])
+ self.assertTrue(uninstall.safe_cache_path(cache, "browser-deps"))
+ for other in [legacy, self.root / "cache/ms-playwright", cache / "nested", uninstall.setup.CACHE]:
+ self.assertFalse(uninstall.safe_cache_path(other, "browser-deps"))
+ selected = {"browser-deps": [str(legacy)]}
+ self.assertEqual(uninstall.canonical_cache_paths(selected), selected)
+
+ def test_program_removal_keeps_browser_caches_when_unselected(self):
+ self.program("fd")
+ cache = uninstall.setup.CACHE / "browser-deps"
+ browsers = self.root / "cache/ms-playwright"
+ cache.mkdir()
+ browsers.mkdir()
+ report = uninstall.apply_plan(self.deploy, {"fd"}, {})
+ self.assertTrue(cache.exists())
+ self.assertTrue(browsers.exists())
+ self.assertEqual(report["unresolved"], [])
+ self.assertEqual(report["removed"], ["fd"])
+
+ def test_browser_cache_selection_preserves_programs_and_playwright_browsers(self):
+ program, link, _ = self.program("vp")
+ cache = uninstall.setup.CACHE / "browser-deps"
+ browsers = self.root / "cache/ms-playwright"
+ cache.mkdir()
+ (cache / "library").write_text("fixture library")
+ browsers.mkdir()
+ (browsers / "browser").write_text("fixture browser")
+ report = uninstall.apply_plan(self.deploy, set(), {"browser-deps": [str(cache)]})
+ self.assertFalse(cache.exists())
+ self.assertTrue(program.exists())
+ self.assertTrue(link.exists())
+ self.assertEqual((browsers / "browser").read_text(), "fixture browser")
+ self.assertEqual(report["unresolved"], [])
+ self.assertEqual(report["removed"], ["browser-deps: " + str(cache)])
+
def test_cache_symlink_and_parent_escape_are_refused(self):
outside = self.root / "important"
outside.mkdir()