diff --git a/.github/ci/offline-node/package-lock.json b/.github/ci/offline-node/package-lock.json new file mode 100644 index 00000000..b3113fca --- /dev/null +++ b/.github/ci/offline-node/package-lock.json @@ -0,0 +1,67 @@ +{ + "name": "proxmenux-offline-node-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "proxmenux-offline-node-tests", + "version": "1.0.0", + "dependencies": { + "lucide-react": "0.454.0", + "react": "19.2.6", + "react-dom": "19.2.6", + "typescript": "5.9.3" + } + }, + "node_modules/lucide-react": { + "version": "0.454.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz", + "integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/.github/ci/offline-node/package.json b/.github/ci/offline-node/package.json new file mode 100644 index 00000000..11e85d30 --- /dev/null +++ b/.github/ci/offline-node/package.json @@ -0,0 +1,12 @@ +{ + "name": "proxmenux-offline-node-tests", + "private": true, + "version": "1.0.0", + "description": "Only the dependencies of the qualified tests/*.cjs CI lane", + "dependencies": { + "typescript": "5.9.3", + "react": "19.2.6", + "react-dom": "19.2.6", + "lucide-react": "0.454.0" + } +} diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py new file mode 100644 index 00000000..ed007dd1 --- /dev/null +++ b/.github/scripts/run_offline_qualified.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Run only the independently qualified, host-independent tests/ fixtures. + +No filesystem discovery widens this manifest. Run from the repository root; +Python and Node have separate CI jobs so either failure blocks the check. +""" + +import argparse +import os +from pathlib import Path +import subprocess +import sys + +PYTHON_FILES = ( + "tests/test_audit_presentation.py", + "tests/test_audit_safety_wording.py", + "tests/test_audit_policy.py", + "tests/test_audit_report.py", + "tests/test_audit_catalog.py", + "tests/storage/test_nvme_status_message.py", + "tests/test_fastfetch_config_generation.py", +) +NODE_FILES = ( + "tests/lxc_updates/test_docker_delegated_ui.cjs", + "tests/test_audit_diagnostic_document.cjs", + "tests/test_audit_policy.cjs", + "tests/test_audit_presentation.cjs", + "tests/test_audit_safety_wording.cjs", + "tests/test_audit_summary.cjs", + "tests/test_backup_archives_empty.cjs", + "tests/test_backup_destination_messages.cjs", + "tests/test_borg_ssh_guidance.cjs", + "tests/test_storage_messages.cjs", +) + +# unittest's CLI accepts a missing pattern as a successful zero-test run. +# Load the exact file in a fresh interpreter and reject zero tests and skips. +PYTHON_RUN = """import sys, unittest +folder, filename = sys.argv[1:] +suite = unittest.TestLoader().discover(start_dir=folder, pattern=filename) +count = suite.countTestCases() +if count == 0: + raise SystemExit('No tests discovered: ' + folder + '/' + filename) +print('DISCOVERED=' + str(count), flush=True) +result = unittest.TextTestRunner(verbosity=2).run(suite) +sys.exit(0 if result.wasSuccessful() and result.testsRun == count and not result.skipped else 1) +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--lane", required=True, choices=("python", "node")) + args = parser.parse_args() + root = Path(__file__).resolve().parents[2] + files = PYTHON_FILES if args.lane == "python" else NODE_FILES + if not files or Path.cwd().resolve() != root: + parser.error("nonempty manifest and repository-root working directory required") + for path in files: + if not (root / path).is_file(): + print(f"Missing qualified test: {path}", file=sys.stderr) + return 1 + if args.lane == "node" and not (root / "AppImage/node_modules/typescript").is_dir(): + print("Install the locked offline-node dependencies first", file=sys.stderr) + return 1 + for path in files: + print(f"RUN {path}", flush=True) + if args.lane == "python": + folder, filename = str(Path(path).parent), Path(path).name + command = [sys.executable, "-I", "-B", "-c", PYTHON_RUN, folder, filename] + else: + command = ["node", path] + result = subprocess.run(command, cwd=root, env=os.environ.copy(), check=False) + if result.returncode != 0: + print(f"FAIL {path}: exit {result.returncode}", file=sys.stderr) + return 1 + print(f"PASS {path}", flush=True) + print(f"PASS {args.lane}: {len(files)} qualified files", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml new file mode 100644 index 00000000..a6f89f92 --- /dev/null +++ b/.github/workflows/test-offline-qualified.yml @@ -0,0 +1,83 @@ +name: Qualified offline tests + +on: + pull_request: + paths: + - '.github/workflows/test-offline-qualified.yml' + - '.github/scripts/run_offline_qualified.py' + - '.github/ci/offline-node/**' + - 'CONTRIBUTING.md' + - 'tests/**' + - 'AppImage/package.json' + - 'AppImage/package-lock.json' + - 'AppImage/scripts/*.py' + - 'AppImage/app/**' + - 'AppImage/components/**' + - 'AppImage/hooks/**' + - 'AppImage/lib/**' + - 'AppImage/messages/**' + - 'lang/*.json' + - 'scripts/**/*.sh' + - 'scripts/**/*.func' + push: + branches: [main, develop] + paths: + - '.github/workflows/test-offline-qualified.yml' + - '.github/scripts/run_offline_qualified.py' + - '.github/ci/offline-node/**' + - 'CONTRIBUTING.md' + - 'tests/**' + - 'AppImage/package.json' + - 'AppImage/package-lock.json' + - 'AppImage/scripts/*.py' + - 'AppImage/app/**' + - 'AppImage/components/**' + - 'AppImage/hooks/**' + - 'AppImage/lib/**' + - 'AppImage/messages/**' + - 'lang/*.json' + - 'scripts/**/*.sh' + - 'scripts/**/*.func' + workflow_dispatch: + +permissions: + contents: read + +jobs: + offline-python: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Run seven qualified Python files + env: + PYTHONDONTWRITEBYTECODE: '1' + run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python + + offline-node: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - uses: actions/setup-node@v6 + with: + node-version: '22.14.0' + - name: Install only locked fixture dependencies (no lifecycle scripts) + run: | + npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund + ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules + - name: Run ten qualified Node files + env: + NODE_PATH: ${{ github.workspace }}/AppImage/node_modules + PYTHONDONTWRITEBYTECODE: '1' + run: python3 -I -B .github/scripts/run_offline_qualified.py --lane node diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 179c63b8..a31006d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -860,6 +860,42 @@ provisions Python and Node and runs this same command on relevant pull requests and main/develop pushes (or manually). It is separate from the translation publication workflow and has read-only repository permissions. +#### Qualified offline `tests/` CI lanes + +`.github/workflows/test-offline-qualified.yml` runs **only** the explicitly listed +seven Python files and ten Node files in `.github/scripts/run_offline_qualified.py`. +Run the same allowlist locally from the root of a clean, disposable checkout +with no `AppImage/node_modules`. Do not run this over a normal Monitor install: +the commands refuse an existing directory, file, or symlink (including a +dangling symlink) rather than overwrite or reuse it. In Bash: + +```bash +( + set -e + if [[ -e AppImage/node_modules || -L AppImage/node_modules ]]; then + printf '%s\n' 'Refusing: AppImage/node_modules already exists; use a clean disposable checkout (nothing overwritten).' >&2 + exit 1 + fi + npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund + ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules + python3 -I -B .github/scripts/run_offline_qualified.py --lane python + NODE_PATH="$PWD/AppImage/node_modules" python3 -I -B .github/scripts/run_offline_qualified.py --lane node +) +``` + +Requires Python 3.11, Node 22.14, npm and Bash/coreutils for the bounded shell +fixtures. The separate lockfile installs only TypeScript, React, React DOM, +Lucide icons and the locked scheduler dependency; it does not build the Monitor +or run npm lifecycle scripts. `--legacy-peer-deps` admits the existing Lucide +React peer constraint against locked React 19; it does not resolve that mismatch. The runner fails on missing files, zero discovered Python +tests, skipped Python tests, or a nonzero exit from either lane. Node fixtures +use top-level assertions, not a runner that reports case/skip totals. +These are local fixture tests, not the whole `tests/` tree, `AppImage/tests/`, +`AppImage/scripts/tests/`, browser smoke, a live Proxmox host, or an AppImage +build. The CI workflow has read-only permissions and runs on relevant PRs and +main/develop pushes, or manually. Review the input paths and the test's imports +before extending the explicit manifest. + #### Monitor checks - **Python tests** — under `AppImage/scripts/tests/`. Run with `python3 -m unittest discover -s AppImage/scripts/tests`. Add a test file when you add non-trivial backend logic (auth, notifications, background checks).