diff --git a/.github/workflows/autotester-tests.yml b/.github/workflows/autotester-tests.yml new file mode 100644 index 000000000..802ab36a3 --- /dev/null +++ b/.github/workflows/autotester-tests.yml @@ -0,0 +1,16 @@ +name: step.autotester-tests + +# Fast, Docker-free tests for the autotester step engine (selectors, conditions, +# templating, the executor, and the shipped scenarios run through a fake bridge). +on: [workflow_call] + +jobs: + autotester-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Run engine tests + working-directory: autotester + run: uv run --group dev pytest diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ffe1a1eb2..da5a27898 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,11 @@ on: type: string required: false default: '' + autotest: + description: bundle the autotester instrumentation into the jars (never for releases) + type: boolean + required: false + default: false jobs: build: @@ -31,13 +36,17 @@ jobs: - name: Execute Gradle build run: | chmod +x gradlew + flags="" + if [ "${{ inputs.autotest }}" = "true" ]; then + flags="-Pautomodpack.autotest" + fi if [ -z "${{ inputs.target_subproject }}" ]; then echo "Building all subprojects" - ./gradlew build + ./gradlew build $flags else args=$(echo "${{ inputs.target_subproject }}" | tr ',' '\n' | sed 's/$/:build/' | paste -sd ' ') echo "Building with arguments=$args" - ./gradlew $args + ./gradlew $args $flags fi env: BUILD_ID: ${{ github.run_number }} diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 0595316a3..5a29a34ab 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -6,6 +6,9 @@ jobs: tests: # runs on linux and windows uses: ./.github/workflows/tests.yml secrets: inherit + autotester-tests: # autotester engine unit tests (no Docker) + uses: ./.github/workflows/autotester-tests.yml + secrets: inherit build: # runs on linux uses: ./.github/workflows/build.yml secrets: inherit \ No newline at end of file diff --git a/.github/workflows/ingame-tests.yml b/.github/workflows/ingame-tests.yml new file mode 100644 index 000000000..81aa074f3 --- /dev/null +++ b/.github/workflows/ingame-tests.yml @@ -0,0 +1,142 @@ +name: In-game Tests + +on: + workflow_dispatch: + inputs: + scenario: + description: "Autotest scenario" + default: "sync" + target: + description: "Target ID (or 'all')" + default: "all" + jobs: + description: "Parallel runs" + default: "1" + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + scenario: ${{ steps.set-scenario.outputs.scenario }} + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - id: set-matrix + run: | + TARGET="${{ github.event.inputs.target || 'all' }}" + ALL=$(uv run --project autotester python -c "import json,yaml; ids=[t['id'] for t in yaml.safe_load(open('autotester/targets.yaml')).get('targets',[])]; print(json.dumps(ids))") + if [ "$TARGET" = "all" ]; then + echo "matrix={\"target\":$ALL}" >> $GITHUB_OUTPUT + else + echo "matrix={\"target\":[\"$TARGET\"]}" >> $GITHUB_OUTPUT + fi + - id: set-scenario + run: | + echo "scenario=${{ github.event.inputs.scenario || 'sync' }}" >> $GITHUB_OUTPUT + + unit: + uses: ./.github/workflows/autotester-tests.yml + secrets: inherit + + build: + needs: [prepare, unit] + uses: ./.github/workflows/build.yml + with: + autotest: true + secrets: inherit + + ingame: + needs: [build, prepare] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - uses: actions/download-artifact@v6 + with: + name: build-artifacts + path: merged/ + - name: Build autotest client image + run: uv --project autotester run autotester build-images + - name: Run autotest + run: > + uv --project autotester run autotester run + --target "${{ matrix.target }}" + --scenario "${{ needs.prepare.outputs.scenario }}" + - uses: actions/upload-artifact@v6 + if: always() + with: + name: autotester-${{ matrix.target }} + path: autotester/out + + report: + needs: [ingame] + if: always() + runs-on: ubuntu-latest + steps: + - name: Install uv + uses: astral-sh/setup-uv@v7 + - uses: actions/download-artifact@v6 + with: + pattern: autotester-* + - name: Aggregate results + id: aggregate + run: | + cat > /tmp/aggregate.py << 'PYEOF' + import json, glob, os + results = [] + for f in sorted(glob.glob("**/results.json", recursive=True)): + try: + data = json.load(open(f)) + results.extend(data.get("results", [])) + except Exception: + name = f.split(os.sep)[0] if os.sep in f else "unknown" + results.append({"target": name, "ok": False, "error": "results.json missing or invalid"}) + + total = len(results) + passed = sum(1 for r in results if r.get("ok")) + any_ok = passed == total + + for r in results: + if not r.get("ok"): + err = r.get("error", "unknown error") + print(f"::error title={r['target']}::{err}") + + summary = {"ok": any_ok, "total": total, "passed": passed, "failed": total - passed, "results": results} + with open("aggregated.json", "w") as f: + json.dump(summary, f, indent=2) + + md = "## In-game Test Results\n\n" + md += f"**{'PASS' if any_ok else 'FAIL'}** — {passed}/{total} passed, {total - passed} failed\n\n" + md += "| Status | Target | Duration | Error |\n" + md += "|--------|--------|----------|-------|\n" + for r in results: + status = "✅" if r.get("ok") else "❌" + dur = f"{r.get('duration', 0):.1f}s" if "duration" in r else "-" + err = r.get("error", "") if not r.get("ok") else "" + md += f"| {status} | {r['target']} | {dur} | {err} |\n" + + with open("summary.md", "w") as f: + f.write(md) + + print(f"ok={str(any_ok).lower()}", file=open(os.environ["GITHUB_OUTPUT"], "a")) + PYEOF + python3 /tmp/aggregate.py + - uses: actions/upload-artifact@v6 + with: + name: aggregated-results + path: | + aggregated.json + summary.md + - name: Post job summary + run: cat summary.md >> $GITHUB_STEP_SUMMARY + - name: Check overall result + if: steps.aggregate.outputs.ok != 'true' + run: | + echo "Some tests failed" + exit 1 diff --git a/.gitignore b/.gitignore index ef2833053..156b09c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,11 @@ output/ /merged/ /core/automodpack/ -dupes \ No newline at end of file +dupes + +# python + +__pycache__/ +*.egg-info/ +autotester/out/ +autotester/.hmc-cache/ diff --git a/autotester/README.md b/autotester/README.md new file mode 100644 index 000000000..a08f27b7c --- /dev/null +++ b/autotester/README.md @@ -0,0 +1,291 @@ +# AutoModpack Autotester + +Docker-based in-game integration tests for AutoModpack. The runner starts a +real Minecraft server and a HeadlessMC client, drives the client UI through an +opt-in file bridge, and verifies that the modpack sync flow works end to end. + +## Prerequisites + +- Docker +- Python 3.11+ +- `uv` +- Built AutoModpack artifacts in `merged/` + +Build artifacts first from the repository root. Use `build` (which runs the jar +merge as a finalizer) rather than `mergeJar` directly — `mergeJar` on its own +does not rebuild the dependency modules. The `-Pautomodpack.autotest` flag bundles +the in-game test instrumentation (`AutoTestBridge` + the `dev` mixins) into the +jars; it is required for the autotester and is never included in release builds. + +```bash +./gradlew build -Pautomodpack.autotest +``` + +## Quick Start + +Build the client image: + +```bash +uv --project autotester run autotester build-images +``` + +Run one target: + +```bash +uv --project autotester run autotester run --target 1.21.11-fabric --scenario download-only +``` + +Run the full default matrix: + +```bash +uv --project autotester run autotester run --target all --scenario sync --jobs 1 +``` + +Clean generated output: + +```bash +uv --project autotester run autotester clean +``` + +## What It Tests + +The default `sync` scenario performs this flow: + +1. Start a server container. +2. Start a client container. +3. Connect to the server. +4. Accept the server certificate fingerprint. +5. Download and verify synced files. +6. Restart the client. +7. Rejoin and verify the player reaches the game. + +The `download-only` scenario stops after the first sync and file verification. +Use it for faster debugging when restart/rejoin behavior is not relevant. + +## Configuration + +- `settings.yaml` contains default paths, Docker images, server config, + timeouts, and runner defaults. +- `targets.yaml` lists Minecraft/loader/Java combinations. +- `scenarios/*.yaml` defines test flows and scenario-specific server files. + +Common run options: + +| Option | Description | +| --- | --- | +| `--target ID` | Target from `targets.yaml`, or `all`. | +| `--scenario ID` | Scenario file stem from `scenarios/`. | +| `--jobs N` | Number of targets to run in parallel. | +| `--artifact-dir PATH` | Directory containing merged AutoModpack jars. | +| `--out-dir PATH` | Directory for logs and `results.json`. | +| `--client-image IMG` | Docker image used for HeadlessMC clients. | + +### HeadlessMC build + +The client image builds its HeadlessMC launcher from the git repo and ref in +`settings.yaml`: + +```yaml +headlessmc: + repo: "https://github.com/Skidamek/headlessmc.git" + ref: "64d3c126e72bbfccf95e71afaa6536f50bc64097" # branch, tag, or commit SHA +``` + +`ref` may be a branch, tag, or commit SHA; it is pinned to a SHA here for +reproducible image builds. This default ref carries the patch required to launch +**MC 26.2** headlessly +(stock HeadlessMC can't yet — its LWJGL stubs don't satisfy 26.2's new render +backend); it does not change behavior on other versions. Point `repo`/`ref` at +any other HeadlessMC build and rebuild the image +(`uv --project autotester run autotester build-images`). If they are unset, the +build falls back to upstream HeadlessMC (`headlesshq/headlessmc`). + +## Scenarios + +Scenarios are **declarative**: a `flow` is an ordered list of *steps*, where each +step is a verb plus arguments. Verbs are generic building blocks (click a button, +type into a field, wait for a condition, verify files), so most tests need no +Python — behavior is expressed entirely in YAML. + +```yaml +id: download-only + +flow: + - use: boot # a macro from scenarios/_lib.yaml + - use: accept_certificate + - use: download_modpack + - do: verify_files # a verb with arguments + name: verify all synced files are present + - do: quit + +topology: + server: + memory: 2G + +serverFiles: + modpackName: amp-autotest + marker: config/amp-autotest-marker.json + files: + - path: config/example.txt + content: "hello\n" +``` + +A step is either a bare name (`- quit`, or a macro name) or a mapping with a +`do:` verb and its arguments. Optional keys on any step: + +| Key | Meaning | +| --- | --- | +| `name` | Human-readable label shown in logs and `results.json`. | +| `when` | A condition; the step runs only if it holds. | +| `repeat` | Run the step N times. | +| `optional` | If the step fails, log it and continue instead of failing the run. | + +### Verbs + +| Verb | Purpose | +| --- | --- | +| `click` | Click the element matched by `select:` (defaults to enabled elements). Set `enable: true` to force-enable it first. | +| `type` / `paste` | Type `value:` into the field matched by `select:` (defaults to the first text field). | +| `wait_for` | Poll `until:` (a condition) until it holds or `timeout:` elapses. | +| `assert` | Fail immediately unless `that:` (a condition) holds. | +| `sleep` | Wait `duration:` (e.g. `2s`). | +| `wait_file` / `wait_files` | Wait for file(s) under the client game dir. | +| `verify_files` | Wait until every `serverFiles.files` entry exists in the synced modpack. | +| `verify_mods` | Wait until every `serverFiles.expectedMods` glob is present. | +| `launch_server` / `wait_server` | Start the server / wait for `Done (`. | +| `launch_client` / `wait_bridge` | Start a client / wait for its bridge. | +| `connect` / `disconnect` / `quit` | Drive the client connection. | +| `wait_client_exit` | Wait for the client container to exit (after a restart). | +| `wait_join` | Wait until the player is in-game (no screen open). | + +### Selectors + +`select:` (and the `element` condition) match GUI elements declaratively: + +```yaml +select: + role: button # button | textfield | any (default) + text: Verify # exact match preferred, else substring (case-insensitive) + text_any: [ok, yes] # any of these + class: Btn # substring of the element's class + enabled: true # filter by enabled / visible + index: -1 # pick the Nth match (negative counts from the end) +``` + +### Conditions + +`when:`, `wait_for.until:`, and `assert.that:` all take a condition. All keys in a +condition must hold (AND): + +| Key | True when | +| --- | --- | +| `screen` / `screen_not` | The current screen class/title contains (any of) the given value(s). | +| `screen_none` | No screen is open (the player is in-game). | +| `element` / `no_element` | A selector matches at least one / no elements. | +| `file` / `file_gone` | A path under the game dir exists / does not exist. | +| `log` | A regex matches a container log; capture groups into vars (see below). | +| `all` / `any` / `not` | Combine sub-conditions. | + +### Templating and variables + +Strings expand `${...}` against the scenario context: `${target.id}`, +`${server.host}`, `${modpack}`, `${marker}`, plus any captured variables. The +`log` condition can capture regex groups into variables for later steps: + +```yaml +- do: wait_for + name: read fingerprint + until: + log: + container: server + matches: 'fingerprint[:\s]+([0-9A-Fa-f:]+)' + capture: { fingerprint: 1 } +- do: type + select: { role: textfield } + value: "${fingerprint}" # captured above +``` + +### Macros + +Reusable step sequences live in `scenarios/_lib.yaml` and are referenced by name +(`- use: boot`, or inline as a bare string). A scenario can also define local +sequences under a `sequences:` key. Files starting with `_` are libraries and are +never run as scenarios. The shipped library provides `boot`, +`read_server_fingerprint`, `connect_to_server`, `accept_certificate`, +`download_modpack`, `restart_client`, and `rejoin`. + +### Adding a new verb + +Generic verbs cover most needs. When you genuinely need new behavior, register a +verb in `automodpack_autotester/engine/` (UI/filesystem verbs) or in `runner.py` +(verbs that touch Docker): + +```python +from automodpack_autotester.engine.registry import verb + +@verb("my_step") +def my_step(ctx, step): + target = ctx.resolve(step["arg"]) # expand templates + ctx.gui() # current GUI snapshot + ctx.bridge.click(...) # drive the client +``` + +Engine internals (selectors, conditions, templating, the executor) are covered by +`tests/` and run without Docker: `uv --project autotester run --group dev pytest`. + +## Output + +By default, output is written to `autotester/out/`. + +Important files: + +- `results.json`: aggregated pass/fail result. +- `/amp-s-*.log`: server container log. +- `/amp-c-*.log`: client container log. +- `/client/game/automodpack/modpacks/`: synced client modpacks. + +`results.json` has this shape: + +```json +{ + "ok": false, + "results": [ + { + "target": "1.21.11-fabric", + "scenario": "sync", + "ok": false, + "duration": 142.7, + "error": "step 'confirm download' failed: ...", + "steps": [ + { "name": "start server", "verb": "launch_server", "ok": true, "duration": 0.1 }, + { "name": "confirm download", "verb": "click", "ok": false, "duration": 90.0, "error": "..." } + ] + } + ] +} +``` + +Each step records its `name`, `verb`, `ok`, and `duration`; failed steps also +carry an `error`. On failure the partial step list up to and including the failing +step is preserved. + +## CI Workflow + +`.github/workflows/ingame-tests.yml` is manual (`workflow_dispatch`). It builds +the normal project artifacts, builds the autotest client image, runs the chosen +target/scenario matrix, uploads per-target logs, and publishes an aggregate +summary. + +## Bridge + +The bridge is disabled unless the JVM property `automodpack.autotest=true` is +present. Test containers pass a per-run token and game directory through JVM +properties. Commands and responses are JSON files under: + +```text +/automodpack/autotest/ +``` + +The bridge is intentionally small and generic. It exposes operations for +`ping`, `gui`, `click`, `text`, `connect`, `disconnect`, and `quit`; the runner +builds scenario behavior from those primitives. diff --git a/autotester/automodpack_autotester/bridge.py b/autotester/automodpack_autotester/bridge.py new file mode 100644 index 000000000..fef312bb5 --- /dev/null +++ b/autotester/automodpack_autotester/bridge.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json +import random +import time +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class BridgeClient: + game_dir: Path + token: str + + def request(self, op: str, timeout: float = 30, **payload) -> dict: + autotest_dir = self.game_dir / "automodpack" / "autotest" + autotest_dir.mkdir(parents=True, exist_ok=True) + cmd = autotest_dir / "bridge-command.json" + rsp = autotest_dir / "bridge-response.json" + rsp.unlink(missing_ok=True) + tmp = cmd.with_suffix(".tmp") + tmp.write_text( + json.dumps({"token": self.token, "op": op, **payload}), encoding="utf-8" + ) + tmp.rename(cmd) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if rsp.exists(): + data = json.loads(rsp.read_text(encoding="utf-8")) + rsp.unlink(missing_ok=True) + if not data.get("ok"): + raise RuntimeError(f"Bridge error on '{op}': {data.get('error', data)}") + return data + time.sleep(random.uniform(0.03, 0.07)) + raise TimeoutError(f"Bridge did not respond to '{op}' after {timeout}s") + + def gui(self, timeout: float = 30) -> dict: + return self.request("gui", timeout=timeout) + + def click(self, element_id: int, timeout: float = 30, **payload) -> dict: + return self.request("click", timeout=timeout, id=element_id, **payload) + + def text(self, element_id: int, value: str, timeout: float = 30) -> dict: + return self.request("text", timeout=timeout, id=element_id, text=value) + + def connect(self, host: str, port: int = 25565, timeout: float = 30) -> dict: + return self.request("connect", timeout=timeout, host=host, port=port) diff --git a/autotester/automodpack_autotester/cli.py b/autotester/automodpack_autotester/cli.py new file mode 100644 index 000000000..983059f9b --- /dev/null +++ b/autotester/automodpack_autotester/cli.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import json +import logging +import os +import shutil +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +import docker as docker_py + +from .config import REPO_ROOT, ROOT, load_scenarios, load_settings, load_targets +from .runner import run_case + +logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + + +def _resolve_settings_path(s: dict, key: str, default: str) -> Path: + raw = s.get("paths", {}).get(key, default) + p = Path(str(raw)) + return (REPO_ROOT / p).resolve() if not p.is_absolute() else p.resolve() + + +def _kill_amp_containers() -> None: + client = docker_py.from_env() + for c in client.containers.list(all=True, filters={"name": "amp-"}): + try: + c.remove(force=True) + except Exception: + pass + for n in client.networks.list(filters={"name": "amp-"}): + try: + n.remove() + except Exception: + pass + + +def main(argv: list[str] | None = None) -> int: + import argparse + + p = argparse.ArgumentParser(prog="autotester") + sub = p.add_subparsers(dest="command", required=True) + + build = sub.add_parser("build-images") + build.add_argument("--client-image") + + run_p = sub.add_parser("run") + run_p.add_argument("--target") + run_p.add_argument("--scenario") + run_p.add_argument("--jobs", type=int, default=1) + run_p.add_argument("--docker-uid", type=int) + run_p.add_argument("--docker-gid", type=int) + run_p.add_argument("--artifact-dir", type=Path) + run_p.add_argument("--out-dir", type=Path) + run_p.add_argument("--client-image") + + clean = sub.add_parser("clean") + clean.add_argument("--out-dir", type=Path) + + args = p.parse_args(argv) + + if args.command == "build-images": + s = load_settings() + hmc = s.get("headlessmc", {}) + img = args.client_image or str( + s.get("images", {}).get("client", "automodpack-autotest-client:local") + ) + # Pass through only what settings provide; the Dockerfile falls back to + # upstream HeadlessMC for anything unset. + buildargs = {} + if hmc.get("repo"): + buildargs["HEADLESSMC_REPO"] = str(hmc["repo"]) + if hmc.get("ref"): + buildargs["HEADLESSMC_REF"] = str(hmc["ref"]) + docker_py.from_env().images.build( + path=str(ROOT / "docker" / "client"), + dockerfile=str(ROOT / "docker" / "client" / "Dockerfile"), + tag=img, + buildargs=buildargs, + rm=True, + ) + return 0 + + if args.command == "clean": + s = load_settings() + out_dir = ( + _resolve_settings_path(s, "outDir", "out") + if not args.out_dir + else args.out_dir.resolve() + ) + shutil.rmtree(out_dir, ignore_errors=True) + return 0 + + # --- run --- + s = load_settings() + rc = s.get("run", {}) + + if args.docker_uid is not None: + os.environ["AUTOTEST_DOCKER_UID"] = str(args.docker_uid) + elif rc.get("dockerUid") is not None: + os.environ.setdefault("AUTOTEST_DOCKER_UID", str(rc["dockerUid"])) + if args.docker_gid is not None: + os.environ["AUTOTEST_DOCKER_GID"] = str(args.docker_gid) + elif rc.get("dockerGid") is not None: + os.environ.setdefault("AUTOTEST_DOCKER_GID", str(rc["dockerGid"])) + + targets = load_targets() + scenarios = load_scenarios() + scenario = scenarios.get(args.scenario or rc.get("scenario", "sync")) + selected = ( + list(targets.values()) + if not args.target or args.target == "all" + else [targets[args.target]] + ) + if not selected: + print("No targets", file=sys.stderr) + return 1 + + out_dir = ( + _resolve_settings_path(s, "outDir", "out") + if not args.out_dir + else args.out_dir.resolve() + ) + artifact_dir = ( + _resolve_settings_path(s, "artifactDir", "merged") + if not args.artifact_dir + else args.artifact_dir.resolve() + ) + client_image = args.client_image or str( + s.get("images", {}).get("client", "automodpack-autotest-client:local") + ) + out_dir.mkdir(parents=True, exist_ok=True) + + results: dict = {} + interrupted = False + try: + executor = ThreadPoolExecutor( + max_workers=max(1, args.jobs or rc.get("jobs", 1)) + ) + try: + task_map = { + executor.submit( + run_case, + t, + scenario, + out_dir=out_dir, + artifact_dir=artifact_dir, + client_image=client_image, + settings=s, + ): t + for t in selected + } + for f in as_completed(task_map): + r = f.result() + results[r["target"]] = r + print( + f"{'PASS' if r['ok'] else 'FAIL'} {r['target']} {r.get('duration', 0):.1f}s" + ) + if r.get("error"): + print(f" {r['error']}", file=sys.stderr) + + except KeyboardInterrupt: + interrupted = True + print("\nInterrupted, cleaning up containers...", file=sys.stderr) + for ff in task_map: + ff.cancel() + try: + _kill_amp_containers() + except KeyboardInterrupt: + print("Force exit.", file=sys.stderr) + os._exit(1) + print("Cleanup complete.", file=sys.stderr) + + finally: + executor.shutdown(wait=False) + ok = all(r.get("ok", False) for r in results.values()) + (out_dir / "results.json").write_text( + json.dumps({"ok": ok, "results": list(results.values())}, indent=2) + ) + if interrupted: + os._exit(1) + + return 0 if ok else 1 + + except KeyboardInterrupt: + os._exit(1) diff --git a/autotester/automodpack_autotester/config.py b/autotester/automodpack_autotester/config.py new file mode 100644 index 000000000..8a2b3a475 --- /dev/null +++ b/autotester/automodpack_autotester/config.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path + +import yaml + + +def _find_root() -> Path: + cwd = Path.cwd().resolve() + for p in (cwd, cwd / "autotester", cwd.parent / "autotester"): + if (p / "settings.yaml").is_file(): + return p + raise FileNotFoundError( + "settings.yaml not found — run from project root or autotester/" + ) + + +ROOT = _find_root() +REPO_ROOT = ROOT.parent + + +def load_yaml(path: Path) -> dict: + with path.open() as f: + return yaml.safe_load(f) or {} + + +def load_settings() -> dict: + return load_yaml(ROOT / "settings.yaml") + + +@dataclass +class Target: + id: str + minecraft: str + loader: str + java: int + fabric_loader: str | None = None + forge_version: str | None = None + neoforge_version: str | None = None + + +def load_targets() -> dict[str, Target]: + raw = load_yaml(ROOT / "targets.yaml") + d = raw.get("defaults", {}) + targets = [] + for item in raw.get("targets", []): + targets.append( + Target( + id=item["id"], + minecraft=item["minecraft"], + loader=item["loader"], + java=item.get("java", d.get("java", 21)), + fabric_loader=item.get("fabricLoader", d.get("fabricLoader")), + forge_version=item.get("forgeVersion", d.get("forgeVersion")), + neoforge_version=item.get("neoforgeVersion", d.get("neoforgeVersion")), + ) + ) + return {t.id: t for t in targets} + + +def load_scenarios() -> dict[str, dict]: + return { + f.stem: load_yaml(f) + for f in sorted((ROOT / "scenarios").glob("*.yaml")) + if not f.name.startswith("_") + } + + +@lru_cache(maxsize=1) +def load_macros() -> dict: + """Shared reusable step sequences from ``scenarios/_lib.yaml`` (if present). + + Cached: the library is static for a run and read once per process. + """ + lib = ROOT / "scenarios" / "_lib.yaml" + return load_yaml(lib) if lib.is_file() else {} + + +@dataclass(frozen=True) +class ServerFiles: + """The modpack a scenario hosts on the server, parsed from ``serverFiles``.""" + + modpack_name: str + marker: Path + files: list[tuple[Path, str]] = field(default_factory=list) + expected_mods: list[str] = field(default_factory=list) + + +def parse_server_files(scenario: dict) -> ServerFiles: + sf = scenario.get("serverFiles", {}) or {} + return ServerFiles( + modpack_name=str(sf.get("modpackName", "amp-autotest")), + marker=Path(str(sf.get("marker", "config/amp-autotest-marker.json"))), + files=[(Path(str(f["path"])), str(f.get("content", ""))) for f in sf.get("files", [])], + expected_mods=[str(m) for m in sf.get("expectedMods", [])], + ) diff --git a/autotester/automodpack_autotester/engine/__init__.py b/autotester/automodpack_autotester/engine/__init__.py new file mode 100644 index 000000000..390a80a33 --- /dev/null +++ b/autotester/automodpack_autotester/engine/__init__.py @@ -0,0 +1,11 @@ +"""Declarative step engine for autotester scenarios. + +Importing this package registers the built-in UI and filesystem verbs. The runner +registers the remaining lifecycle verbs (launch/connect/quit/...) when it loads. +""" +from . import steps_io, steps_ui # noqa: F401 -- import for verb registration +from .context import ClientExited, Context +from .executor import run_flow +from .registry import verb + +__all__ = ["Context", "ClientExited", "run_flow", "verb"] diff --git a/autotester/automodpack_autotester/engine/conditions.py b/autotester/automodpack_autotester/engine/conditions.py new file mode 100644 index 000000000..9275d6333 --- /dev/null +++ b/autotester/automodpack_autotester/engine/conditions.py @@ -0,0 +1,93 @@ +"""Boolean conditions, shared by ``wait_for``, ``assert`` and ``when``. + +A condition is a mapping; all keys must hold (AND). Keys: + + screen screenClass/title contains (str or list -> any) + screen_not none of these + screen_none true -> in-game (no screen open) + element a selector that must match at least one element + no_element a selector that must match nothing + file path (relative to the client game dir) exists + file_gone path does not exist + log {container: server|client, matches: , capture: {var: group}} + all / any combine a list of sub-conditions + not negate a sub-condition +""" +from __future__ import annotations + +import re + +from . import selectors + +_GUI_KEYS = {"screen", "screen_not", "screen_none", "element", "no_element"} + + +def evaluate(ctx, cond: dict, gui: dict | None = None) -> bool: + if not cond: + return True + if gui is None and _needs_gui(cond): + gui = ctx.gui() + return all(_check(ctx, key, val, gui) for key, val in cond.items()) + + +def describe(cond: dict) -> str: + return repr(cond) + + +def _needs_gui(cond: dict) -> bool: + for key, val in cond.items(): + if key in _GUI_KEYS: + return True + if key in ("all", "any") and any(_needs_gui(s) for s in val): + return True + if key == "not" and _needs_gui(val): + return True + return False + + +def _check(ctx, key, val, gui) -> bool: + if key == "all": + return all(evaluate(ctx, sub, gui) for sub in val) + if key == "any": + return any(evaluate(ctx, sub, gui) for sub in val) + if key == "not": + return not evaluate(ctx, val, gui) + if key == "screen": + return _screen(gui, val) + if key == "screen_not": + return not _screen(gui, val) + if key == "screen_none": + return (gui.get("screenClass") is None) == bool(val) + if key == "element": + return selectors.find_one(gui, ctx.resolve(val)) is not None + if key == "no_element": + return selectors.find_one(gui, ctx.resolve(val)) is None + if key == "file": + return ctx.path(val).exists() + if key == "file_gone": + return not ctx.path(val).exists() + if key == "log": + return _log(ctx, val) + raise ValueError(f"unknown condition key: {key!r}") + + +def _screen(gui: dict, val) -> bool: + sc = str(gui.get("screenClass") or "") + title = str(gui.get("title") or "") + needles = [val] if isinstance(val, str) else list(val) + return any(str(n) in sc or str(n) in title for n in needles) + + +def _log(ctx, spec: dict) -> bool: + which = str(spec.get("container", "client")) + pattern = ctx.resolve(str(spec["matches"])) + logs = ctx.container_logs(which, spec.get("tail", 400)) + m = re.search(pattern, logs, re.IGNORECASE | re.MULTILINE) + if not m: + return False + for var, group in (spec.get("capture") or {}).items(): + try: + ctx.vars[str(var)] = m.group(int(group)) + except (IndexError, ValueError): + pass + return True diff --git a/autotester/automodpack_autotester/engine/context.py b/autotester/automodpack_autotester/engine/context.py new file mode 100644 index 000000000..800879c77 --- /dev/null +++ b/autotester/automodpack_autotester/engine/context.py @@ -0,0 +1,97 @@ +"""Execution context shared by every step: data, variables, templating, bridge access.""" +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from ..bridge import BridgeClient +from .util import ClientExited + +_VAR = re.compile(r"\$\{([^}]+)\}") + + +@dataclass +class Context: + """Everything a step needs. Built once per test case by the runner.""" + + target: Any + scenario: dict + settings: dict + game_dir: Path + server_dir: Path + out_dir: Path + client_image: str + srv_name: str + cli_name: str + net_name: str + token: str + artifact: Path + modpack_name: str + marker_rel: Path + scenario_files: list # list[(Path, str)] + expected_mods: list + vars: dict = field(default_factory=dict) + bridge: BridgeClient | None = None + # Injected by the runner so the engine stays decoupled from Docker. + logs_provider: Callable[[str, int | None], str] | None = None + running_provider: Callable[[], None] | None = None + + # --- variables / templating ------------------------------------------- + + def namespace(self) -> dict: + return { + "target": self.target, + "server": {"host": self.srv_name, "port": 25565}, + "client": {"game_dir": str(self.game_dir)}, + "modpack": self.modpack_name, + "modpack_dir": f"automodpack/modpacks/{self.modpack_name}", + "marker": str(self.marker_rel), + **self.vars, + } + + def resolve(self, value: Any) -> Any: + """Recursively expand ``${var}`` / ``${var.attr}`` in strings, lists, dicts.""" + if isinstance(value, str): + ns = self.namespace() + return _VAR.sub(lambda m: str(_lookup(ns, m.group(1).strip())), value) + if isinstance(value, list): + return [self.resolve(v) for v in value] + if isinstance(value, dict): + return {k: self.resolve(v) for k, v in value.items()} + return value + + # --- bridge / containers --------------------------------------------- + + def gui(self, timeout: float = 30) -> dict: + self.assert_client_running() + if self.bridge is None: + raise RuntimeError("bridge not ready (run wait_bridge first)") + return self.bridge.gui(timeout=timeout) + + def assert_client_running(self) -> None: + if self.running_provider is not None: + self.running_provider() + + def container_logs(self, which: str = "client", tail: int | None = None) -> str: + if self.logs_provider is None: + return "" + return self.logs_provider(which, tail) + + def path(self, rel: str) -> Path: + return self.game_dir / self.resolve(str(rel)) + + +def _lookup(ns: dict, dotted: str) -> Any: + parts = dotted.split(".") + if parts[0] not in ns: + raise KeyError(f"unknown template variable: ${{{dotted}}}") + cur: Any = ns[parts[0]] + for part in parts[1:]: + cur = cur.get(part) if isinstance(cur, dict) else getattr(cur, part, None) + return "" if cur is None else cur + + +__all__ = ["Context", "ClientExited"] diff --git a/autotester/automodpack_autotester/engine/executor.py b/autotester/automodpack_autotester/engine/executor.py new file mode 100644 index 000000000..c682dcdf6 --- /dev/null +++ b/autotester/automodpack_autotester/engine/executor.py @@ -0,0 +1,90 @@ +"""Run a scenario flow: expand macros, gate on ``when``, execute verbs, record results.""" +from __future__ import annotations + +import logging +import time + +from . import conditions +from .registry import get as get_verb + +logger = logging.getLogger(__name__) + + +def run_flow(ctx, scenario: dict, *, lib: dict | None = None, results: list | None = None) -> list[dict]: + """Execute ``scenario['flow']`` and return a per-step result list. + + Pass ``results`` to collect step records into a caller-owned list, so partial + results survive when the flow raises partway through. + """ + macros = dict(lib or {}) + macros.update(scenario.get("sequences") or {}) + flow = scenario.get("flow") + if not flow: + raise ValueError("scenario has no 'flow'") + if results is None: + results = [] + _run_steps(ctx, flow, macros, results) + return results + + +def _run_steps(ctx, steps, macros, results, depth=0): + if depth > 25: + raise RuntimeError("macro expansion too deep (cycle in sequences?)") + for raw in steps: + # Normalize to a mapping; a bare string is a macro name or a verb name. + if isinstance(raw, str): + step = {"use": raw} if raw in macros else {"do": raw} + elif isinstance(raw, dict): + step = dict(raw) + else: + raise ValueError(f"invalid step: {raw!r}") + + # `when` and `repeat` apply uniformly to every step kind (verb, use, group). + when = step.get("when") + if when is not None and not conditions.evaluate(ctx, ctx.resolve(when)): + logger.info("[%s] skip (when not met): %s", _tid(ctx), _label(step)) + continue + + for _ in range(int(step.get("repeat", 1))): + if "use" in step: + name = step["use"] + if name not in macros: + raise ValueError(f"unknown macro: {name!r}") + _run_steps(ctx, macros[name], macros, results, depth + 1) + elif "group" in step: + _run_steps(ctx, step.get("steps", []), macros, results, depth + 1) + else: + _run_one(ctx, step, results) + + +def _run_one(ctx, step, results): + verb_name = step.get("do") + fn = get_verb(verb_name) + if fn is None: + raise ValueError(f"unknown step verb: {verb_name!r}") + label = step.get("name") or verb_name + started = time.monotonic() + logger.info("[%s] step: %s", _tid(ctx), label) + try: + fn(ctx, step) + except Exception as e: + results.append({ + "name": label, "verb": verb_name, "ok": False, + "duration": time.monotonic() - started, "error": str(e), + }) + if step.get("optional"): + logger.warning("[%s] optional step '%s' failed: %s", _tid(ctx), label, e) + return + raise RuntimeError(f"step '{label}' failed: {e}") from e + results.append({ + "name": label, "verb": verb_name, "ok": True, + "duration": time.monotonic() - started, + }) + + +def _tid(ctx): + return getattr(ctx.target, "id", "?") + + +def _label(step): + return step.get("name") or step.get("do") or step.get("use") or "?" diff --git a/autotester/automodpack_autotester/engine/registry.py b/autotester/automodpack_autotester/engine/registry.py new file mode 100644 index 000000000..62d3899ae --- /dev/null +++ b/autotester/automodpack_autotester/engine/registry.py @@ -0,0 +1,25 @@ +"""Verb registry. Step verbs register here and the executor looks them up by name.""" +from __future__ import annotations + +from collections.abc import Callable + +VERBS: dict[str, Callable] = {} + + +def verb(*names: str) -> Callable: + """Register a step handler under one or more verb names. + + A handler has the signature ``fn(ctx, step) -> None`` where ``step`` is the + raw step mapping from the scenario. + """ + + def deco(fn: Callable) -> Callable: + for name in names: + VERBS[name] = fn + return fn + + return deco + + +def get(name: str) -> Callable | None: + return VERBS.get(name) diff --git a/autotester/automodpack_autotester/engine/selectors.py b/autotester/automodpack_autotester/engine/selectors.py new file mode 100644 index 000000000..e925d2dcf --- /dev/null +++ b/autotester/automodpack_autotester/engine/selectors.py @@ -0,0 +1,79 @@ +"""Match GUI elements from a bridge snapshot by a declarative selector. + +A selector is a mapping; all given fields must match (AND): + + role button | textfield | any (default: any) + text exact (case-insensitive, trimmed) preferred, else substring + text_any list of texts, any may match + class substring of the element's class name + enabled true/false + visible true/false + index pick the Nth match (default 0; negative counts from the end) +""" +from __future__ import annotations + + +def _elements(gui: dict, role: str) -> list: + role = role.lower() + if role in ("button", "buttons"): + return list(gui.get("buttons", [])) + if role in ("textfield", "textfields", "field"): + return list(gui.get("textFields", [])) + return list(gui.get("buttons", [])) + list(gui.get("textFields", [])) + + +def _needles(selector: dict) -> list[str] | None: + if selector.get("text") is not None: + return [str(selector["text"])] + if selector.get("text_any") is not None: + return [str(t) for t in selector["text_any"]] + return None + + +def _exact(element: dict, needles: list[str]) -> bool: + text = str(element.get("text", "")).strip().lower() + return any(text == n.strip().lower() for n in needles) + + +def _matches_text(element: dict, needles: list[str]) -> bool: + if _exact(element, needles): + return True + text = str(element.get("text", "")).lower() + return any(n.strip().lower() in text for n in needles) + + +def find_all(gui: dict, selector: dict) -> list: + role = str(selector.get("role", "any")) + needles = _needles(selector) + klass = selector.get("class") + enabled = selector.get("enabled") + visible = selector.get("visible") + out = [] + for e in _elements(gui, role): + if enabled is not None and bool(e.get("enabled", False)) != bool(enabled): + continue + if visible is not None and bool(e.get("visible", False)) != bool(visible): + continue + if klass is not None and str(klass).lower() not in str(e.get("class", "")).lower(): + continue + if needles is not None and not _matches_text(e, needles): + continue + out.append(e) + return out + + +def find_one(gui: dict, selector: dict): + matches = find_all(gui, selector) + if not matches: + return None + if "index" in selector: + idx = int(selector["index"]) + if idx < 0: + idx += len(matches) + return matches[idx] if 0 <= idx < len(matches) else None + needles = _needles(selector) + if needles: + for e in matches: # prefer an exact text match over a substring one + if _exact(e, needles): + return e + return matches[0] diff --git a/autotester/automodpack_autotester/engine/steps_io.py b/autotester/automodpack_autotester/engine/steps_io.py new file mode 100644 index 000000000..65ecbf8ed --- /dev/null +++ b/autotester/automodpack_autotester/engine/steps_io.py @@ -0,0 +1,59 @@ +"""Filesystem verbs: wait_file, wait_files, verify_files, verify_mods. + +Log-based waits are expressed with ``wait_for`` + a ``log`` condition, so no +dedicated verb is needed for them. +""" +from __future__ import annotations + +from fnmatch import fnmatch + +from .registry import verb +from .util import await_condition, parse_duration + + +def _await_exist(ctx, root, rels, step, msg, default_timeout): + """Poll until every path in ``rels`` exists under ``root``, or time out.""" + paths = [root / r for r in rels] + timeout = parse_duration(step.get("timeout"), default=default_timeout) + await_condition( + lambda: True if all(p.exists() for p in paths) else None, + timeout, + step.get("poll"), + msg, + ) + + +@verb("wait_file") +def wait_file(ctx, step): + rel = ctx.resolve(str(step["path"])) + _await_exist(ctx, ctx.game_dir, [rel], step, f"file {rel} did not appear", 300) + + +@verb("wait_files") +def wait_files(ctx, step): + root = ctx.game_dir / ctx.resolve(str(step.get("root", ""))) + rels = [ctx.resolve(str(p)) for p in step.get("paths", [])] + _await_exist(ctx, root, rels, step, f"files did not all appear under {root}", 120) + + +@verb("verify_files") +def verify_files(ctx, step): + """Wait until every file declared in the scenario's ``serverFiles`` is present.""" + root = ctx.game_dir / ctx.resolve(str(step.get("root", "${modpack_dir}"))) + rels = [str(rel) for rel, _ in ctx.scenario_files] + _await_exist(ctx, root, rels, step, f"modpack files missing under {root}", 120) + + +@verb("verify_mods") +def verify_mods(ctx, step): + if not ctx.expected_mods: + return + mod_dir = ctx.game_dir / ctx.resolve(str(step.get("root", "${modpack_dir}/mods"))) + timeout = parse_duration(step.get("timeout"), default=120) + + def _all(): + mods = {p.name for p in mod_dir.glob("*.jar")} if mod_dir.exists() else set() + ok = all(any(fnmatch(m, pat) for m in mods) for pat in ctx.expected_mods) + return True if ok else None + + await_condition(_all, timeout, step.get("poll"), "expected mods missing") diff --git a/autotester/automodpack_autotester/engine/steps_ui.py b/autotester/automodpack_autotester/engine/steps_ui.py new file mode 100644 index 000000000..8f70d922a --- /dev/null +++ b/autotester/automodpack_autotester/engine/steps_ui.py @@ -0,0 +1,63 @@ +"""UI verbs: click, type/paste, wait_for, assert, sleep.""" +from __future__ import annotations + +import time + +from . import conditions, selectors +from .registry import verb +from .util import await_condition, parse_duration + + +def _await_element(ctx, selector, step, not_found): + """Poll the GUI until ``selector`` matches an element, or time out.""" + timeout = parse_duration(step.get("timeout"), default=30) + return await_condition( + lambda: selectors.find_one(ctx.gui(), selector), + timeout, + step.get("poll"), + not_found, + ) + + +@verb("click") +def click(ctx, step): + selector = dict(ctx.resolve(step.get("select") or {})) + selector.setdefault("enabled", True) # by default only click clickable elements + el = _await_element(ctx, selector, step, f"no element matched {selector!r}") + if step.get("enable"): + ctx.bridge.click(int(el["id"]), enable=True) + else: + ctx.bridge.click(int(el["id"])) + + +@verb("type", "paste") +def type_(ctx, step): + selector = dict(ctx.resolve(step.get("select") or {"role": "textfield"})) + value = str(ctx.resolve(step.get("value", ""))) + el = _await_element(ctx, selector, step, f"no text field matched {selector!r}") + ctx.bridge.text(int(el["id"]), value) + + +@verb("wait_for") +def wait_for(ctx, step): + cond = step.get("until") or {} + timeout = parse_duration(step.get("timeout"), default=60) + await_condition( + lambda: True if conditions.evaluate(ctx, cond) else None, + timeout, + step.get("poll"), + f"condition not met: {conditions.describe(cond)}", + ) + + +@verb("assert") +def assert_(ctx, step): + cond = step.get("that") or step.get("until") or {} + if not conditions.evaluate(ctx, cond): + raise AssertionError(f"assertion failed: {conditions.describe(cond)}") + + +@verb("sleep") +def sleep(ctx, step): + dur = parse_duration(step.get("duration") or step.get("seconds"), default=1) + time.sleep(dur or 0) diff --git a/autotester/automodpack_autotester/engine/util.py b/autotester/automodpack_autotester/engine/util.py new file mode 100644 index 000000000..4529b470b --- /dev/null +++ b/autotester/automodpack_autotester/engine/util.py @@ -0,0 +1,61 @@ +"""Shared helpers: duration parsing and the polling primitive used by every wait.""" +from __future__ import annotations + +import random +import time +from collections.abc import Callable +from typing import Any + + +class ClientExited(RuntimeError): + """The client container is gone. Raised through waits instead of being retried.""" + + +def parse_duration(value: Any, default: float | None = None) -> float | None: + """Parse ``90s`` / ``3m`` / ``500ms`` / ``180`` into seconds.""" + if value is None: + return float(default) if default is not None else None + if isinstance(value, (int, float)): + return float(value) + s = str(value).strip().lower() + try: + if s.endswith("ms"): + return float(s[:-2]) / 1000.0 + if s.endswith("s"): + return float(s[:-1]) + if s.endswith("m"): + return float(s[:-1]) * 60.0 + if s.endswith("h"): + return float(s[:-1]) * 3600.0 + return float(s) + except ValueError: + return float(default) if default is not None else None + + +def await_condition( + pred: Callable[[], Any], + timeout: float | None, + poll: Any = None, + msg: str = "condition not met", +) -> Any: + """Poll ``pred`` until it returns a non-None value or the timeout elapses. + + Transient bridge errors (``TimeoutError`` / ``RuntimeError``) are swallowed and + retried; :class:`ClientExited` is always re-raised so a dead client fails fast. + Scenario errors (``ValueError``/``AssertionError``) propagate immediately. + """ + interval = parse_duration(poll, default=0.5) or 0.5 + deadline = time.monotonic() + (timeout if timeout is not None else 60.0) + last_err: Exception | None = None + while time.monotonic() < deadline: + try: + result = pred() + if result is not None: + return result + except ClientExited: + raise + except (TimeoutError, RuntimeError) as e: + last_err = e + time.sleep(interval * random.uniform(0.8, 1.2)) + suffix = f" (last error: {last_err})" if last_err else "" + raise TimeoutError(f"{msg} within {timeout}s{suffix}") diff --git a/autotester/automodpack_autotester/runner.py b/autotester/automodpack_autotester/runner.py new file mode 100644 index 000000000..923858d64 --- /dev/null +++ b/autotester/automodpack_autotester/runner.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +import json +import logging +import os +import random +import secrets +import shutil +import time +from pathlib import Path + +import docker as docker_py + +from .bridge import BridgeClient +from .config import Target, load_macros, parse_server_files +from .engine import ClientExited, Context, run_flow +from .engine.registry import verb +from .engine.util import await_condition, parse_duration + + +logger = logging.getLogger(__name__) + +_docker = docker_py.from_env() + + +# ── low-level docker / container helpers ────────────────────────────────── + + +def _jitter_sleep(base, fraction=0.2): + time.sleep(random.uniform(base * (1 - fraction), base * (1 + fraction))) + + +def _container(name): + return _docker.containers.get(name) + + +def _container_logs(name, tail=None): + try: + kwargs = {} + if tail is not None: + kwargs["tail"] = tail + return _container(name).logs(**kwargs).decode("utf-8", errors="replace") + except docker_py.errors.NotFound: + return "" + + +def _remove_container(name): + try: + _container(name).remove(force=True) + except docker_py.errors.NotFound: + pass + + +def _ensure_network(name): + _remove_network(name) + _docker.networks.create(name, check_duplicate=True) + + +def _remove_network(name): + try: + _docker.networks.get(name).remove() + except docker_py.errors.NotFound: + pass + + +def _ensure_volume(name): + _docker.volumes.create(name) + + +def _remove_volume(name): + try: + _docker.volumes.get(name).remove() + except docker_py.errors.NotFound: + pass + + +def _run_container(name, image, network, env, mounts, command=None, user=None, entrypoint=None): + volumes = {} + for host, container_path, readonly in mounts: + volumes[str(host)] = {"bind": container_path, "mode": "ro" if readonly else "rw"} + kwargs = dict( + image=image, detach=True, name=name, network=network, + environment=dict(env), volumes=volumes, command=command, user=user, + ) + if entrypoint is not None: + kwargs["entrypoint"] = entrypoint + return _docker.containers.run(**kwargs) + + +def _assert_running(name): + c = _container(name) + c.reload() + state = c.attrs.get("State", {}) + if not state.get("Running", False): + raise RuntimeError( + f"Container {name} exited (code={state.get('ExitCode', -1)}, error={state.get('Error', '')})" + ) + + +def _inspect_container(name): + return _container(name).attrs + + +def _wait_for_log(name, needle, timeout): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if needle in _container_logs(name, tail=200): + return + _assert_running(name) + _jitter_sleep(2) + raise TimeoutError(f"Timeout waiting for {needle!r} in {name}") + + +def _wait_exited(name, timeout): + c = _container(name) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + c.reload() + if not c.attrs.get("State", {}).get("Running", False): + return + _jitter_sleep(1) + raise TimeoutError(f"Timeout waiting for {name} to exit") + + +def _uid(): + return int(os.environ.get("AUTOTEST_DOCKER_UID", os.getuid())) + + +def _gid(): + return int(os.environ.get("AUTOTEST_DOCKER_GID", os.getgid())) + + +def _load_ver(t): + if t.loader == "fabric": + return t.fabric_loader or "" + if t.loader == "forge": + return t.forge_version or "" + if t.loader == "neoforge": + return t.neoforge_version or "" + return "" + + +def _bridge_state(ctx: Context) -> Path: + return ctx.game_dir / "automodpack" / "autotest" / "bridge-state.json" + + +# ── server / client setup ───────────────────────────────────────────────── + + +def _prepare_server(ctx: Context): + srv_dir = ctx.server_dir + (srv_dir / "mods").mkdir(parents=True, exist_ok=True) + shutil.copy2(ctx.artifact, srv_dir / "mods" / "automodpack.jar") + cfg = dict(ctx.settings.get("automodpack", {}).get("config", {})) + cfg["modpackName"] = ctx.modpack_name + cfg["acceptedLoaders"] = [ctx.target.loader] + (srv_dir / "automodpack").mkdir(parents=True, exist_ok=True) + (srv_dir / "automodpack" / "automodpack-server.json").write_text(json.dumps(cfg, indent=2)) + host_root = srv_dir / "automodpack" / "host-modpack" / "main" + host_root.mkdir(parents=True, exist_ok=True) + (host_root / ctx.marker_rel).parent.mkdir(parents=True, exist_ok=True) + (host_root / ctx.marker_rel).write_text(json.dumps({"marker": ctx.modpack_name}) + "\n") + for rel, content in ctx.scenario_files: + f = host_root / rel + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(content) + + +def _launch_server(ctx: Context): + target, scenario, settings = ctx.target, ctx.scenario, ctx.settings + topo = scenario.get("topology", {}).get("server", {}) + srv_type = topo.get("type") or settings.get("serverTypes", {}).get(target.loader) + if not srv_type: + raise ValueError(f"No server type for {target.loader}") + + env = dict(settings.get("server", {}).get("env", {})) + env.update({ + "TYPE": str(srv_type), + "VERSION": target.minecraft, + "MEMORY": str(topo.get("memory", "2G")), + }) + for k, v in [ + ("fabric_loader", "FABRIC_LOADER_VERSION"), + ("forge_version", "FORGE_VERSION"), + ("neoforge_version", "NEOFORGE_VERSION"), + ]: + val = getattr(target, k, None) + if val: + env[v] = val + env.update({str(k): str(v) for k, v in (topo.get("env", {}) or {}).items()}) + mr = topo.get("modrinth", {}) + if mr: + projs = list( + dict.fromkeys( + str(p).strip() + for p in ( + list(mr.get("projects", [])) + + list((mr.get("projectsByLoader", {}) or {}).get(target.loader, []) or []) + ) + if p + ) + ) + if projs: + env["MODRINTH_PROJECTS"] = ",".join(projs) + if mr.get("version"): + env["MODRINTH_VERSION"] = str(mr["version"]) + if mr.get("versionType"): + env["MODRINTH_PROJECTS_DEFAULT_VERSION_TYPE"] = str(mr["versionType"]) + sc = topo.get("serverCache", {}) or settings.get("serverCache", {}) + if sc.get("enabled", True): + vol = f"{sc.get('volumePrefix', 'amp-server-cache')}-{target.id}" + if sc.get("clean", False): + _remove_volume(vol) + _ensure_volume(vol) + mounts = [(vol, "/data", False)] + for sub in ("mods", "automodpack"): + (ctx.server_dir / sub).mkdir(parents=True, exist_ok=True) + mounts.append((ctx.server_dir / sub, f"/data/{sub}", False)) + else: + mounts = [(ctx.server_dir, "/data", False)] + img = str(topo.get("image") or settings.get("images", {}).get("server", "itzg/minecraft-server")) + if ":" not in img: + tag = str(settings.get("images", {}).get("serverTagTemplate", "java{java}")).format(java=target.java) + img = f"{img}:{tag}" + _run_container(name=ctx.srv_name, image=img, network=ctx.net_name, env=env, mounts=mounts) + + +def _launch_client(ctx: Context): + game_dir = ctx.game_dir + (game_dir / "mods").mkdir(parents=True, exist_ok=True) + shutil.copy2(ctx.artifact, game_dir / "mods" / "automodpack.jar") + (game_dir / "options.txt").write_text("narrator:0\n") + _bridge_state(ctx).unlink(missing_ok=True) + + # Per-target HMC cache (isolated to prevent concurrent NeoForge installer corruption) + hmc_cache_root = (ctx.out_dir.parent / ".hmc-cache" / ctx.target.id.replace(".", "_")).resolve() + hmc_cache_root.mkdir(parents=True, exist_ok=True) + + client_run_seconds = int(float( + ctx.scenario.get("timeouts", {}).get( + "clientRunSeconds", + ctx.settings.get("timeouts", {}).get("clientRunSeconds", 600), + ) + )) + _run_container( + name=ctx.cli_name, + image=ctx.client_image, + network=ctx.net_name, + env={ + "AM_AUTOTEST_BRIDGE_TOKEN": ctx.token, + "AM_AUTOTEST_GAME_DIR": "/work/game", + "AM_AUTOTEST_HMC_CACHE_DIR": "/work/hmc-cache", + "AM_AUTOTEST_CLIENT_TIMEOUT_SECONDS": str(client_run_seconds), + }, + mounts=[ + (game_dir, "/work/game", False), + (hmc_cache_root, "/work/hmc-cache", False), + ], + command=[ + "/opt/automodpack/run-headlessmc-client", + ctx.target.loader, + ctx.target.minecraft, + "localhost", + "25565", + str(ctx.target.java), + _load_ver(ctx.target), + ], + user=f"{_uid()}:{_gid()}", + ) + _jitter_sleep(1) + _assert_running(ctx.cli_name) + + +# ── lifecycle verbs (need Docker; pure UI/IO verbs live in engine/) ─────── + + +@verb("launch_server") +def _v_launch_server(ctx: Context, step): + _launch_server(ctx) + + +@verb("wait_server") +def _v_wait_server(ctx: Context, step): + to = ctx.scenario.get("timeouts", {}) or ctx.settings.get("timeouts", {}) + timeout = parse_duration(step.get("timeout"), default=float(to.get("serverStartSeconds", 180))) + _wait_for_log(ctx.srv_name, "Done (", timeout=timeout) + + +@verb("launch_client") +def _v_launch_client(ctx: Context, step): + _remove_container(ctx.cli_name) + ctx.bridge = None + _launch_client(ctx) + + +@verb("wait_bridge") +def _v_wait_bridge(ctx: Context, step): + if ctx.bridge is None: + ctx.bridge = BridgeClient(ctx.game_dir, ctx.token) + timeout = parse_duration( + step.get("timeout"), + default=float(ctx.scenario.get("timeouts", {}).get("clientStartSeconds", 180)), + ) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + _assert_running(ctx.cli_name) + except RuntimeError as e: + logs = _container_logs(ctx.cli_name) + raise TimeoutError(f"Client exited before bridge: {e}\n--- logs ---\n{logs[-2000:]}") + try: + sf = _bridge_state(ctx) + if sf.exists(): + data = json.loads(sf.read_text(encoding="utf-8")) + if data.get("status") == "ready": + ctx.bridge.request("ping", timeout=5) + return + except Exception: + pass + _jitter_sleep(1) + raise TimeoutError(f"Bridge for {ctx.target.id} did not become available within {timeout}s") + + +@verb("connect") +def _v_connect(ctx: Context, step): + host = ctx.resolve(step.get("host") or ctx.srv_name) + port = int(step.get("port", 25565)) + timeout = parse_duration(step.get("timeout"), default=90) + deadline = time.monotonic() + timeout + _TITLE = ("TitleScreen", "class_442") + _CONNECT = ("ConnectScreen", "class_397") + while time.monotonic() < deadline: + _assert_running(ctx.cli_name) + ctx.bridge.connect(host, port) + poll_dl = time.monotonic() + min(deadline - time.monotonic(), 45) + while time.monotonic() < poll_dl: + screen = str(ctx.bridge.gui().get("screenClass") or "") + if any(n in screen for n in _TITLE): + break + if not any(n in screen for n in _CONNECT): + return + _jitter_sleep(0.5) + ctx.bridge.request("disconnect") + _jitter_sleep(1) + raise RuntimeError(f"Could not connect to {host}:{port} after multiple attempts") + + +@verb("disconnect") +def _v_disconnect(ctx: Context, step): + try: + if ctx.bridge is not None: + ctx.bridge.request("disconnect") + except (RuntimeError, TimeoutError): + pass + + +@verb("quit") +def _v_quit(ctx: Context, step): + try: + state = _inspect_container(ctx.cli_name).get("State", {}) + if state.get("Running", False) and ctx.bridge is not None: + ctx.bridge.request("quit") + except (RuntimeError, TimeoutError): + pass + + +@verb("wait_client_exit") +def _v_wait_client_exit(ctx: Context, step): + timeout = parse_duration(step.get("timeout"), default=90) + _wait_exited(ctx.cli_name, timeout=timeout) + + +@verb("wait_join") +def _v_wait_join(ctx: Context, step): + timeout = parse_duration( + step.get("timeout"), + default=float(ctx.scenario.get("timeouts", {}).get("rejoinSeconds", 180)), + ) + await_condition( + lambda: True if ctx.gui(timeout=10).get("screenClass") is None else None, + timeout, + step.get("poll"), + f"{ctx.target.id}: player did not reach in-game", + ) + + +# ── case orchestration ──────────────────────────────────────────────────── + + +def run_case( + target: Target, + scenario: dict, + *, + out_dir: Path, + artifact_dir: Path, + client_image: str, + settings: dict, +) -> dict: + started = time.monotonic() + scenario_id = scenario.get("id", "?") + case_dir = out_dir / f"{target.id}-{int(time.time())}-{secrets.token_hex(3)}" + server_dir = case_dir / "server" + game_dir = case_dir / "client" / "game" + net_name = f"amp-{secrets.token_hex(4)}"[:63] + srv_name = f"amp-s-{secrets.token_hex(4)}"[:63] + cli_name = f"amp-c-{secrets.token_hex(4)}"[:63] + token = secrets.token_hex(16) + + for d in (server_dir, game_dir): + d.mkdir(parents=True, exist_ok=True) + + step_results: list[dict] = [] + try: + pattern = f"automodpack-mc{target.minecraft}-{target.loader}-*.jar" + matches = sorted(artifact_dir.glob(pattern)) + if not matches: + raise FileNotFoundError(f"No artifact for {target.id} in {artifact_dir}") + artifact = matches[-1].resolve() + + sf = parse_server_files(scenario) + + ctx = Context( + target=target, + scenario=scenario, + settings=settings, + game_dir=game_dir, + server_dir=server_dir, + out_dir=out_dir, + client_image=client_image, + srv_name=srv_name, + cli_name=cli_name, + net_name=net_name, + token=token, + artifact=artifact, + modpack_name=sf.modpack_name, + marker_rel=sf.marker, + scenario_files=sf.files, + expected_mods=sf.expected_mods, + vars=dict(scenario.get("vars", {}) or {}), + ) + ctx.logs_provider = lambda which, tail=None: _container_logs( + srv_name if which == "server" else cli_name, tail=tail + ) + + def _running(): + try: + _assert_running(cli_name) + except RuntimeError as e: + raise ClientExited(str(e)) + + ctx.running_provider = _running + + _prepare_server(ctx) + _ensure_network(net_name) + + run_flow(ctx, scenario, lib=load_macros(), results=step_results) + + return { + "target": target.id, + "scenario": scenario_id, + "ok": True, + "duration": time.monotonic() - started, + "steps": step_results, + } + + except Exception as e: + return { + "target": target.id, + "scenario": scenario_id, + "ok": False, + "duration": time.monotonic() - started, + "error": str(e), + "steps": step_results, + } + + finally: + for name in [cli_name, srv_name]: + try: + logs = _container_logs(name) + if logs: + (case_dir / f"{name}.log").write_text(logs, encoding="utf-8", errors="replace") + except Exception: + pass + try: + _remove_container(name) + except Exception: + logger.warning("Failed to remove container %s", name) + _remove_network(net_name) diff --git a/autotester/docker/client/Dockerfile b/autotester/docker/client/Dockerfile new file mode 100644 index 000000000..e188a25e0 --- /dev/null +++ b/autotester/docker/client/Dockerfile @@ -0,0 +1,77 @@ +# Git repository and ref the HeadlessMC launcher is built from. These are +# normally supplied by `autotester build-images` from settings.yaml +# (headlessmc.repo/ref). The defaults below are only a fallback for a bare +# `docker build` and point at upstream HeadlessMC; set headlessmc.repo/ref in +# settings.yaml to build a patched launcher (e.g. for MC 26.2 headless). +ARG HEADLESSMC_REPO=https://github.com/headlesshq/headlessmc.git +ARG HEADLESSMC_REF=main + +# ── Builder: compile the HeadlessMC launcher wrapper jar ────────────── +# HeadlessMC must be built with JDK 21 (its Gradle build rejects newer JDKs). +FROM eclipse-temurin:21-jdk AS hmc +ARG HEADLESSMC_REPO +ARG HEADLESSMC_REF +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /build +# Fetch by ref so HEADLESSMC_REF can be a branch, tag, or commit SHA (a shallow +# `clone --branch` only accepts branch/tag names — fetching FETCH_HEAD also takes SHAs). +RUN git init -q . \ + && git remote add origin "${HEADLESSMC_REPO}" \ + && git fetch --depth 1 origin "${HEADLESSMC_REF}" \ + && git checkout -q FETCH_HEAD \ + && ./gradlew --no-daemon headlessmc-launcher-wrapper:build -x test \ + && jar="$(find headlessmc-launcher-wrapper/build/libs -maxdepth 1 \ + -name 'headlessmc-launcher-wrapper-*.jar' \ + ! -name '*-dev.jar' ! -name '*-javadoc.jar' ! -name '*-sources.jar' \ + | head -n1)" \ + && test -n "$jar" \ + && cp "$jar" /headlessmc-launcher-wrapper.jar + +# ── Runtime client image ────────────────────────────────────────────── +FROM ubuntu:26.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV HMC_HOME=/work/hmc-cache + +# Install Java 17, 21, 25 from repos +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + unzip \ + bash \ + procps \ + libatomic1 \ + libx11-6 \ + libxext6 \ + libxrender1 \ + libxtst6 \ + libxi6 \ + libxrandr2 \ + libxxf86vm1 \ + libgl1 \ + libglx-mesa0 \ + libasound2t64 \ + openjdk-17-jre-headless \ + openjdk-21-jre-headless \ + openjdk-25-jre-headless \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /opt/headlessmc /opt/automodpack /work/game /work/hmc-cache \ + /work/HeadlessMC /work/hmc-shared-versions \ + && chmod a+rwx /work/HeadlessMC /work/hmc-shared-versions + +# Install the HeadlessMC launcher built above as the `hmc` command. +COPY --from=hmc /headlessmc-launcher-wrapper.jar \ + /opt/headlessmc/headlessmc-launcher-wrapper.jar +RUN printf '#!/usr/bin/env bash\nexec java -jar /opt/headlessmc/headlessmc-launcher-wrapper.jar "$@"\n' \ + > /usr/local/bin/hmc \ + && chmod +x /usr/local/bin/hmc + +COPY run-headlessmc-client /opt/automodpack/run-headlessmc-client +RUN chmod +x /opt/automodpack/run-headlessmc-client + +WORKDIR /work +ENTRYPOINT [] diff --git a/autotester/docker/client/run-headlessmc-client b/autotester/docker/client/run-headlessmc-client new file mode 100644 index 000000000..78947fc4f --- /dev/null +++ b/autotester/docker/client/run-headlessmc-client @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +loader="${1:?loader required}" +minecraft="${2:?minecraft version required}" +host="${3:?server host required}" +port="${4:?server port required}" +java_version="${5:?java version required}" +loader_version="${6:-}" + +game_dir="${AM_AUTOTEST_GAME_DIR:-/work/game}" +cache_dir="${AM_AUTOTEST_HMC_CACHE_DIR:-/work/hmc-cache}" +bridge_token="${AM_AUTOTEST_BRIDGE_TOKEN:?bridge token required}" +client_timeout="${AM_AUTOTEST_CLIENT_TIMEOUT_SECONDS:-600}" + +mkdir -p "$game_dir" "$cache_dir" + +# ── Share client-jar cache across targets ────────────────────────────── +shared_versions="/work/hmc-shared-versions" +mkdir -p "$shared_versions" +if [ ! -L "$cache_dir/versions" ]; then + rm -rf "$cache_dir/versions" 2>/dev/null || true + ln -s "$shared_versions" "$cache_dir/versions" +fi + +# ── Find the right Java binary ───────────────────────────────────── +java_bin="" +for dir in /usr/lib/jvm/*"${java_version}"*/bin/java; do + if [ -x "$dir" ]; then + java_bin="$dir" + break + fi +done + +if [ -z "$java_bin" ] && command -v java &>/dev/null; then + java_bin="$(command -v java)" +fi + +if [ -z "$java_bin" ] || [ ! -x "$java_bin" ]; then + echo "ERROR: Java ${java_version} not found" >&2 + echo "Searched /usr/lib/jvm/*${java_version}* and PATH" >&2 + ls -la /usr/lib/jvm/ 2>/dev/null || echo "(no /usr/lib/jvm)" >&2 + exit 1 +fi + +actual_ver=$("$java_bin" -version 2>&1 | head -1 | sed 's/.*version "\([0-9]*\).*/\1/') +echo "Java: $("$java_bin" -version 2>&1 | head -1) ($java_bin)" +if [ "$actual_ver" != "$java_version" ]; then + echo "WARNING: Requested Java ${java_version}, using Java ${actual_ver}" >&2 +fi + +export JAVA_HOME="${java_bin%/bin/java}" +export PATH="$JAVA_HOME/bin:$PATH" + +# ── Build JVM arguments ─────────────────────────────────────────── +jvmargs="-Dautomodpack.autotest=true -Dautomodpack.autotest.gamedir=${game_dir} -Dautomodpack.autotest.token=${bridge_token} -Dcom.mojang.text2speech=false" +gameargs="--width 854 --height 480" + +# ── Write HMC config ────────────────────────────────────────────── +cat > /work/HeadlessMC/config.properties <-). Forge/NeoForge need no install here — HMC +# resolves and installs them on demand from the `:` launch target. +launch_target="${loader}:${minecraft}" +if [ "$loader" = "fabric" ] && [ -n "$loader_version" ]; then + timeout 30 hmc --command "fabric ${minecraft} --uid ${loader_version}" 2>/dev/null || true + launch_target="fabric-loader-${loader_version}-${minecraft}" +fi + +# ── Disable Neo/Forge early display window (may cause crash in CI) ── +mkdir -p "$game_dir/config" +cat > "$game_dir/config/fml.toml" </dev/null || true +hmc --command "config -refresh" 2>/dev/null || true + +# ── Launch ───────────────────────────────────────────────────────── +exec timeout "$client_timeout" hmc \ + --command "launch ${launch_target} -lwjgl -offline --jvm \"${jvmargs}\" ${gameargs}" diff --git a/autotester/pyproject.toml b/autotester/pyproject.toml new file mode 100644 index 000000000..55b5cf95d --- /dev/null +++ b/autotester/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "automodpack-autotest" +version = "0.1.0" +description = "Docker-first in-game integration tests for AutoModpack" +requires-python = ">=3.11" +dependencies = [ + "docker>=7.1.0", + "PyYAML>=6.0.1", +] + +[project.scripts] +autotester = "automodpack_autotester.cli:main" + +[dependency-groups] +dev = ["pytest>=8.0"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.setuptools.packages.find] +where = ["."] +include = ["automodpack_autotester*"] +namespaces = true diff --git a/autotester/scenarios/_lib.yaml b/autotester/scenarios/_lib.yaml new file mode 100644 index 000000000..684d450e3 --- /dev/null +++ b/autotester/scenarios/_lib.yaml @@ -0,0 +1,92 @@ +# Shared, reusable step sequences (macros). Reference one from a scenario's +# `flow` by name (a bare string) or with `{ use: }`. Files starting with +# `_` are libraries, not scenarios, so the runner never executes this directly. +# +# Steps are declarative: every entry is either a bare verb/macro name, or a +# mapping with a `do:` verb plus its arguments. `${...}` templates expand against +# the scenario context (target, server, modpack, marker, and any captured vars). + +# Launch the server and client together (client boots while the server starts), +# capture the server's TLS fingerprint, then connect once both are ready. +boot: + - do: launch_server + name: start server + - do: launch_client + name: start client (boots in parallel with the server) + - use: read_server_fingerprint + - do: wait_server + name: wait for server to finish loading + - do: wait_bridge + name: wait for the client test bridge + - use: connect_to_server + +# Scrape the one-time certificate fingerprint out of the server log into a var. +read_server_fingerprint: + - do: wait_for + name: read TLS fingerprint from server log + timeout: 180s + until: + log: + container: server + matches: '(?:certificate\s+)?fingerprint[:\s]+([0-9A-Fa-f:]+)' + capture: { fingerprint: 1 } + +connect_to_server: + - do: connect + name: connect to the server + +# Enter the captured fingerprint on the trust prompt and verify it. +accept_certificate: + - do: wait_for + name: wait for the certificate prompt + timeout: 180s + until: + all: + - element: { role: textfield } + - element: { text: verify, enabled: true } + - do: type + name: enter the fingerprint + select: { role: textfield } + value: "${fingerprint}" + - do: click + name: click verify + select: { text: verify } + - do: wait_for + name: wait for the certificate to be accepted + timeout: 20s + until: + no_element: { text: verify } + +# Confirm the download prompt and wait for the modpack to land on disk. +download_modpack: + - do: wait_for + name: wait for the download prompt + timeout: 90s + until: + element: { text: download, enabled: true } + - do: click + name: confirm download + select: { text: download } + - do: wait_file + name: wait for the modpack marker file + path: "${modpack_dir}/${marker}" + timeout: 300s + +# Click the post-download "restart required" button and wait for the client to exit. +restart_client: + - do: click + name: confirm restart + select: { text_any: ["close the game", "restart", "quit"] } + timeout: 20s + - do: wait_client_exit + name: wait for the client to close + +# Relaunch the client and play through to in-game (used to confirm a synced modpack loads). +rejoin: + - do: launch_client + name: relaunch the client + - do: wait_bridge + name: wait for the client test bridge + - use: connect_to_server + - do: wait_join + name: wait until the player is in-game diff --git a/autotester/scenarios/download-only.yaml b/autotester/scenarios/download-only.yaml new file mode 100644 index 000000000..fd0305444 --- /dev/null +++ b/autotester/scenarios/download-only.yaml @@ -0,0 +1,34 @@ +id: download-only +description: | + Boot server + client, trust the certificate, sync the modpack, and verify every + hosted file landed on the client. Does not restart or rejoin. + +flow: + - use: boot + - use: accept_certificate + - use: download_modpack + - do: verify_files + name: verify all synced files are present + - do: quit + name: quit the client + +topology: + server: + memory: 2G + env: + ENABLE_ROLLING_LOGS: "false" + modrinth: + projects: + - ferrite-core? + dependencies: true + +serverFiles: + modpackName: amp-autotest + marker: config/amp-autotest-marker.json + files: + - path: config/amp-autotest-alpha.txt + content: "amp-autotest-alpha\n" + - path: config/amp-autotest-beta.json + content: '{"id":"beta","value":42}' + - path: config/amp-autotest-gamma.cfg + content: "alpha=true\nbeta=false\n" diff --git a/autotester/scenarios/sync.yaml b/autotester/scenarios/sync.yaml new file mode 100644 index 000000000..ccc7ee583 --- /dev/null +++ b/autotester/scenarios/sync.yaml @@ -0,0 +1,37 @@ +id: sync +description: | + Full round trip: boot, trust the certificate, sync the modpack, verify the files, + restart the client, rejoin, and confirm the player reaches the game with the + modpack applied. + +flow: + - use: boot + - use: accept_certificate + - use: download_modpack + - do: verify_files + name: verify all synced files are present + - use: restart_client + - use: rejoin + - do: quit + name: quit the client + +topology: + server: + memory: 2G + env: + ENABLE_ROLLING_LOGS: "false" + modrinth: + projects: + - ferrite-core? + dependencies: true + +serverFiles: + modpackName: amp-autotest + marker: config/amp-autotest-marker.json + files: + - path: config/amp-autotest-alpha.txt + content: "amp-autotest-alpha\n" + - path: config/amp-autotest-beta.json + content: '{"id":"beta","value":42}' + - path: config/amp-autotest-gamma.cfg + content: "alpha=true\nbeta=false\n" diff --git a/autotester/settings.yaml b/autotester/settings.yaml new file mode 100644 index 000000000..a0b024763 --- /dev/null +++ b/autotester/settings.yaml @@ -0,0 +1,88 @@ +paths: + artifactDir: merged + outDir: autotester/out + +images: + server: itzg/minecraft-server + proxy: itzg/mc-proxy + client: automodpack-autotest-client:local + serverTagTemplate: "java{java}" + +run: + target: all + scenario: sync + jobs: 1 + +server: + memory: 2G + nameTemplate: "amp-{target_id}" + env: + EULA: "TRUE" + ONLINE_MODE: "FALSE" + ENABLE_AUTOPAUSE: "FALSE" + MOTD: "amp-autotest" + DIFFICULTY: "peaceful" + SPAWN_ANIMALS: "false" + SPAWN_MONSTERS: "false" + SPAWN_NPCS: "false" + GENERATE_STRUCTURES: "false" + LEVEL_TYPE: "flat" + GENERATOR_SETTINGS: "{\"lakes\":false,\"layers\":[{\"block\":\"minecraft:stone\",\"height\":1}],\"biome\":\"minecraft:plains\",\"structures\":{\"structures\":{}}}" + SPAWN_RADIUS: "0" + +serverTypes: + fabric: FABRIC + forge: FORGE + neoforge: NEOFORGE + +headlessmc: + # Git repository and ref the HeadlessMC launcher is built from. The default + # builds the patch required to launch MC 26.2 headlessly; point these at a + # different repo/ref to use another HeadlessMC build. ref may be a branch, tag + # or commit SHA — pinned to a SHA here for reproducible builds (this is the + # head of the mc26.2-headless branch; bump it when the fork moves, or switch + # to the upstream tag once the patch is merged upstream). + repo: "https://github.com/Skidamek/headlessmc.git" + ref: "64d3c126e72bbfccf95e71afaa6536f50bc64097" + +timeouts: + serverStartSeconds: 180 + clientStartSeconds: 180 + clientRunSeconds: 300 + downloadFileSeconds: 180 + rejoinSeconds: 90 + +serverCache: + enabled: true + volumePrefix: "amp-server-cache" + clean: false + +automodpack: + config: + DO_NOT_CHANGE_IT: 2 + modpackHost: true + generateModpackOnStart: true + syncedFiles: + - "/mods/*.jar" + - "/kubejs/**" + - "!/kubejs/server_scripts/**" + - "/emotes/*" + allowEditsInFiles: + - "/config/**" + forceCopyFilesToStandardLocation: [] + nonModpackFilesToDelete: {} + autoExcludeServerSideMods: true + autoExcludeUnnecessaryFiles: true + requireAutoModpackOnClient: true + nagUnModdedClients: false + bindAddress: "" + bindPort: -1 + addressToSend: "" + portToSend: -1 + disableInternalTLS: false + requireMagicPackets: false + updateIpsOnEveryStart: false + bandwidthLimit: 0 + validateSecrets: true + secretLifetime: 336 + selfUpdater: false diff --git a/autotester/targets.yaml b/autotester/targets.yaml new file mode 100644 index 000000000..70a8d231c --- /dev/null +++ b/autotester/targets.yaml @@ -0,0 +1,27 @@ +defaults: + artifactPattern: "automodpack-mc{minecraft}-{loader}-*.jar" + fabricLoader: "0.17.3" + +targets: + - { id: "26.2-fabric", minecraft: "26.2", loader: "fabric", java: 25, fabricLoader: "0.19.3" } + - { id: "26.2-neoforge", minecraft: "26.2", loader: "neoforge", java: 25, neoforgeVersion: "26.2.0.7-beta" } + - { id: "26.1-fabric", minecraft: "26.1", loader: "fabric", java: 25, fabricLoader: "0.18.4" } + - { id: "26.1-neoforge", minecraft: "26.1", loader: "neoforge", java: 25, neoforgeVersion: "26.1.2.64-beta" } + - { id: "1.21.11-fabric", minecraft: "1.21.11", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.11-neoforge", minecraft: "1.21.11", loader: "neoforge", java: 21, neoforgeVersion: "21.11.42" } + - { id: "1.21.10-fabric", minecraft: "1.21.10", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.10-neoforge", minecraft: "1.21.10", loader: "neoforge", java: 21, neoforgeVersion: "21.10.64" } + - { id: "1.21.8-fabric", minecraft: "1.21.8", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.8-neoforge", minecraft: "1.21.8", loader: "neoforge", java: 21, neoforgeVersion: "21.8.50" } + - { id: "1.21.5-fabric", minecraft: "1.21.5", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.5-neoforge", minecraft: "1.21.5", loader: "neoforge", java: 21, neoforgeVersion: "21.5.97" } + - { id: "1.21.4-fabric", minecraft: "1.21.4", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.4-neoforge", minecraft: "1.21.4", loader: "neoforge", java: 21, neoforgeVersion: "21.4.157" } + - { id: "1.21.1-fabric", minecraft: "1.21.1", loader: "fabric", java: 21, fabricLoader: "0.17.3" } + - { id: "1.21.1-neoforge", minecraft: "1.21.1", loader: "neoforge", java: 21, neoforgeVersion: "21.1.230" } + - { id: "1.20.1-fabric", minecraft: "1.20.1", loader: "fabric", java: 17, fabricLoader: "0.17.3" } + - { id: "1.20.1-forge", minecraft: "1.20.1", loader: "forge", java: 17, forgeVersion: "47.3.0" } + - { id: "1.19.2-fabric", minecraft: "1.19.2", loader: "fabric", java: 17, fabricLoader: "0.17.3" } + - { id: "1.19.2-forge", minecraft: "1.19.2", loader: "forge", java: 17, forgeVersion: "43.3.0" } + - { id: "1.18.2-fabric", minecraft: "1.18.2", loader: "fabric", java: 17, fabricLoader: "0.17.3" } + - { id: "1.18.2-forge", minecraft: "1.18.2", loader: "forge", java: 17, forgeVersion: "40.2.10" } diff --git a/autotester/tests/__init__.py b/autotester/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/autotester/tests/conftest.py b/autotester/tests/conftest.py new file mode 100644 index 000000000..36b725255 --- /dev/null +++ b/autotester/tests/conftest.py @@ -0,0 +1,131 @@ +"""Shared fixtures: a Docker-free Context factory and a scriptable fake bridge. + +These let the engine (selectors, conditions, templating, verbs, executor) be +exercised end to end without Docker, HeadlessMC, or a real Minecraft server. +""" +from __future__ import annotations + +import types +from pathlib import Path + +import pytest + +from automodpack_autotester.engine import Context + + +@pytest.fixture +def make_ctx(tmp_path): + """Build a Context backed by tmp dirs. Override any field via kwargs.""" + + def _make(**overrides) -> Context: + game_dir = overrides.pop("game_dir", tmp_path / "game") + game_dir.mkdir(parents=True, exist_ok=True) + defaults = dict( + target=types.SimpleNamespace( + id="1.21-fabric", minecraft="1.21", loader="fabric", java=21 + ), + scenario={}, + settings={}, + game_dir=game_dir, + server_dir=tmp_path / "server", + out_dir=tmp_path / "out", + client_image="img", + srv_name="srv-container", + cli_name="cli-container", + net_name="net", + token="tok", + artifact=tmp_path / "automodpack.jar", + modpack_name="amp-autotest", + marker_rel=Path("config/amp-autotest-marker.json"), + scenario_files=[], + expected_mods=[], + ) + defaults.update(overrides) + ctx = Context(**defaults) + ctx.running_provider = lambda: None # client is always "running" in tests + return ctx + + return _make + + +class FakeBridge: + """A tiny GUI state machine that mimics the real client over the file bridge. + + Screens: title -> cert -> download -> restart -> (relaunch) -> ingame. + Clicking the download button writes the modpack files into the game dir, so + the filesystem verbs see real files appear exactly as they would in Docker. + """ + + def __init__(self, ctx: Context): + self.ctx = ctx + self.screen = "title" + self.fingerprint: str | None = None + self.synced = False + self.exited = False + self.clicks: list[int] = [] + self.typed: dict[int, str] = {} + + # --- snapshot --------------------------------------------------------- + def gui(self, timeout: float = 30) -> dict: + snapshots = { + "title": {"screenClass": "TitleScreen", "buttons": [], "textFields": []}, + "cert": { + "screenClass": "CertScreen", + "buttons": [{"id": 2, "text": "Verify", "enabled": True, "visible": True}], + "textFields": [{"id": 1, "text": "", "enabled": True, "visible": True}], + }, + "download": { + "screenClass": "DownloadScreen", + "buttons": [{"id": 3, "text": "Download", "enabled": True, "visible": True}], + "textFields": [], + }, + "restart": { + "screenClass": "RestartScreen", + "buttons": [{"id": 4, "text": "Close the game", "enabled": True, "visible": True}], + "textFields": [], + }, + "ingame": {"screenClass": None, "buttons": [], "textFields": []}, + } + return snapshots[self.screen] + + # --- actions ---------------------------------------------------------- + def text(self, element_id: int, value: str, timeout: float = 30) -> dict: + self.typed[element_id] = value + if element_id == 1: + self.fingerprint = value + return {"ok": True} + + def click(self, element_id: int, timeout: float = 30, **payload) -> dict: + self.clicks.append(element_id) + if element_id == 2 and self.fingerprint: + self.screen = "download" + elif element_id == 3: + self._write_modpack() + self.screen = "restart" + elif element_id == 4: + self.exited = True + return {"ok": True} + + def connect(self, host: str, port: int = 25565, timeout: float = 30) -> dict: + # Already-synced clients drop straight in-game; first contact hits the cert prompt. + self.screen = "ingame" if self.synced else "cert" + return {"ok": True} + + def request(self, op: str, timeout: float = 30, **payload) -> dict: + if op == "disconnect": + self.screen = "title" + elif op == "quit": + self.exited = True + return {"ok": True} + + # --- helpers ---------------------------------------------------------- + def _write_modpack(self) -> None: + root = self.ctx.game_dir / "automodpack" / "modpacks" / self.ctx.modpack_name + marker = root / self.ctx.marker_rel + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("{}") + for rel, content in self.ctx.scenario_files: + f = root / rel + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(content) + self.synced = True diff --git a/autotester/tests/test_flows.py b/autotester/tests/test_flows.py new file mode 100644 index 000000000..ea8583bd9 --- /dev/null +++ b/autotester/tests/test_flows.py @@ -0,0 +1,142 @@ +"""End-to-end engine tests: run the *real* shipped scenarios and macro library +through a fake bridge, with the Docker lifecycle verbs stubbed out. + +This proves the declarative pipeline (config -> macros -> executor -> verbs -> +selectors/conditions/templating) works against the actual scenario files, with +no Docker, HeadlessMC, or Minecraft server involved. +""" +from __future__ import annotations + +import pytest + +from automodpack_autotester.config import load_macros, load_scenarios, parse_server_files +from automodpack_autotester.engine import run_flow +from automodpack_autotester.engine.registry import verb + +from .conftest import FakeBridge + + +# ── stub the lifecycle verbs the runner normally provides (need Docker) ──── + + +@verb("launch_server", "wait_server") +def _noop(ctx, step): + pass + + +@verb("launch_client") +def _launch_client(ctx, step): + ctx.bridge.exited = False # a fresh client process is running + + +@verb("wait_bridge") +def _wait_bridge(ctx, step): + assert ctx.bridge is not None + + +@verb("connect") +def _connect(ctx, step): + ctx.bridge.connect(ctx.srv_name, int(step.get("port", 25565))) + + +@verb("quit") +def _quit(ctx, step): + ctx.bridge.request("quit") + + +@verb("disconnect") +def _disconnect(ctx, step): + ctx.bridge.request("disconnect") + + +@verb("wait_client_exit") +def _wait_client_exit(ctx, step): + assert ctx.bridge.exited, "client did not exit after restart" + + +@verb("wait_join") +def _wait_join(ctx, step): + assert ctx.gui().get("screenClass") is None, "player never reached in-game" + + +# ── helpers ─────────────────────────────────────────────────────────────── + + +def _ctx_for(make_ctx, scenario: dict): + sf = parse_server_files(scenario) + ctx = make_ctx( + scenario=scenario, + modpack_name=sf.modpack_name, + marker_rel=sf.marker, + scenario_files=sf.files, + expected_mods=sf.expected_mods, + ) + ctx.bridge = FakeBridge(ctx) + ctx.logs_provider = lambda which, tail=None: ( + "[Server thread/INFO]: Certificate fingerprint: AB:CD:EF:01:23" + if which == "server" + else "" + ) + return ctx + + +# ── tests ───────────────────────────────────────────────────────────────── + + +def test_download_only_flow(make_ctx): + scenario = load_scenarios()["download-only"] + ctx = _ctx_for(make_ctx, scenario) + + results = run_flow(ctx, scenario, lib=load_macros()) + + assert all(r["ok"] for r in results), [r for r in results if not r["ok"]] + assert ctx.bridge.fingerprint == "AB:CD:EF:01:23" + root = ctx.game_dir / "automodpack" / "modpacks" / ctx.modpack_name + for rel, _ in ctx.scenario_files: + assert (root / rel).exists(), f"missing synced file {rel}" + + +def test_sync_flow_round_trip(make_ctx): + scenario = load_scenarios()["sync"] + ctx = _ctx_for(make_ctx, scenario) + + results = run_flow(ctx, scenario, lib=load_macros()) + + assert all(r["ok"] for r in results), [r for r in results if not r["ok"]] + names = [r["name"] for r in results] + # the round trip really restarted and rejoined + assert any("relaunch" in n for n in names), names + assert any("in-game" in n for n in names), names + assert ctx.bridge.exited # final quit + + +def test_scenarios_only_reference_known_verbs(): + """Static guard: every verb named in the shipped scenarios/macros exists.""" + from automodpack_autotester.engine.registry import VERBS + + macros = load_macros() + scenarios = load_scenarios() + + def verbs_in(steps): + for raw in steps: + if isinstance(raw, str): + if raw not in macros: + yield raw + elif isinstance(raw, dict): + if "do" in raw: + yield raw["do"] + for key in ("steps",): + if isinstance(raw.get(key), list): + yield from verbs_in(raw[key]) + + used: set[str] = set() + for seq in macros.values(): + used.update(verbs_in(seq)) + for sc in scenarios.values(): + used.update(verbs_in(sc.get("flow", []))) + for name in sc.get("flow", []): + if isinstance(name, dict) and "use" in name: + used.update(verbs_in(macros.get(name["use"], []))) + + unknown = {v for v in used if v not in VERBS} + assert not unknown, f"scenarios reference unregistered verbs: {unknown}" diff --git a/autotester/tests/test_unit.py b/autotester/tests/test_unit.py new file mode 100644 index 000000000..ae0b9340f --- /dev/null +++ b/autotester/tests/test_unit.py @@ -0,0 +1,259 @@ +"""Unit tests for the declarative engine: parsing, selectors, conditions, +templating, polling, and the flow executor — all Docker-free.""" +from __future__ import annotations + +import time + +import pytest + +from automodpack_autotester.engine import conditions, run_flow, selectors +from automodpack_autotester.engine.registry import verb +from automodpack_autotester.engine.util import ClientExited, await_condition, parse_duration + + +# ── parse_duration ──────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "value,expected", + [ + ("90s", 90.0), + ("3m", 180.0), + ("500ms", 0.5), + ("2h", 7200.0), + ("180", 180.0), + (45, 45.0), + (1.5, 1.5), + ], +) +def test_parse_duration_values(value, expected): + assert parse_duration(value) == expected + + +def test_parse_duration_default_and_invalid(): + assert parse_duration(None, default=12) == 12.0 + assert parse_duration("garbage", default=7) == 7.0 + assert parse_duration(None) is None + + +# ── await_condition ─────────────────────────────────────────────────────── + + +def test_await_condition_returns_first_non_none(): + seen = [] + + def pred(): + seen.append(1) + return "done" if len(seen) >= 3 else None + + assert await_condition(pred, timeout=5, poll="1ms") == "done" + assert len(seen) >= 3 + + +def test_await_condition_reraises_client_exited(): + def pred(): + raise ClientExited("client gone") + + with pytest.raises(ClientExited): + await_condition(pred, timeout=5, poll="1ms") + + +def test_await_condition_swallows_transient_then_times_out(): + def pred(): + raise RuntimeError("bridge hiccup") + + start = time.monotonic() + with pytest.raises(TimeoutError) as e: + await_condition(pred, timeout=0.2, poll="10ms", msg="never") + assert "bridge hiccup" in str(e.value) + assert time.monotonic() - start < 2 + + +# ── selectors ───────────────────────────────────────────────────────────── + + +GUI = { + "screenClass": "S", + "buttons": [ + {"id": 1, "text": "Cancel", "enabled": True, "class": "net.Btn"}, + {"id": 2, "text": "Download file", "enabled": False, "class": "net.Btn"}, + {"id": 3, "text": "Download", "enabled": True, "class": "net.Btn"}, + ], + "textFields": [{"id": 9, "text": "", "enabled": True, "class": "net.Edit"}], +} + + +def test_selector_exact_preferred_over_substring(): + el = selectors.find_one(GUI, {"text": "Download"}) + assert el["id"] == 3 # exact "Download", not "Download file" + + +def test_selector_enabled_filter(): + el = selectors.find_one(GUI, {"text_any": ["download file"], "enabled": True}) + assert el is None # the only "download file" button is disabled + + +def test_selector_role_and_class(): + assert selectors.find_one(GUI, {"role": "textfield"})["id"] == 9 + assert selectors.find_one(GUI, {"class": "edit"})["id"] == 9 + + +def test_selector_index_negative(): + btns = selectors.find_all(GUI, {"role": "button"}) + assert len(btns) == 3 + assert selectors.find_one(GUI, {"role": "button", "index": -1})["id"] == 3 + + +def test_selector_no_match(): + assert selectors.find_one(GUI, {"text": "nope"}) is None + + +# ── templating ──────────────────────────────────────────────────────────── + + +def test_resolve_builtins_and_vars(make_ctx): + ctx = make_ctx(vars={"who": "world"}) + assert ctx.resolve("${target.id}") == "1.21-fabric" + assert ctx.resolve("${server.host}:${server.port}") == "srv-container:25565" + assert ctx.resolve("${modpack}") == "amp-autotest" + assert ctx.resolve("${marker}") == "config/amp-autotest-marker.json" + assert ctx.resolve("hello ${who}") == "hello world" + + +def test_resolve_nested_structures(make_ctx): + ctx = make_ctx(vars={"x": "1"}) + out = ctx.resolve({"a": ["${x}", "b"], "c": {"d": "${modpack}"}}) + assert out == {"a": ["1", "b"], "c": {"d": "amp-autotest"}} + + +def test_resolve_unknown_var_raises(make_ctx): + ctx = make_ctx() + with pytest.raises(KeyError): + ctx.resolve("${nope}") + + +# ── conditions ──────────────────────────────────────────────────────────── + + +def test_condition_screen_and_element(make_ctx): + ctx = make_ctx() + assert conditions.evaluate(ctx, {"screen": "DownloadScreen"}, gui=GUI) is False + assert conditions.evaluate(ctx, {"screen": "S"}, gui=GUI) is True + assert conditions.evaluate(ctx, {"element": {"text": "Download"}}, gui=GUI) is True + assert conditions.evaluate(ctx, {"no_element": {"text": "missing"}}, gui=GUI) is True + + +def test_condition_screen_none(make_ctx): + ctx = make_ctx() + assert conditions.evaluate(ctx, {"screen_none": True}, gui={"screenClass": None}) is True + assert conditions.evaluate(ctx, {"screen_none": True}, gui=GUI) is False + + +def test_condition_file_and_gone(make_ctx): + ctx = make_ctx() + (ctx.game_dir / "here.txt").write_text("x") + assert conditions.evaluate(ctx, {"file": "here.txt"}) is True + assert conditions.evaluate(ctx, {"file_gone": "nope.txt"}) is True + assert conditions.evaluate(ctx, {"file": "nope.txt"}) is False + + +def test_condition_all_any_not(make_ctx): + ctx = make_ctx() + cond = {"all": [{"screen": "S"}, {"not": {"screen": "X"}}]} + assert conditions.evaluate(ctx, cond, gui=GUI) is True + assert conditions.evaluate(ctx, {"any": [{"screen": "X"}, {"screen": "S"}]}, gui=GUI) is True + + +def test_condition_log_captures_variable(make_ctx): + ctx = make_ctx() + ctx.logs_provider = lambda which, tail=None: "line\nCertificate fingerprint: AB:CD:EF\nmore" + cond = {"log": {"container": "server", "matches": r"fingerprint[:\s]+([0-9A-Fa-f:]+)", + "capture": {"fp": 1}}} + assert conditions.evaluate(ctx, cond) is True + assert ctx.vars["fp"] == "AB:CD:EF" + + +# ── executor ────────────────────────────────────────────────────────────── + + +@verb("t_rec") +def _t_rec(ctx, step): + ctx.vars.setdefault("log", []).append(step.get("tag", "?")) + + +@verb("t_boom") +def _t_boom(ctx, step): + raise RuntimeError("kaboom") + + +def test_executor_macro_and_group_expansion(make_ctx): + ctx = make_ctx() + lib = {"greet": [{"do": "t_rec", "tag": "a"}, {"do": "t_rec", "tag": "b"}]} + scenario = { + "flow": [ + "greet", + {"group": True, "steps": [{"do": "t_rec", "tag": "c"}]}, + {"use": "greet"}, + ] + } + run_flow(ctx, scenario, lib=lib) + assert ctx.vars["log"] == ["a", "b", "c", "a", "b"] + + +def test_executor_when_gate_and_repeat(make_ctx): + ctx = make_ctx() + scenario = { + "flow": [ + {"do": "t_rec", "tag": "x", "repeat": 3}, + {"do": "t_rec", "tag": "skipped", "when": {"file": "absent.txt"}}, + ] + } + run_flow(ctx, scenario) + assert ctx.vars["log"] == ["x", "x", "x"] + + +def test_executor_when_and_repeat_apply_to_macros_and_groups(make_ctx): + ctx = make_ctx() + lib = {"greet": [{"do": "t_rec", "tag": "g"}]} + scenario = { + "flow": [ + {"use": "greet", "when": {"file": "absent.txt"}}, # gated out + {"use": "greet", "repeat": 2}, # macro runs twice + {"group": True, "steps": [{"do": "t_rec", "tag": "x"}], "repeat": 2}, + ] + } + run_flow(ctx, scenario, lib=lib) + assert ctx.vars["log"] == ["g", "g", "x", "x"] + + +def test_executor_records_results_and_optional(make_ctx): + ctx = make_ctx() + results = run_flow(ctx, {"flow": [{"do": "t_boom", "name": "explode", "optional": True}]}) + assert results[0]["ok"] is False + assert "kaboom" in results[0]["error"] + + +def test_executor_propagates_failure_with_partial_results(make_ctx): + ctx = make_ctx() + collected: list = [] + with pytest.raises(RuntimeError) as e: + run_flow( + ctx, + {"flow": [{"do": "t_rec", "tag": "ok"}, {"do": "t_boom", "name": "bad"}]}, + results=collected, + ) + assert "step 'bad' failed" in str(e.value) + assert [r["name"] for r in collected] == ["t_rec", "bad"] + assert collected[-1]["ok"] is False + + +def test_executor_unknown_verb(make_ctx): + ctx = make_ctx() + with pytest.raises(ValueError): + run_flow(ctx, {"flow": [{"do": "does_not_exist"}]}) + + +def test_executor_requires_flow(make_ctx): + ctx = make_ctx() + with pytest.raises(ValueError): + run_flow(ctx, {}) diff --git a/autotester/uv.lock b/autotester/uv.lock new file mode 100644 index 000000000..687955512 --- /dev/null +++ b/autotester/uv.lock @@ -0,0 +1,306 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "automodpack-autotest" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "docker" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "docker", specifier = ">=7.1.0" }, + { name = "pyyaml", specifier = ">=6.0.1" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] diff --git a/build.forge.gradle.kts b/build.forge.gradle.kts index 6fd228ea9..06200b3d1 100644 --- a/build.forge.gradle.kts +++ b/build.forge.gradle.kts @@ -25,7 +25,6 @@ dependencies { implementation(project(":core")) { isTransitive = false } implementation(project(":loader-core")) { isTransitive = false } - compileOnly("net.fabricmc.fabric-api:fabric-api:0.92.6+1.20.1") annotationProcessor("org.spongepowered:mixin:0.8.5:processor") // Required to generate refmaps } diff --git a/build.neoforge.gradle.kts b/build.neoforge.gradle.kts index 6cd3a2d20..5b0cd2498 100644 --- a/build.neoforge.gradle.kts +++ b/build.neoforge.gradle.kts @@ -17,15 +17,9 @@ neoForge { } } -repositories { - maven("https://maven.su5ed.dev/releases") { name = "FFAPI" } -} - dependencies { implementation(project(":core")) { isTransitive = false } implementation(project(":loader-core")) { isTransitive = false } - - implementation("org.sinytra.forgified-fabric-api:forgified-fabric-api:0.115.6+2.1.4+1.21.1") } tasks { @@ -50,9 +44,9 @@ java { targetCompatibility = JavaVersion.VERSION_25 toolchain.languageVersion.set(JavaLanguageVersion.of(25)) } else { - withSourcesJar() sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 toolchain.languageVersion.set(JavaLanguageVersion.of(21)) } + withSourcesJar() } diff --git a/buildSrc/src/main/kotlin/MergeJarTask.kt b/buildSrc/src/main/kotlin/MergeJarTask.kt index 68b913f37..8d5338ba6 100644 --- a/buildSrc/src/main/kotlin/MergeJarTask.kt +++ b/buildSrc/src/main/kotlin/MergeJarTask.kt @@ -86,4 +86,4 @@ abstract class MergeJarTask : DefaultTask() { outputJar.get().asFile.writeText(finalJar.absolutePath) println("Merged: ${jarToMerge.name} and ${zstdFile.name} from: ${loaderFile.name} into: ${finalJar.name} Took: ${System.currentTimeMillis() - time}ms") } -} \ No newline at end of file +} diff --git a/buildSrc/src/main/kotlin/ModuleUtils.kt b/buildSrc/src/main/kotlin/ModuleUtils.kt index 7702bb3c3..f374d29cd 100644 --- a/buildSrc/src/main/kotlin/ModuleUtils.kt +++ b/buildSrc/src/main/kotlin/ModuleUtils.kt @@ -10,9 +10,9 @@ fun getLoaderModuleName(name: String): String { return when { name.contains("fabric") -> "fabric-core" name.contains("neoforge") -> when (mcVersion) { - "1.20.6", "1.20.4", "1.20.1", "1.19.4", "1.19.2", "1.18.2" -> "neoforge-fml2" - "1.21.8", "1.21.5", "1.21.4", "1.21.3", "1.21.1" -> "neoforge-fml4" - "1.21.11", "1.21.10", "26.1", "26.2" -> "neoforge-fml10" + "1.21.8", "1.21.5", "1.21.4", "1.21.1" -> "neoforge-fml4" + "1.21.10", "1.21.11" -> "neoforge-fml10" + "26.1", "26.2" -> "neoforge-fml11" else -> error("Unknown neoforge loader module for Minecraft version: $mcVersion") } name.contains("forge") -> if (mcVersion == "1.18.2") "forge-fml40" else "forge-fml47" diff --git a/buildSrc/src/main/kotlin/automodpack.common.gradle.kts b/buildSrc/src/main/kotlin/automodpack.common.gradle.kts index 416238a3f..882366761 100644 --- a/buildSrc/src/main/kotlin/automodpack.common.gradle.kts +++ b/buildSrc/src/main/kotlin/automodpack.common.gradle.kts @@ -1,11 +1,37 @@ import java.security.MessageDigest import java.math.BigInteger +import org.gradle.api.tasks.SourceSetContainer plugins { idea id("dev.luna5ama.jar-optimizer") } +// Test-only instrumentation (AutoTestBridge + the dev mixins that drive it) is +// compiled into the mod only for autotester builds (-Pautomodpack.autotest); it +// must never ship in release jars. Exclude it from the source set so it lands in +// no jar variant (avoids loom remapJar vs jar pitfalls), and drop the dev mixins +// from the config so Mixin doesn't look for the now-absent classes. +if (!project.hasProperty("automodpack.autotest")) { + plugins.withId("java") { + the().named("main").configure { + java.exclude( + "pl/skidam/automodpack/client/autotest/**", + "pl/skidam/automodpack/mixin/dev/**", + ) + } + } + tasks.named("processResources").configure { + doLast { + val cfg = layout.buildDirectory + .file("resources/main/automodpack-main.mixins.json").get().asFile + if (cfg.exists()) { + cfg.writeText(cfg.readText().replace(Regex(",\\s*\"dev\\.[^\"]*\""), "")) + } + } + } +} + idea { module { isDownloadJavadoc = true @@ -47,9 +73,14 @@ val mergeJarTask = tasks.register("mergeJar") { this.outputJar.set(layout.buildDirectory.file("merged-jar-path.txt")) val filesToHash = mutableListOf() + tasks.findByName("jar")?.let { projectJar -> + dependsOn(projectJar) + filesToHash.add(projectJar) + } for (module in getAllDependentLoaderModules(project.name)) { - val modLoaderJar = rootProject.project(module).tasks.named("jar") - filesToHash.add(modLoaderJar) + rootProject.project(module).tasks.findByName("jar")?.let { modLoaderJar -> + filesToHash.add(modLoaderJar) + } } // Compute the actual hash of the content of all input jars. @@ -113,4 +144,4 @@ tasks.register("optimizeMergedJar") { println("Optimized ${jarFile.name} - Took: ${System.currentTimeMillis() - time}ms") } } -} \ No newline at end of file +} diff --git a/core/src/main/java/pl/skidam/automodpack_core/modpack/ModpackContent.java b/core/src/main/java/pl/skidam/automodpack_core/modpack/ModpackContent.java index bb9d67d86..810e97748 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/modpack/ModpackContent.java +++ b/core/src/main/java/pl/skidam/automodpack_core/modpack/ModpackContent.java @@ -89,7 +89,7 @@ public boolean create(FileMetadataCache cache) { } } - var tempPathMap = new HashMap<>(filesToProcess); + var tempPathMap = new ConcurrentHashMap<>(filesToProcess); List> futures = filesToProcess.entrySet().stream() .map(entry -> CompletableFuture.supplyAsync(() -> { diff --git a/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java b/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java index c6ad39ccb..f926465e3 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java +++ b/core/src/main/java/pl/skidam/automodpack_core/protocol/DownloadClient.java @@ -31,101 +31,151 @@ public class DownloadClient implements AutoCloseable { + // Dedicated daemon pool for the blocking network/login stages (pool + // hydration, and the up-to-120s certificate prompt). Keeps long downloads + // and that prompt off ForkJoinPool.commonPool, which is shared JVM-wide and + // also drives the parallel streams used here and in ModpackContent. + public static final ExecutorService NET_EXECUTOR = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "automodpack-net"); + t.setDaemon(true); + return t; + }); + private final List connections = new ArrayList<>(); - private InetSocketAddress address = null; - /** - * Transports the connection and the specific SSLContext used to create it. - * Required because the SSLContext may change dynamically during trust recovery. - */ private record InitialConnectionResult(PreValidationConnection connection, SSLContext sslContext) {} /** - * Initializes the client by establishing a single "probe" connection to validate/recover SSL trust, - * then hydrates the remaining connection pool in parallel using the validated SSL context. + * Holds the outcome of a single probe attempt, along with the KeyStore for later mutation. */ - public DownloadClient(Jsons.ModpackAddresses modpackAddresses, byte[] secretBytes, int poolSize, Function trustedByUserCallback) throws IOException { - if (poolSize < 1) throw new IllegalArgumentException("Pool size must be greater than 0"); + private record ProbeResult(InitialConnectionResult success, X509Certificate untrustedCert, IOException error, KeyStore keyStore) {} - KeyStore keyStore = loadDefaultKeyStore(); - - // Establish probe to handle potential SSL handshake errors (e.g., self-signed certs) sequentially before pooling. - InitialConnectionResult probe = establishProbeConnection(modpackAddresses, keyStore, trustedByUserCallback); - - if (probe.connection.getSocket() != null && !probe.connection.getSocket().isClosed()) { - if (secretBytes == null) { - probe.connection().getSocket().close(); - } else { - connections.add(new Connection(probe.connection, secretBytes)); - } - } + /** + * Package-private constructor for the async {@link #createAsync} path. + * Accepts a pre-hydrated connection pool. + */ + DownloadClient(List connections) { + this.connections.addAll(connections); + } - if (secretBytes == null) { - return; + /** + * Async factory. If the certificate needs user approval, no thread blocks: the returned future + * completes when the trust callback's future completes (via UI callbacks on the render thread). + */ + public static CompletableFuture createAsync( + Jsons.ModpackAddresses addresses, + byte[] secretBytes, + int poolSize, + Function> trustCallback) { + + if (poolSize < 1) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Pool size must be greater than 0")); } - int remainingNeeded = poolSize - connections.size(); - if (remainingNeeded < 1) { - return; - } + return CompletableFuture.supplyAsync(() -> { + KeyStore keyStore = loadDefaultKeyStore(); + AtomicReference capturedChain = new AtomicReference<>(); + SSLContext context = createSSLContext(keyStore, capturedChain::set); - // Parallel pool hydration using the session-aware SSLContext from the probe. - List newConnections = IntStream.range(0, remainingNeeded) - .parallel() - .mapToObj(i -> { try { - return new Connection(getPreValidationConnection(modpackAddresses, probe.sslContext), secretBytes); + PreValidationConnection probe = getPreValidationConnection(addresses, context); + return new ProbeResult(new InitialConnectionResult(probe, context), null, null, keyStore); } catch (IOException e) { - throw new CompletionException(e); + X509Certificate[] chain = capturedChain.get(); + X509Certificate untrusted = (chain != null && chain.length > 0) ? chain[0] : null; + return new ProbeResult(null, untrusted, e, keyStore); + } + }, NET_EXECUTOR).thenCompose(pr -> { + if (pr.success != null) { + try { + return CompletableFuture.completedFuture( + new DownloadClient(hydratePool(pr.success, secretBytes, poolSize, addresses))); + } catch (IOException e) { + return CompletableFuture.failedFuture(e); + } + } + if (pr.untrustedCert == null || trustCallback == null) { + return CompletableFuture.failedFuture(pr.error); } - }) - .toList(); - connections.addAll(newConnections); - LOGGER.info("Download client initialized with {} connections to {}", connections.size(), modpackAddresses.hostAddress.getHostString()); + return trustCallback.apply(pr.untrustedCert) + .thenComposeAsync(trusted -> { + if (!trusted) { + return CompletableFuture.failedFuture(new IOException("User rejected certificate")); + } + return CompletableFuture.supplyAsync(() -> { + try { + pr.keyStore.setCertificateEntry(addresses.hostAddress.getHostString(), pr.untrustedCert); + SSLContext trustedCtx = createSSLContext(pr.keyStore, null); + PreValidationConnection retry = getPreValidationConnection(addresses, trustedCtx); + return new DownloadClient(hydratePool( + new InitialConnectionResult(retry, trustedCtx), + secretBytes, poolSize, addresses)); + } catch (Exception e) { + throw new CompletionException(new IOException("Failed to reconnect after trust", e)); + } + }, NET_EXECUTOR); + }, NET_EXECUTOR) + .orTimeout(120, TimeUnit.SECONDS) + .exceptionally(e -> { + throw new CompletionException(new IOException("Certificate not trusted", e)); + }); + }); } /** - * Attempts a connection with a capturing trust manager. If the handshake fails due to an - * untrusted certificate, the chain is captured, presented to the user callback, and the connection is retried. + * Hydrates the connection pool from a successful probe + SSL context. + * Shared by both sync and async construction paths. */ - private InitialConnectionResult establishProbeConnection(Jsons.ModpackAddresses addresses, KeyStore keyStore, Function trustCallback) throws IOException { - AtomicReference capturedChain = new AtomicReference<>(); - SSLContext context = createSSLContext(keyStore, capturedChain::set); - - try { - PreValidationConnection conn = getPreValidationConnection(addresses, context); - return new InitialConnectionResult(conn, context); - } catch (IOException e) { // Inavlid/Selfsigned certificate, prompt user for trust. - return recoverProbeConnection(e, addresses, keyStore, trustCallback, capturedChain.get()); + private static List hydratePool(InitialConnectionResult probe, byte[] secretBytes, int poolSize, Jsons.ModpackAddresses addresses) throws IOException { + List conns = new ArrayList<>(); + if (probe.connection().getSocket() != null && !probe.connection().getSocket().isClosed()) { + if (secretBytes == null) { + probe.connection().getSocket().close(); + return conns; + } else { + conns.add(new Connection(probe.connection(), secretBytes)); + } } - } + if (secretBytes == null) return conns; - private InitialConnectionResult recoverProbeConnection(IOException originalError, Jsons.ModpackAddresses addresses, KeyStore keyStore, Function trustCallback, X509Certificate[] chain) throws IOException { - if (chain == null || chain.length == 0 || trustCallback == null) { - throw originalError; - } + int remainingNeeded = poolSize - conns.size(); + if (remainingNeeded < 1) return conns; - boolean isTrusted = trustCallback.apply(chain[0]); - if (!isTrusted) { - throw new IOException("User rejected the certificate.", originalError); + // Open the remaining connections in parallel. Record each one the moment + // it is created so that if any sibling task fails, we can close every + // connection that did open (including the probe) instead of leaking its + // socket and single-thread executor. + List opened = Collections.synchronizedList(new ArrayList<>()); + try { + IntStream.range(0, remainingNeeded) + .parallel() + .forEach(i -> { + try { + opened.add(new Connection(getPreValidationConnection(addresses, probe.sslContext()), secretBytes)); + } catch (IOException e) { + throw new CompletionException(e); + } + }); + } catch (RuntimeException e) { + for (Connection c : conns) closeQuietly(c); + for (Connection c : opened) closeQuietly(c); + Throwable cause = (e instanceof CompletionException && e.getCause() != null) ? e.getCause() : e; + if (cause instanceof IOException io) throw io; + throw new IOException("Failed to hydrate connection pool", cause); } + conns.addAll(opened); + return conns; + } + private static void closeQuietly(AutoCloseable c) { try { - keyStore.setCertificateEntry(addresses.hostAddress.getHostString(), chain[0]); - - // Re-initialize context with the updated KeyStore containing the user-trusted cert. - SSLContext trustedContext = createSSLContext(keyStore, null); - - PreValidationConnection retryConn = getPreValidationConnection(addresses, trustedContext); - return new InitialConnectionResult(retryConn, trustedContext); - - } catch (KeyStoreException kse) { - throw new IOException("Failed to update KeyStore with trusted certificate", kse); + c.close(); + } catch (Exception ignored) { } } - private KeyStore loadDefaultKeyStore() { + private static KeyStore loadDefaultKeyStore() { try { KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); keyStore.load(null); @@ -135,22 +185,16 @@ private KeyStore loadDefaultKeyStore() { } } - private PreValidationConnection getPreValidationConnection(Jsons.ModpackAddresses modpackAddresses, SSLContext sharedContext) throws IOException { + private static PreValidationConnection getPreValidationConnection(Jsons.ModpackAddresses modpackAddresses, SSLContext sharedContext) throws IOException { String hostName = modpackAddresses.hostAddress.getHostString(); - if (address == null) { - address = new InetSocketAddress(hostName, modpackAddresses.hostAddress.getPort()); - if (address.isUnresolved()) { - throw new IOException("Failed to resolve host address: " + hostName); - } + InetSocketAddress address = new InetSocketAddress(hostName, modpackAddresses.hostAddress.getPort()); + if (address.isUnresolved()) { + throw new IOException("Failed to resolve host address: " + hostName); } return new PreValidationConnection(address, modpackAddresses, sharedContext); } - /** - * Configures SSL context with TLSv1.3 and a custom TrustManager. - * @param onValidating Optional callback to capture certificate chains during handshake failures. - */ - private SSLContext createSSLContext(KeyStore trustedCertificates, Consumer onValidating) { + private static SSLContext createSSLContext(KeyStore trustedCertificates, Consumer onValidating) { try { SSLContext sslContext = SSLContext.getInstance("TLSv1.3"); X509ExtendedTrustManager trustManager = new CustomizableTrustManager(trustedCertificates, onValidating); @@ -169,18 +213,21 @@ private SSLContext createSSLContext(KeyStore trustedCertificates, Consumer trustedByUserCallback) { try { - return new DownloadClient(modpackAddresses, secretBytes, poolSize, trustedByUserCallback); - } catch (IOException e) { - LOGGER.error("Failed to create download client: {}", e.getMessage()); - LOGGER.debug(e); + Function> asyncCallback = trustedByUserCallback == null + ? null + : certificate -> CompletableFuture.completedFuture(trustedByUserCallback.apply(certificate)); + return createAsync(modpackAddresses, secretBytes, poolSize, asyncCallback).get(); + } catch (Exception e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + Throwable cause = e instanceof ExecutionException && e.getCause() != null ? e.getCause() : e; + LOGGER.error("Failed to create download client: {}", cause.getMessage()); + LOGGER.debug(cause); return null; } } - /** - * Recursively searches for an idle connection in the pool. - * Marks the found connection as busy to prevent race conditions. - */ private synchronized Connection getFreeConnection() { Iterator iterator = connections.iterator(); while (iterator.hasNext()) { @@ -214,10 +261,6 @@ public void close() { } } -/** - * Handles the initial TCP connection and protocol-specific handshakes (Magic) - * prior to or during the SSL upgrade. - */ class PreValidationConnection { private final SSLSocket socket; @@ -227,7 +270,6 @@ public PreValidationConnection(InetSocketAddress resolvedHostAddress, Jsons.Modp plainSocket.connect(resolvedHostAddress, 10000); plainSocket.setSoTimeout(10000); - // Perform custom "Magic" handshake over plain text if required by config. if (modpackAddresses.requiresMagic) { try { DataOutputStream plainOut = new DataOutputStream(new BufferedOutputStream(plainSocket.getOutputStream())); @@ -250,7 +292,6 @@ public PreValidationConnection(InetSocketAddress resolvedHostAddress, Jsons.Modp } } - // Layer SSL over the existing socket. SSLSocketFactory factory = sslContext.getSocketFactory(); SSLSocket sslSocket = (SSLSocket) factory.createSocket(plainSocket, resolvedHostAddress.getHostString(), resolvedHostAddress.getPort(), true); @@ -276,10 +317,6 @@ protected SSLSocket getSocket() { } } -/** - * Manages an active, authenticated session. Handles protocol negotiation, - * framing, compression, and async I/O. - */ class Connection implements AutoCloseable { private byte protocolVersion = LATEST_SUPPORTED_PROTOCOL_VERSION; @@ -292,8 +329,6 @@ class Connection implements AutoCloseable { private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final AtomicBoolean busy = new AtomicBoolean(false); - // Reuse this buffer for reading from socket to avoid allocation per frame. - // Size = Default Chunk + Header overhead (approx) + Safety margin private final byte[] networkInputBuffer = new byte[MAX_CHUNK_SIZE + 8192]; public Connection(PreValidationConnection preValidationConnection, byte[] secretBytes) throws IOException { @@ -306,7 +341,6 @@ public Connection(PreValidationConnection preValidationConnection, byte[] secret this.in = new DataInputStream(new BufferedInputStream(this.socket.getInputStream())); this.out = new DataOutputStream(new BufferedOutputStream(this.socket.getOutputStream())); - // Negotiate connection parameters sequentially. try { if (!PlatformUtils.canUseZstd()) { this.compressionType = COMPRESSION_GZIP; @@ -389,9 +423,6 @@ public CompletableFuture sendRefreshRequest(byte[][] fileHashes, Path dest }, executor); } - /** - * Cleans up input stream and releases the busy flag upon completion. - */ private void finalBlock(Exception exception) { try { int available; @@ -405,9 +436,6 @@ private void finalBlock(Exception exception) { } } - /** - * Segments payload into chunks, compresses them, and sends with protocol framing. - */ private void writeProtocolMessage(byte[] payload) throws IOException { CompressionCodec codec = getCompressionCodec(); int offset = 0; @@ -427,14 +455,11 @@ private void writeProtocolMessage(byte[] payload) throws IOException { out.flush(); } - /** - * Reads a framing header (Compressed Len + Original Len) and returns decompressed data. - */ private byte[] readProtocolMessageFrame() throws IOException { int compressedLength = in.readInt(); int originalLength = in.readInt(); - int maxAllowedSize = this.chunkSize + 8192; // Allow overhead buffer + int maxAllowedSize = this.chunkSize + 8192; if (compressedLength < 0 || compressedLength > maxAllowedSize) { throw new IOException("Frame compressed length (" + compressedLength + ") exceeds limit (" + maxAllowedSize + ")"); @@ -453,9 +478,6 @@ private byte[] readProtocolMessageFrame() throws IOException { return getCompressionCodec().decompress(networkInputBuffer, 0, compressedLength, originalLength); } - /** - * Processes the server response stream. Expects Header -> Data Frames -> EOT. - */ private Path readFileResponse(Path destination, IntConsumer chunkCallback) throws IOException { byte[] headerData = readProtocolMessageFrame(); ByteBuffer headerWrap = ByteBuffer.wrap(headerData); @@ -566,4 +588,4 @@ public void close() { try { socket.close(); } catch (Exception ignored) {} executor.shutdownNow(); } -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index c68b93b4a..5cd13678c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,7 +9,7 @@ org.gradle.caching = true org.gradle.configuration-cache.problems = warn org.gradle.daemon = true -core_modules = core, fabric-core, fabric-15, fabric-16, forge-fml40, forge-fml47, neoforge-fml2, neoforge-fml4, neoforge-fml10 +core_modules = core, fabric-core, fabric-15, fabric-16, forge-fml40, forge-fml47, neoforge-fml4, neoforge-fml10, neoforge-fml11 mod.id = automodpack_mod mod_name = AutoModpack diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c61a118f7..7e7d24f6f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java index 5c77d010f..1fafe06b0 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/Preload.java @@ -65,8 +65,22 @@ private void updateAll() { LegacyClientCacheUtils.deleteDummyFiles(); } else { Secrets.Secret secret = SecretsStore.getClientSecret(clientConfig.selectedModpack); - Jsons.ModpackAddresses modpackAddresses = new Jsons.ModpackAddresses(selectedModpackAddress, selectedServerAddress, requiresMagic); + + // When update-on-launch is disabled, just load the already-installed + // modpack: don't contact the server and don't reconcile local files, + // so the user can freely add/remove mods (e.g. a binary search). + if (!clientConfig.updateSelectedModpackOnLaunch) { + LegacyClientCacheUtils.deleteDummyFiles(); + var localModpackContent = ConfigTools.loadModpackContent(selectedModpackDir.resolve(hostModpackContentFile.getFileName())); + try { + new ModpackUpdater(localModpackContent, modpackAddresses, secret, selectedModpackDir).loadModpack(); + } catch (Exception e) { + LOGGER.error("Failed to load modpack", e); + } + return; + } + var optionalLatestModpackContent = ModpackUtils.requestServerModpackContent(modpackAddresses, secret, false); var latestModpackContent = ConfigTools.loadModpackContent(selectedModpackDir.resolve(hostModpackContentFile.getFileName())); @@ -83,9 +97,7 @@ private void updateAll() { // Delete dummy files LegacyClientCacheUtils.deleteDummyFiles(); - if (clientConfig.updateSelectedModpackOnLaunch) { - new ModpackUpdater(latestModpackContent, modpackAddresses, secret, selectedModpackDir).processModpackUpdate(null); - } + new ModpackUpdater(latestModpackContent, modpackAddresses, secret, selectedModpackDir).processModpackUpdate(null); } } diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java index 3a879477c..88cd06023 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUpdater.java @@ -3,21 +3,27 @@ import org.jetbrains.annotations.Nullable; import pl.skidam.automodpack_core.auth.Secrets; import pl.skidam.automodpack_core.auth.SecretsStore; -import pl.skidam.automodpack_core.config.Jsons; import pl.skidam.automodpack_core.config.ConfigTools; +import pl.skidam.automodpack_core.config.Jsons; import pl.skidam.automodpack_core.protocol.DownloadClient; -import pl.skidam.automodpack_core.utils.*; +import pl.skidam.automodpack_core.utils.FileInspection; +import pl.skidam.automodpack_core.utils.LegacyClientCacheUtils; +import pl.skidam.automodpack_core.utils.SmartFileUtils; +import pl.skidam.automodpack_core.utils.WorkaroundUtil; import pl.skidam.automodpack_core.utils.cache.FileMetadataCache; import pl.skidam.automodpack_core.utils.cache.ModFileCache; import pl.skidam.automodpack_core.utils.launchers.LauncherVersionSwapper; import pl.skidam.automodpack_loader_core.ReLauncher; import pl.skidam.automodpack_loader_core.screen.ScreenManager; -import pl.skidam.automodpack_loader_core.utils.*; +import pl.skidam.automodpack_loader_core.utils.DownloadManager; +import pl.skidam.automodpack_loader_core.utils.FetchManager; +import pl.skidam.automodpack_loader_core.utils.UpdateType; import java.io.IOException; import java.net.ConnectException; import java.net.SocketTimeoutException; -import java.nio.file.*; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -66,7 +72,7 @@ public void processModpackUpdate(ModpackUtils.UpdateCheckResult result) { // Handle the case where serverModpackContent is null if (serverModpackContent == null) { try (var cache = FileMetadataCache.open(hashCacheDBFile)) { - CheckAndLoadModpack(cache); + checkAndLoadModpack(cache); } return; } @@ -103,7 +109,7 @@ public void processModpackUpdate(ModpackUtils.UpdateCheckResult result) { } else { Files.writeString(modpackContentFile, serverModpackContentJson); try (var cache = FileMetadataCache.open(hashCacheDBFile)) { - CheckAndLoadModpack(cache); + checkAndLoadModpack(cache); } } } @@ -112,7 +118,25 @@ public void processModpackUpdate(ModpackUtils.UpdateCheckResult result) { } } - private void CheckAndLoadModpack(FileMetadataCache cache) throws Exception { + public void checkAndLoadModpack() throws Exception { + try (var cache = FileMetadataCache.open(hashCacheDBFile)) { + checkAndLoadModpack(cache); + } + } + + // Load the already-installed modpack without contacting the server or + // reconciling local files against it. Used when update-on-launch is disabled + // so the user can freely add/remove mods (e.g. a binary search) without + // AutoModpack restoring or deleting them. + public void loadModpack() throws Exception { + if (!Files.exists(modpackDir)) + return; + try (var cache = FileMetadataCache.open(hashCacheDBFile)) { + loadModpackMods(cache); + } + } + + private void checkAndLoadModpack(FileMetadataCache cache) throws Exception { if (!Files.exists(modpackDir)) return; @@ -125,51 +149,57 @@ private void CheckAndLoadModpack(FileMetadataCache cache) throws Exception { return; } - // Load the modpack excluding mods from standard mods directory without need to restart the game - if (preload) { - Set standardModsHashes; - List modpackMods = List.of(); - - // 1. Collect hashes of existing standard mods into a Set for fast lookup - try (Stream standardModsStream = Files.list(MODS_DIR)) { - standardModsHashes = standardModsStream - .filter(path -> Files.isRegularFile(path) && path.toString().endsWith(".jar")) // Check extension/type before hashing - .map(cache::getHashOrNull) // Safe wrapper for IOException - .filter(Objects::nonNull) - .collect(Collectors.toSet()); // Use Set for O(1) performance - } catch (IOException e) { - LOGGER.error("Failed to list standard mods directory", e); - standardModsHashes = Collections.emptySet(); - } - - // 2. Filter modpack mods excluding those already present in standard mods - Path modpackModsDir = modpackDir.resolve("mods"); - if (Files.exists(modpackModsDir)) { - try (Stream modpackModsStream = Files.list(modpackModsDir)) { - final Set finalStandardModsHashes = standardModsHashes; - modpackMods = modpackModsStream - .filter(path -> Files.isRegularFile(path) && path.toString().endsWith(".jar")) - .filter(mod -> { - String modHash = cache.getHashOrNull(mod); - // Only load if hash is valid AND not found in standard set - return modHash != null && !finalStandardModsHashes.contains(modHash); - }) - .toList(); - } catch (IOException e) { - LOGGER.error("Failed to list modpack mods directory", e); - } - } + loadModpackMods(cache); + } - MODPACK_LOADER.loadModpack(modpackMods); + // Load the modpack mods that aren't already present in the standard mods + // directory, without requiring a restart. + private void loadModpackMods(FileMetadataCache cache) throws Exception { + if (!preload) { + LOGGER.info("Modpack is already loaded"); return; } - LOGGER.info("Modpack is already loaded"); + Set standardModsHashes; + List modpackMods = List.of(); + + // 1. Collect hashes of existing standard mods into a Set for fast lookup + try (Stream standardModsStream = Files.list(MODS_DIR)) { + standardModsHashes = standardModsStream + .filter(path -> Files.isRegularFile(path) && path.toString().endsWith(".jar")) // Check extension/type before hashing + .map(cache::getHashOrNull) // Safe wrapper for IOException + .filter(Objects::nonNull) + .collect(Collectors.toSet()); // Use Set for O(1) performance + } catch (IOException e) { + LOGGER.error("Failed to list standard mods directory", e); + standardModsHashes = Collections.emptySet(); + } + + // 2. Filter modpack mods excluding those already present in standard mods + Path modpackModsDir = modpackDir.resolve("mods"); + if (Files.exists(modpackModsDir)) { + try (Stream modpackModsStream = Files.list(modpackModsDir)) { + final Set finalStandardModsHashes = standardModsHashes; + modpackMods = modpackModsStream + .filter(path -> Files.isRegularFile(path) && path.toString().endsWith(".jar")) + .filter(mod -> { + String modHash = cache.getHashOrNull(mod); + // Only load if hash is valid AND not found in standard set + return modHash != null && !finalStandardModsHashes.contains(modHash); + }) + .toList(); + } catch (IOException e) { + LOGGER.error("Failed to list modpack mods directory", e); + } + } + + MODPACK_LOADER.loadModpack(modpackMods); } public void startUpdate(Set filesToUpdate) { if (modpackSecret == null) { LOGGER.error("Cannot update modpack, secret is null"); + new ScreenManager().error("automodpack.error.critical", "Secret is null - cannot update", "automodpack.error.logs"); return; } @@ -238,7 +268,7 @@ public void startUpdate(Set files LOGGER.error("Update failed successfully! Try again! Took: {}ms", System.currentTimeMillis() - start); } else if (preload) { LOGGER.info("Update completed! Took: {}ms", System.currentTimeMillis() - start); - CheckAndLoadModpack(cache); + checkAndLoadModpack(cache); } else { boolean requiredRestart = applyModpack(cache); LOGGER.info("Update completed! Required restart: {} Took: {}ms", requiredRestart, System.currentTimeMillis() - start); diff --git a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java index 73151268c..507dd66d6 100644 --- a/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java +++ b/loader/core/src/main/java/pl/skidam/automodpack_loader_core/client/ModpackUtils.java @@ -23,7 +23,8 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Future; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import java.util.stream.Collectors; @@ -648,45 +649,39 @@ public static Path getModpackPath(InetSocketAddress address, String modpackName) public static Optional requestServerModpackContent(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, boolean allowAskingUser) { return fetchModpackContent(modpackAddresses, secret, (client) -> client.downloadFile(new byte[0], modpackContentTempFile, null), - "Fetched", allowAskingUser); + allowAskingUser); } public static Optional refreshServerModpackContent(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, byte[][] fileHashes, boolean allowAskingUser) { return fetchModpackContent(modpackAddresses, secret, (client) -> client.requestRefresh(fileHashes, modpackContentTempFile), - "Re-fetched", allowAskingUser); + allowAskingUser); } - private static Optional fetchModpackContent(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, Function> operation, String fetchType, boolean allowAskingUser) { + private static Optional fetchModpackContent(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, Function> operation, boolean allowAskingUser) { if (secret == null) return Optional.empty(); if (modpackAddresses.isAnyEmpty()) throw new IllegalArgumentException("Modpack addresses are empty!"); - try (DownloadClient client = DownloadClient.tryCreate(modpackAddresses, secret.secretBytes(), 1, userValidationCallback(modpackAddresses.hostAddress, allowAskingUser))) { - if (client == null) return Optional.empty(); - var future = operation.apply(client); - Path path = future.get(); - var content = Optional.ofNullable(ConfigTools.loadModpackContent(path)); - Files.deleteIfExists(modpackContentTempFile); - - if (content.isPresent() && potentiallyMalicious(content.get())) { - return Optional.empty(); - } - - return content; + try { + return fetchModpackContentAsync( + modpackAddresses, + secret, + operation, + blockingValidationCallback(modpackAddresses.hostAddress, allowAskingUser) + ).get(); } catch (Exception e) { LOGGER.error("Error while getting server modpack content", e); + return Optional.empty(); } - - return Optional.empty(); } public static boolean canConnectModpackHost(Jsons.ModpackAddresses modpackAddresses) { if (modpackAddresses.isAnyEmpty()) throw new IllegalArgumentException("Modpack addresses are empty!"); - try (DownloadClient client = DownloadClient.tryCreate(modpackAddresses, null, 1, null)) { + try (DownloadClient client = DownloadClient.createAsync(modpackAddresses, null, 1, null).get()) { return client != null; } catch (Exception e) { LOGGER.error("Error while pinging AutoModpack host server", e); @@ -729,8 +724,9 @@ public static Function userValidationCallback(InetSock private static Boolean askUserAboutCertificate(InetSocketAddress address, String fingerprint) { LOGGER.info("Asking user for {}", address.getHostString()); - Optional screen = new ScreenManager().getScreen(); - if (screen.isEmpty()) { + + var parent = new ScreenManager().getScreen().orElse(null); + if (parent == null) { LOGGER.warn("No screen available, cannot ask user"); return false; } @@ -743,9 +739,12 @@ private static Boolean askUserAboutCertificate(InetSocketAddress address, String latch.countDown(); }; Runnable cancelCallback = latch::countDown; - new ScreenManager().validation(screen.get(), fingerprint, trustCallback, cancelCallback); + new ScreenManager().validation(parent, fingerprint, trustCallback, cancelCallback); try { - latch.await(); + if (!latch.await(120, TimeUnit.SECONDS)) { + LOGGER.warn("Certificate validation timed out for {}", address.getHostString()); + return false; + } } catch (InterruptedException e) { return false; } @@ -753,6 +752,121 @@ private static Boolean askUserAboutCertificate(InetSocketAddress address, String return accepted.get(); } + // ---- Async versions (non-blocking, used by login packet flow) ---- + + public static CompletableFuture> requestServerModpackContentAsync(Jsons.ModpackAddresses modpackAddresses, Secrets.Secret secret, boolean allowAskingUser) { + if (secret == null) + return CompletableFuture.completedFuture(Optional.empty()); + if (modpackAddresses.isAnyEmpty()) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Modpack addresses are empty!")); + } + + return fetchModpackContentAsync(modpackAddresses, secret, + (client) -> client.downloadFile(new byte[0], modpackContentTempFile, null), + userValidationCallbackAsync(modpackAddresses.hostAddress, allowAskingUser)); + } + + private static CompletableFuture> fetchModpackContentAsync( + Jsons.ModpackAddresses modpackAddresses, + Secrets.Secret secret, + Function> operation, + Function> trustCallback) { + if (secret == null) + return CompletableFuture.completedFuture(Optional.empty()); + if (modpackAddresses.isAnyEmpty()) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Modpack addresses are empty!")); + } + + return DownloadClient.createAsync(modpackAddresses, secret.secretBytes(), 1, trustCallback) + .thenCompose(client -> { + CompletableFuture operationFuture; + try { + operationFuture = operation.apply(client); + } catch (Exception e) { + try { + client.close(); + } catch (Exception ignored) {} + return CompletableFuture.completedFuture(Optional.empty()); + } + + return operationFuture.handleAsync((path, throwable) -> { + try (client) { + if (throwable != null) { + LOGGER.error("Error while getting server modpack content", throwable); + return Optional.empty(); + } + + var content = Optional.ofNullable(ConfigTools.loadModpackContent(path)); + Files.deleteIfExists(modpackContentTempFile); + + if (content.isPresent() && potentiallyMalicious(content.get())) { + return Optional.empty(); + } + + return content; + } catch (Exception e) { + LOGGER.error("Error while getting server modpack content", e); + return Optional.empty(); + } + }); + }) + .exceptionally(e -> { + LOGGER.error("Error while getting server modpack content", e); + return Optional.empty(); + }); + } + + private static Function> blockingValidationCallback(InetSocketAddress address, boolean allowAskingUser) { + Function callback = userValidationCallback(address, allowAskingUser); + return certificate -> CompletableFuture.completedFuture(callback.apply(certificate)); + } + + public static Function> userValidationCallbackAsync(InetSocketAddress address, boolean allowAskingUser) { + return certificate -> { + String fingerprint; + try { + fingerprint = NetUtils.getFingerprint(certificate); + } catch (CertificateEncodingException e) { + return CompletableFuture.completedFuture(false); + } + if (Objects.equals(knownHosts.hosts.get(address.getHostString()), fingerprint)) + return CompletableFuture.completedFuture(true); + LOGGER.warn("Received untrusted certificate from server {}!", address.getHostString()); + if (allowAskingUser) { + return askUserAboutCertificateAsync(address, fingerprint); + } + + return CompletableFuture.completedFuture(false); + }; + } + + private static CompletableFuture askUserAboutCertificateAsync(InetSocketAddress address, String fingerprint) { + LOGGER.info("Asking user for {}", address.getHostString()); + + return CompletableFuture.supplyAsync(() -> { + var parent = new ScreenManager().getScreen().orElse(null); + if (parent == null) { + LOGGER.warn("No screen available, cannot ask user"); + return false; + } + + CompletableFuture future = new CompletableFuture<>(); + Runnable trustAction = () -> { + knownHosts.hosts.put(address.getHostString(), fingerprint); + ConfigTools.save(knownHostsFile, knownHosts); + future.complete(true); + }; + Runnable cancelAction = () -> future.complete(false); + new ScreenManager().validation(parent, fingerprint, trustAction, cancelAction); + + try { + return future.get(120, TimeUnit.SECONDS); + } catch (Exception e) { + return false; + } + }, DownloadClient.NET_EXECUTOR); + } + public static boolean potentiallyMalicious(Jsons.ModpackContentFields serverModpackContent) { if (isUnsafePath(serverModpackContent.modpackName, true)) { LOGGER.error("Modpack content is invalid: modpack name '{}' is unsafe/malicious", serverModpackContent.modpackName); diff --git a/loader/loader-fabric-core.gradle.kts b/loader/loader-fabric-core.gradle.kts index d628c684f..5d981cce3 100644 --- a/loader/loader-fabric-core.gradle.kts +++ b/loader/loader-fabric-core.gradle.kts @@ -79,6 +79,7 @@ tasks.named("shadowJar") { exclude("kotlin/**", "log4j2.xml") exclude("META-INF/maven/**", "META-INF/native-image/**", "META-INF/io.netty.versions.properties") + exclude("META-INF/services/java.security.Provider") mergeServiceFiles() } diff --git a/loader/loader-forge.gradle.kts b/loader/loader-forge.gradle.kts index 674ee0cee..03bc6e50d 100644 --- a/loader/loader-forge.gradle.kts +++ b/loader/loader-forge.gradle.kts @@ -76,6 +76,7 @@ tasks.named("shadowJar") { exclude("kotlin/**", "log4j2.xml") exclude("META-INF/maven/**", "META-INF/native-image/**", "META-INF/io.netty.versions.properties") + exclude("META-INF/services/java.security.Provider") mergeServiceFiles() } diff --git a/loader/loader-neoforge.gradle.kts b/loader/loader-neoforge.gradle.kts index 9689beccc..881cb5bee 100644 --- a/loader/loader-neoforge.gradle.kts +++ b/loader/loader-neoforge.gradle.kts @@ -76,15 +76,16 @@ tasks.named("shadowJar") { exclude("kotlin/**", "log4j2.xml") exclude("META-INF/maven/**", "META-INF/native-image/**", "META-INF/io.netty.versions.properties") + exclude("META-INF/services/java.security.Provider") mergeServiceFiles() } - java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + val javaVersion = findProperty("deps.java") as String + sourceCompatibility = JavaVersion.toVersion(javaVersion) + targetCompatibility = JavaVersion.toVersion(javaVersion) + toolchain.languageVersion.set(JavaLanguageVersion.of(javaVersion)) withSourcesJar() } diff --git a/loader/neoforge/fml10/gradle.properties b/loader/neoforge/fml10/gradle.properties index 3bfd31f4a..116805422 100644 --- a/loader/neoforge/fml10/gradle.properties +++ b/loader/neoforge/fml10/gradle.properties @@ -1 +1,2 @@ -deps.neoforge=21.10.64 \ No newline at end of file +deps.neoforge=21.10.64 +deps.java=21 \ No newline at end of file diff --git a/loader/neoforge/fml11/gradle.properties b/loader/neoforge/fml11/gradle.properties new file mode 100644 index 000000000..9f0e70b27 --- /dev/null +++ b/loader/neoforge/fml11/gradle.properties @@ -0,0 +1,2 @@ +deps.neoforge = 26.1.2.64-beta +deps.java = 25 diff --git a/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java b/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java new file mode 100644 index 000000000..e88cdd392 --- /dev/null +++ b/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java @@ -0,0 +1,38 @@ +package pl.skidam.automodpack_loader_core_neoforge; + +import net.neoforged.fml.jarcontents.JarContents; +import net.neoforged.fml.loading.progress.ProgressMeter; +import net.neoforged.fml.loading.progress.StartupNotificationManager; +import net.neoforged.neoforgespi.ILaunchContext; +import net.neoforged.neoforgespi.locating.*; +import pl.skidam.automodpack_loader_core.Preload; +import pl.skidam.automodpack_loader_core_neoforge.mods.ModpackLoader; + +import java.nio.file.Path; + +@SuppressWarnings("unused") +public class EarlyModLocator implements IModFileCandidateLocator { + + @Override + public void findCandidates(ILaunchContext context, IDiscoveryPipeline pipeline) { + + ProgressMeter progress = StartupNotificationManager.prependProgressBar("[Automodpack] Preload", 0); + new Preload(); + progress.complete(); + + for (Path path : ModpackLoader.modsToLoad) { + pipeline.addPath(path, ModFileDiscoveryAttributes.DEFAULT, IncompatibleFileReporting.WARN_ALWAYS); + try { + JarContents jarContents = JarContents.ofPath(path); + pipeline.readModFile(jarContents, ModFileDiscoveryAttributes.DEFAULT); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + @Override + public int getPriority() { + return IModFileCandidateLocator.HIGHEST_SYSTEM_PRIORITY; + } +} \ No newline at end of file diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java b/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java similarity index 74% rename from loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java rename to loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java index 13083e566..77d4c2918 100644 --- a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java +++ b/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/loader/LoaderManager.java @@ -20,7 +20,7 @@ public ModPlatform getPlatformType() { public boolean isModLoaded(String modId) { LoadingModList loadingModList; try { - loadingModList= FMLLoader.getLoadingModList(); + loadingModList= FMLLoader.getCurrent().getLoadingModList(); } catch (IllegalStateException e) { return false; } @@ -29,12 +29,12 @@ public boolean isModLoaded(String modId) { @Override public String getLoaderVersion() { - return FMLLoader.versionInfo().neoForgeVersion(); + return FMLLoader.getCurrent().getVersionInfo().neoForgeVersion(); } @Override public EnvironmentType getEnvironmentType() { - if (FMLLoader.getDist() == Dist.CLIENT) { + if (FMLLoader.getCurrent().getDist() == Dist.CLIENT) { return EnvironmentType.CLIENT; } else { return EnvironmentType.SERVER; @@ -45,13 +45,13 @@ public EnvironmentType getEnvironmentType() { public String getModVersion(String modId) { if (preload) { if (modId.equals("minecraft")) { - return FMLLoader.versionInfo().mcVersion(); + return FMLLoader.getCurrent().getVersionInfo().mcVersion(); } return null; } - ModInfo modInfo = FMLLoader.getLoadingModList().getMods().stream().filter(mod -> mod.getModId().equals(modId)).findFirst().orElse(null); + ModInfo modInfo = FMLLoader.getCurrent().getLoadingModList().getMods().stream().filter(mod -> mod.getModId().equals(modId)).findFirst().orElse(null); if (modInfo == null) { return null; @@ -62,6 +62,6 @@ public String getModVersion(String modId) { @Override public boolean isDevelopmentEnvironment() { - return !FMLLoader.isProduction(); + return !FMLLoader.getCurrent().isProduction(); } } \ No newline at end of file diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java b/loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java similarity index 100% rename from loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java rename to loader/neoforge/fml11/src/main/java/pl/skidam/automodpack_loader_core_neoforge/mods/ModpackLoader.java diff --git a/loader/neoforge/fml11/src/main/resources/META-INF/jarjar/metadata.json b/loader/neoforge/fml11/src/main/resources/META-INF/jarjar/metadata.json new file mode 100644 index 000000000..975276778 --- /dev/null +++ b/loader/neoforge/fml11/src/main/resources/META-INF/jarjar/metadata.json @@ -0,0 +1,12 @@ +{ + "jars": [ + { + "identifier": { + "group": "pl.skidam", + "artifact": "automodpack" + }, + "path": "META-INF/jarjar/automodpack-mod.jar", + "isObfuscated": false + } + ] +} diff --git a/loader/neoforge/fml2/src/main/resources/META-INF/mods.toml b/loader/neoforge/fml11/src/main/resources/META-INF/neoforge.mods.toml similarity index 100% rename from loader/neoforge/fml2/src/main/resources/META-INF/mods.toml rename to loader/neoforge/fml11/src/main/resources/META-INF/neoforge.mods.toml diff --git a/loader/neoforge/fml11/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModFileCandidateLocator b/loader/neoforge/fml11/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModFileCandidateLocator new file mode 100644 index 000000000..f5484f137 --- /dev/null +++ b/loader/neoforge/fml11/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModFileCandidateLocator @@ -0,0 +1 @@ +pl.skidam.automodpack_loader_core.EarlyModLocator diff --git a/loader/neoforge/fml2/gradle.properties b/loader/neoforge/fml2/gradle.properties deleted file mode 100644 index 5cf92ca53..000000000 --- a/loader/neoforge/fml2/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -deps.neoforge=20.4.248 \ No newline at end of file diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java deleted file mode 100644 index eb862e59b..000000000 --- a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java +++ /dev/null @@ -1,34 +0,0 @@ -package pl.skidam.automodpack_loader_core_neoforge; - -import java.nio.file.Path; -import java.util.*; -import java.util.stream.Stream; -import net.neoforged.fml.loading.moddiscovery.AbstractJarFileModLocator; -import net.neoforged.fml.loading.progress.ProgressMeter; -import net.neoforged.fml.loading.progress.StartupNotificationManager; -import pl.skidam.automodpack_loader_core.Preload; -import pl.skidam.automodpack_loader_core_neoforge.mods.ModpackLoader; - -@SuppressWarnings("unused") -public class EarlyModLocator extends AbstractJarFileModLocator { - - @Override - public void initArguments(Map arguments) {} - - @Override - public String name() { - return "automodpack"; - } - - @Override - public Stream scanCandidates() { - ProgressMeter progress = StartupNotificationManager.prependProgressBar( - "[Automodpack] Preload", - 0 - ); - new Preload(); - progress.complete(); - - return ModpackLoader.modsToLoad.stream(); - } -} diff --git a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java b/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java deleted file mode 100644 index 2e2382f25..000000000 --- a/loader/neoforge/fml2/src/main/java/pl/skidam/automodpack_loader_core_neoforge/LazyModLocator.java +++ /dev/null @@ -1,60 +0,0 @@ -package pl.skidam.automodpack_loader_core_neoforge; - -import com.google.common.collect.ImmutableMap; -import net.neoforged.fml.loading.moddiscovery.AbstractJarFileDependencyLocator; -import net.neoforged.neoforgespi.locating.IModFile; - -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import static cpw.mods.modlauncher.api.LamdbaExceptionUtils.uncheck; - -@SuppressWarnings("unused") -public class LazyModLocator extends AbstractJarFileDependencyLocator { - @Override - public List scanMods(Iterable loadedMods) { - var list = new ArrayList(); - try { - list.add(getMainMod()); - } catch (Exception e) { - throw new RuntimeException(e); - } - - return list; - } - - @Override - public String name() { - return null; - } - - @Override - public void initArguments(Map arguments) { } - - // Code based on connector's https://github.com/Sinytra/Connector/blob/0514fec8f189b88c5cec54dc5632fbcee13d56dc/src/main/java/dev/su5ed/sinytra/connector/locator/EmbeddedDependencies.java#L88 - private IModFile getMainMod() throws IOException, URISyntaxException { - final Path SELF_PATH = uncheck(() -> { - URL jarLocation = LazyModLocator.class.getProtectionDomain().getCodeSource().getLocation(); - return Path.of(jarLocation.toURI()); - }); - - final String depName = "META-INF/jarjar/automodpack-mod.jar"; - - final Path pathInModFile = SELF_PATH.resolve(depName); - final URI filePathUri = new URI("jij:" + pathInModFile.toAbsolutePath().toUri().getRawSchemeSpecificPart()).normalize(); - final Map outerFsArgs = ImmutableMap.of("packagePath", pathInModFile); - final FileSystem zipFS = FileSystems.newFileSystem(filePathUri, outerFsArgs); - - final Path modPath = zipFS.getPath("/"); - var mod = createMod(modPath); - return mod.file(); - } -} diff --git a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator b/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator deleted file mode 100644 index 4de645304..000000000 --- a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IDependencyLocator +++ /dev/null @@ -1 +0,0 @@ -pl.skidam.automodpack_loader_core.LazyModLocator \ No newline at end of file diff --git a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModLocator b/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModLocator deleted file mode 100644 index 25faad979..000000000 --- a/loader/neoforge/fml2/src/main/resources/META-INF/services/net.neoforged.neoforgespi.locating.IModLocator +++ /dev/null @@ -1 +0,0 @@ -pl.skidam.automodpack_loader_core.EarlyModLocator \ No newline at end of file diff --git a/loader/neoforge/fml4/gradle.properties b/loader/neoforge/fml4/gradle.properties index cc919d069..23cf9f4e2 100644 --- a/loader/neoforge/fml4/gradle.properties +++ b/loader/neoforge/fml4/gradle.properties @@ -1 +1,2 @@ -deps.neoforge=21.1.182 \ No newline at end of file +deps.neoforge=21.1.230 +deps.java=21 diff --git a/settings.gradle.kts b/settings.gradle.kts index c365ad2b9..d0f318f00 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,7 +34,7 @@ coreModules.forEach { module -> "fabric-core" -> project.buildFileName = "../../loader-fabric-core.gradle.kts" "fabric-15", "fabric-16" -> project.buildFileName = "../../loader-fabric.gradle.kts" "forge-fml40", "forge-fml47" -> project.buildFileName = "../../loader-forge.gradle.kts" - "neoforge-fml2", "neoforge-fml4", "neoforge-fml10" -> project.buildFileName = "../../loader-neoforge.gradle.kts" + "neoforge-fml4", "neoforge-fml10", "neoforge-fml11" -> project.buildFileName = "../../loader-neoforge.gradle.kts" } } @@ -51,12 +51,8 @@ stonecutter { match("1.21.8", "fabric", "neoforge") match("1.21.5", "fabric", "neoforge") match("1.21.4", "fabric", "neoforge") - match("1.21.3", "fabric", "neoforge") match("1.21.1", "fabric", "neoforge") - match("1.20.6", "fabric", "neoforge") - match("1.20.4", "fabric", "neoforge") match("1.20.1", "fabric", "forge") - match("1.19.4", "fabric", "forge") match("1.19.2", "fabric", "forge") match("1.18.2", "fabric", "forge") } diff --git a/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java b/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java index 29330d100..4c9189dca 100644 --- a/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java +++ b/src/main/java/pl/skidam/automodpack/client/ScreenImpl.java @@ -10,56 +10,59 @@ import java.nio.file.Path; import java.util.Optional; -import net.minecraft.util.Util; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.TitleScreen; public class ScreenImpl implements ScreenService { + private static void executeOnClient(Runnable task) { + Minecraft.getInstance().execute(task); + } + @Override public void download(Object... args) { - Minecraft.getInstance().execute(() -> Screens.download(args[0], args[1])); + executeOnClient(() -> Screens.download(args[0], args[1])); } @Override public void fetch(Object... args) { - Minecraft.getInstance().execute(() -> Screens.fetch(args[0])); + executeOnClient(() -> Screens.fetch(args[0])); } @Override public void changelog(Object... args) { - Minecraft.getInstance().execute(() -> Screens.changelog(args[0], args[1], args[2])); + executeOnClient(() -> Screens.changelog(args[0], args[1], args[2])); } @Override public void restart(Object... args) { - Minecraft.getInstance().execute(() -> Screens.restart(args[0], args[1], args[2])); + executeOnClient(() -> Screens.restart(args[0], args[1], args[2])); } @Override public void danger(Object... args) { - Minecraft.getInstance().execute(() -> Screens.danger(args[0], args[1])); + executeOnClient(() -> Screens.danger(args[0], args[1])); } @Override public void error(String... args) { - Minecraft.getInstance().execute(() -> Screens.error(args)); + executeOnClient(() -> Screens.error(args)); } @Override public void menu(Object... args) { - Minecraft.getInstance().execute(Screens::menu); + executeOnClient(Screens::menu); } @Override public void title(Object... args) { - Minecraft.getInstance().execute(Screens::title); + executeOnClient(Screens::title); } @Override public void validation(Object... args) { - Minecraft.getInstance().execute(() -> Screens.validation(args[0], args[1], args[2], args[3])); + executeOnClient(() -> Screens.validation(args[0], args[1], args[2], args[3])); } @Override @@ -83,11 +86,10 @@ private static Screen getScreen() { } public static void setScreen(Screen screen) { - // required for forge to handle it properly /*? if >=26.2 {*/ - Util.backgroundExecutor().execute(() -> Minecraft.getInstance().execute(() -> Minecraft.getInstance().gui.setScreen(screen))); + Minecraft.getInstance().gui.setScreen(screen); /*?} else {*/ - /*Util.backgroundExecutor().execute(() -> Minecraft.getInstance().execute(() -> Minecraft.getInstance().setScreen(screen))); + /*Minecraft.getInstance().setScreen(screen); *//*?}*/ } diff --git a/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java new file mode 100644 index 000000000..11f809a07 --- /dev/null +++ b/src/main/java/pl/skidam/automodpack/client/autotest/AutoTestBridge.java @@ -0,0 +1,477 @@ +package pl.skidam.automodpack.client.autotest; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.client.Minecraft; +import pl.skidam.automodpack_loader_core.screen.ScreenManager; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.screens.ConnectScreen; +/*? if >=1.21.6 {*/ +import net.minecraft.client.gui.screens.GenericMessageScreen; +/*?}*/ +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.client.multiplayer.resolver.ServerAddress; +/*? if >= 1.21.10 {*/ +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.input.MouseButtonInfo; +/*?}*/ +/*? if >= 1.20.5 {*/ +import net.minecraft.client.multiplayer.TransferState; +/*?}*/ +/*? if >= 1.19.2 {*/ +import net.minecraft.network.chat.Component; +/*?} else {*/ +/*import net.minecraft.network.chat.TranslatableComponent; +*//*?}*/ + +import java.io.IOException; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import static pl.skidam.automodpack_core.Constants.LOGGER; + +public final class AutoTestBridge { + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static volatile Path bridgeDir; + private static final AtomicBoolean CLIENT_READY = new AtomicBoolean(false); + private static volatile boolean reloadFinished = false; + + public static void markReloadFinished() { + reloadFinished = true; + } + + public static boolean hasReloadFinished() { + return reloadFinished; + } + + public static void startIfEnabled() { + if (!Boolean.getBoolean("automodpack.autotest")) return; + if (!STARTED.compareAndSet(false, true)) return; + String token = System.getProperty("automodpack.autotest.token", ""); + String gameDir = System.getProperty("automodpack.autotest.gamedir", ""); + if (token.isBlank() || gameDir.isBlank()) { + LOGGER.warn("AutoModpack bridge disabled: token is '{}', gamedir is '{}'", token, gameDir); + return; + } + + Thread t = new Thread(() -> run(Path.of(gameDir), token), "AutoModpackBridge"); + t.setDaemon(true); + t.start(); + + // Fallback in case the reload-finished mixin fires before the title screen is shown. + Thread waiter = new Thread(() -> { + while (!CLIENT_READY.get()) { + try { + Thread.sleep(100); + if (currentScreen() instanceof TitleScreen && hasReloadFinished()) { + onClientReady(); + return; + } + } catch (Exception ignored) { + } + } + }, "AutoModpackReadyWaiter"); + waiter.setDaemon(true); + waiter.start(); + } + + public static void onClientReady() { + if (!CLIENT_READY.compareAndSet(false, true)) return; + Path dir = bridgeDir; + if (dir == null) return; + try { + writeFile(dir.resolve("bridge-state.json"), "{\"status\":\"ready\"}"); + LOGGER.info("AutoModpack autotest: client ready, wrote bridge-state.json"); + } catch (IOException e) { + LOGGER.error("Cannot write client-ready state", e); + } + } + + private static void run(Path gameDir, String token) { + Path dir = gameDir.resolve("automodpack/autotest"); + bridgeDir = dir; + try { + Files.createDirectories(dir); + } catch (IOException e) { + LOGGER.error("Cannot initialize autotest bridge directory", e); + return; + } + + LOGGER.info("AutoModpack bridge ready at {}", dir); + Path cmd = dir.resolve("bridge-command.json"); + Path rsp = dir.resolve("bridge-response.json"); + while (true) { + try { + if (Files.exists(cmd)) { + String json = Files.readString(cmd, StandardCharsets.UTF_8); + Files.delete(cmd); + writeFile(rsp, handle(json, token)); + } + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + LOGGER.error("AutoModpack bridge error", e); + } + } + } + + private static String handle(String json, String token) { + try { + JsonObject req = JsonParser.parseString(json).getAsJsonObject(); + if (!token.equals(optString(req, "token"))) { + return err("invalid bridge token"); + } + + return exec(req); + } catch (Exception e) { + LOGGER.error("AutoModpack bridge command failed", e); + return err(e.getMessage()); + } + } + + private static String exec(JsonObject req) throws Exception { + return switch (optString(req, "op")) { + case "ping" -> ok(); + case "gui" -> onMain(() -> gui().toString()); + case "click" -> onMain(() -> click(req)); + case "text" -> onMain(() -> text(req)); + case "connect" -> onMain(() -> connect(req)); + case "disconnect" -> onMain(AutoTestBridge::disconnect); + case "quit" -> onMain(AutoTestBridge::quit); + default -> err("unknown operation: " + optString(req, "op")); + }; + } + + private static Screen currentScreen() { + return (Screen) new ScreenManager().getScreen().orElse(null); + } + + private static JsonObject gui() { + Screen s = currentScreen(); + JsonObject o = base(); + o.addProperty("screenClass", s == null ? null : s.getClass().getName()); + o.addProperty("title", s == null ? null : s.getTitle().getString()); + GuiElements elements = elements(s); + o.add("buttons", elementsJson(elements.buttons())); + o.add("textFields", elementsJson(elements.textFields())); + o.add("other", elementsJson(elements.other())); + o.add("elements", elementsJson(elements.all())); + return o; + } + + private static String click(JsonObject req) { + Screen s = currentScreen(); + if (s == null) return err("no screen"); + + int button = optInt(req, "button", 0); + int x; + int y; + if (has(req, "id")) { + GuiElement e = elements(s).byId(optInt(req, "id", -1)); + if (e == null) return err("no gui element with id " + optInt(req, "id", -1)); + if (has(req, "enable") && req.get("enable").getAsBoolean() && e.widget() instanceof Button) { + e.widget().active = true; + } + x = e.x() + e.width() / 2; + y = e.y() + e.height() / 2; + } else { + x = optInt(req, "x", -1); + y = optInt(req, "y", -1); + if (x < 0 || y < 0) return err("click needs either id or x/y"); + } + + s.mouseMoved(x, y); + /*? if >= 1.21.10 {*/ + MouseButtonEvent event = new MouseButtonEvent(x, y, new MouseButtonInfo(button, 0)); + s.mouseClicked(event, false); + s.mouseReleased(event); + /*?} else {*/ + /*s.mouseClicked(x, y, button); + s.mouseReleased(x, y, button); + *//*?}*/ + return ok(); + } + + private static String text(JsonObject req) { + Screen s = currentScreen(); + if (s == null) return err("no screen"); + + int id = optInt(req, "id", -1); + GuiElement e = elements(s).byId(id); + if (e == null || !(e.widget() instanceof EditBox editBox)) { + return err("no text field with id " + id); + } + + editBox.setValue(optString(req, "text")); + return ok(); + } + + private static String connect(JsonObject req) { + Minecraft c = Minecraft.getInstance(); + String host = optString(req, "host"); + int port = optInt(req, "port", 25565); + if (host.isBlank()) return err("host is required"); + + ServerAddress address = ServerAddress.parseString(host + ":" + port); + ServerData serverData = new ServerData("AutoTest", address.toString() + /*? if >= 1.20.4 {*/, ServerData.Type.OTHER/*?} else {*//*, false*//*?}*/); + /*? if >= 1.20.5 {*/ + ConnectScreen.startConnecting(new TitleScreen(), c, address, serverData, false, (TransferState) null); + /*?} else if >= 1.20.4 {*/ + /*ConnectScreen.startConnecting(new TitleScreen(), c, address, serverData, false); + *//*?} else if >= 1.20.1 {*/ + /*ConnectScreen.startConnecting(new TitleScreen(), c, address, serverData, false); + *//*?} else {*/ + /*ConnectScreen.startConnecting(new TitleScreen(), c, address, serverData); + *//*?}*/ + return ok(); + } + + private static String disconnect() { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.level == null) { + minecraft.gui.setScreen(new TitleScreen()); + return ok(); + } + + /*? if >=1.21.6 {*/ + minecraft.level.disconnect(translatable("multiplayer.status.quitting")); + minecraft.clearClientLevel(new GenericMessageScreen(translatable("multiplayer.disconnect.generic"))); + /*?} else {*/ + /*minecraft.level.disconnect(); + *//*?}*/ + minecraft.gui.setScreen(new TitleScreen()); + return ok(); + } + + private static String quit() { + Minecraft.getInstance().stop(); + return ok(); + } + + private static GuiElements elements(Screen screen) { + if (screen == null) return new GuiElements(List.of()); + + LinkedHashSet widgets = new LinkedHashSet<>(); + for (GuiEventListener child : screen.children()) { + if (child instanceof AbstractWidget widget) widgets.add(widget); + } + findWidgets(screen, widgets, newSeenSet()); + + List result = new ArrayList<>(); + int id = 0; + for (AbstractWidget widget : widgets) { + result.add(new GuiElement(id++, widget)); + } + return new GuiElements(result); + } + + private static void findWidgets(Object object, Set widgets, Set seen) { + if (object == null || seen.contains(object)) return; + seen.add(object); + + Class type = object.getClass(); + while (type != null && type != Object.class) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + try { + field.setAccessible(true); + collectWidgetValue(field.get(object), widgets, seen); + } catch (ReflectiveOperationException | RuntimeException ignored) { + // Best effort only. Screen#children is the primary source. + } + } + type = type.getSuperclass(); + } + } + + private static void collectWidgetValue(Object value, Set widgets, Set seen) { + if (value == null) return; + if (value instanceof AbstractWidget widget) { + widgets.add(widget); + return; + } + if (value instanceof Collection collection) { + for (Object item : collection) collectWidgetValue(item, widgets, seen); + return; + } + if (value instanceof Map map) { + for (Object item : map.values()) collectWidgetValue(item, widgets, seen); + return; + } + if (value.getClass().isArray()) { + int length = Array.getLength(value); + for (int i = 0; i < length; i++) collectWidgetValue(Array.get(value, i), widgets, seen); + } + } + + private static JsonArray elementsJson(List elements) { + JsonArray a = new JsonArray(); + for (GuiElement e : elements) { + JsonObject o = new JsonObject(); + o.addProperty("id", e.id()); + o.addProperty("text", e.text()); + o.addProperty("x", e.x()); + o.addProperty("y", e.y()); + o.addProperty("width", e.width()); + o.addProperty("height", e.height()); + o.addProperty("enabled", e.widget().active); + o.addProperty("visible", e.widget().visible); + o.addProperty("type", e.type()); + o.addProperty("class", e.widget().getClass().getName()); + a.add(o); + } + return a; + } + + private static Set newSeenSet() { + return java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + } + + private static String onMain(ThrowingSupplier supplier) throws Exception { + Minecraft c = Minecraft.getInstance(); + CompletableFuture f = new CompletableFuture<>(); + c.execute(() -> { + try { + f.complete(supplier.get()); + } catch (Exception e) { + f.completeExceptionally(e); + } + }); + T result = f.get(); + return result instanceof String string ? string : String.valueOf(result); + } + + private static void writeFile(Path p, String c) throws IOException { + Path t = p.resolveSibling(p.getFileName() + ".tmp"); + Files.writeString(t, c, StandardCharsets.UTF_8); + Files.move(t, p, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } + + private static JsonObject base() { + JsonObject o = new JsonObject(); + o.addProperty("ok", true); + return o; + } + + private static String ok() { + return "{\"ok\":true}"; + } + + private static String err(String m) { + JsonObject o = new JsonObject(); + o.addProperty("ok", false); + o.addProperty("error", m == null ? "unknown" : m); + return o.toString(); + } + + private static boolean has(JsonObject o, String k) { + JsonElement e = o.get(k); + return e != null && !e.isJsonNull(); + } + + private static String optString(JsonObject o, String k) { + JsonElement e = o.get(k); + return e != null && !e.isJsonNull() ? e.getAsString() : ""; + } + + private static int optInt(JsonObject o, String k, int d) { + JsonElement e = o.get(k); + return e != null && !e.isJsonNull() ? e.getAsInt() : d; + } + + /*? if >= 1.19.2 {*/ + private static Component translatable(String key) { + return Component.translatable(key); + } + /*?} else {*/ + /*private static TranslatableComponent translatable(String key) { + return new TranslatableComponent(key); + } + *//*?}*/ + + private record GuiElements(List all) { + GuiElement byId(int id) { + for (GuiElement element : all) { + if (element.id() == id) return element; + } + return null; + } + + List buttons() { + return all.stream().filter(e -> e.widget() instanceof Button).toList(); + } + + List textFields() { + return all.stream().filter(e -> e.widget() instanceof EditBox).toList(); + } + + List other() { + return all.stream().filter(e -> !(e.widget() instanceof Button) && !(e.widget() instanceof EditBox)).toList(); + } + } + + private record GuiElement(int id, AbstractWidget widget) { + String text() { + return widget instanceof EditBox editBox ? editBox.getValue() : widget.getMessage().getString(); + } + + int x() { + /*? if >= 1.19.4 {*/ + return widget.getX(); + /*?} else {*/ + /*return widget.x; + *//*?}*/ + } + + int y() { + /*? if >= 1.19.4 {*/ + return widget.getY(); + /*?} else {*/ + /*return widget.y; + *//*?}*/ + } + + int width() { + return widget.getWidth(); + } + + int height() { + return widget.getHeight(); + } + + String type() { + if (widget instanceof Button) return "Button"; + if (widget instanceof EditBox) return "TextField"; + return widget.getClass().getSimpleName(); + } + } + + @FunctionalInterface + private interface ThrowingSupplier { + T get() throws Exception; + } +} diff --git a/src/main/java/pl/skidam/automodpack/client/ui/DangerScreen.java b/src/main/java/pl/skidam/automodpack/client/ui/DangerScreen.java index e9d97b763..f52f64f3e 100644 --- a/src/main/java/pl/skidam/automodpack/client/ui/DangerScreen.java +++ b/src/main/java/pl/skidam/automodpack/client/ui/DangerScreen.java @@ -9,10 +9,13 @@ import pl.skidam.automodpack.client.ui.versioned.VersionedText; import pl.skidam.automodpack_loader_core.client.ModpackUpdater; +import java.util.concurrent.atomic.AtomicBoolean; + public class DangerScreen extends VersionedScreen { private final Screen parent; private final ModpackUpdater modpackUpdaterInstance; + private final AtomicBoolean updateStarted = new AtomicBoolean(false); public DangerScreen(Screen parent, ModpackUpdater modpackUpdaterInstance) { super(VersionedText.literal("DangerScreen")); @@ -48,7 +51,11 @@ protected void init() { VersionedText.translatable( "automodpack.danger.confirm" ).withStyle(ChatFormatting.BOLD), - button -> Util.backgroundExecutor().execute(() -> modpackUpdaterInstance.startUpdate(modpackUpdaterInstance.getModpackFileList())) + button -> { + if (updateStarted.compareAndSet(false, true)) { + Util.backgroundExecutor().execute(() -> modpackUpdaterInstance.startUpdate(modpackUpdaterInstance.getModpackFileList())); + } + } ) ); } @@ -102,7 +109,7 @@ public void versionedRender(VersionedMatrices matrices, int mouseX, int mouseY, @Override public boolean onKeyPress(int keyCode, int scanCode, int modifiers) { - if (keyCode == 257) { // Enter key (GLFW_KEY_ENTER = 257) + if (keyCode == 257 && updateStarted.compareAndSet(false, true)) { // Enter key (GLFW_KEY_ENTER = 257) Util.backgroundExecutor().execute(() -> modpackUpdaterInstance.startUpdate(modpackUpdaterInstance.getModpackFileList())); return true; } diff --git a/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java b/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java index 33b3e9884..dcb6f5dc2 100644 --- a/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java +++ b/src/main/java/pl/skidam/automodpack/client/ui/FingerprintVerificationScreen.java @@ -18,6 +18,7 @@ public class FingerprintVerificationScreen extends VersionedScreen { private final Runnable validatedCallback; private final Runnable canceledCallback; private boolean validated = false; + private String inputText = ""; private final Toast failedToast = new SystemToast(SystemToast.SystemToastId.PACK_LOAD_FAILURE, VersionedText.translatable("automodpack.validation.failed"), VersionedText.translatable("automodpack.retry")); @@ -40,6 +41,9 @@ protected void init() { super.init(); initWidgets(); + if (!inputText.isEmpty()) { + this.textField.setValue(inputText); + } this.addRenderableWidget(this.textField); this.addRenderableWidget(this.backButton); @@ -90,16 +94,28 @@ public void initWidgets() { setTooltip(wikiButton, VersionedText.translatable("automodpack.learnmore")); } - private void verifyFingerprint() { + public void forceValidate() { + verifyButton.active = false; + this.validated = true; + this.inputText = ""; + if (this.minecraft != null) { + this.minecraft.gui.setScreen(parent); + } + validatedCallback.run(); + } + + public void setInputText(String text) { + this.inputText = text; + if (this.textField != null) { + this.textField.setValue(text); + } + } + + public void verifyFingerprint() { String input = textField.getValue().strip(); - + inputText = input; if (input.equals(serverFingerprint)) { - verifyButton.active = false; - this.validated = true; - if (this.minecraft != null) { - this.minecraft.gui.setScreen(parent); - } - validatedCallback.run(); + forceValidate(); } else { Constants.LOGGER.error("Server fingerprint validation failed, try again"); if (this.minecraft != null) { diff --git a/src/main/java/pl/skidam/automodpack/init/FabricInit.java b/src/main/java/pl/skidam/automodpack/init/FabricInit.java index 92c675613..21861f227 100644 --- a/src/main/java/pl/skidam/automodpack/init/FabricInit.java +++ b/src/main/java/pl/skidam/automodpack/init/FabricInit.java @@ -37,4 +37,4 @@ public static void onInitialize() { LOGGER.info("AutoModpack launched! took " + (System.currentTimeMillis() - start) + "ms"); } } -/*?}*/ \ No newline at end of file +/*?}*/ diff --git a/src/main/java/pl/skidam/automodpack/init/ForgeInit.java b/src/main/java/pl/skidam/automodpack/init/ForgeInit.java index 509adccd6..df89b0ebe 100644 --- a/src/main/java/pl/skidam/automodpack/init/ForgeInit.java +++ b/src/main/java/pl/skidam/automodpack/init/ForgeInit.java @@ -46,4 +46,4 @@ public static void onCommandsRegister(RegisterCommandsEvent event) { } } } -*//*?}*/ \ No newline at end of file +*//*?}*/ diff --git a/src/main/java/pl/skidam/automodpack/mixin/MixinPlugin.java b/src/main/java/pl/skidam/automodpack/mixin/MixinPlugin.java index b58bd9607..ed854e335 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/MixinPlugin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/MixinPlugin.java @@ -8,6 +8,8 @@ import java.util.Set; public class MixinPlugin implements IMixinConfigPlugin { + private static final boolean AUTOTEST_ENABLED = Boolean.getBoolean("automodpack.autotest"); + @Override public void onLoad(String mixinPackage) { // Needed for versions < 1.18 @@ -21,6 +23,9 @@ public String getRefMapperConfig() { @Override public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { + if (mixinClassName.contains(".dev.")) { + return AUTOTEST_ENABLED; + } return true; } diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java index 70e305445..4c31522cc 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/FabricLoginMixin.java @@ -1,6 +1,5 @@ package pl.skidam.automodpack.mixin.core; -import net.fabricmc.fabric.impl.networking.server.ServerLoginNetworkAddon; import net.minecraft.network.protocol.login.ClientboundCustomQueryPacket; import net.minecraft.resources.Identifier; import org.spongepowered.asm.mixin.Mixin; @@ -11,7 +10,7 @@ import pl.skidam.automodpack.networking.LoginNetworkingIDs; @Pseudo -@Mixin(value = ServerLoginNetworkAddon.class, remap = false) +@Mixin(targets = "net.fabricmc.fabric.impl.networking.server.ServerLoginNetworkAddon", remap = false) public class FabricLoginMixin { @Inject( diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java index 15a325b36..12cc65ef7 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryRequestS2CPacketMixin.java @@ -1,9 +1,10 @@ package pl.skidam.automodpack.mixin.core; +import net.minecraft.network.protocol.login.ClientboundCustomQueryPacket; import org.spongepowered.asm.mixin.Mixin; + /*? if >=1.20.2 {*/ import net.minecraft.network.FriendlyByteBuf; -import net.minecraft.network.protocol.login.ClientboundCustomQueryPacket; import net.minecraft.network.protocol.login.custom.CustomQueryPayload; import net.minecraft.resources.Identifier; import org.spongepowered.asm.mixin.Final; @@ -14,16 +15,15 @@ import pl.skidam.automodpack.networking.PayloadHelper; import pl.skidam.automodpack.networking.server.LoginRequestPayload; import pl.skidam.automodpack_core.Constants; +/*?}*/ -// TODO find better way to do this, its mixin only for 1.20.2 and above +// ClientboundCustomQueryPacket exists on every version, so below 1.20.2 the body +// is simply disabled — the readPayload injection only exists from 1.20.2 — +// leaving an intentional no-op mixin. @Mixin(value = ClientboundCustomQueryPacket.class, priority = 300) -/*?} else {*/ -/*import pl.skidam.automodpack.init.Common; -@Mixin(Common.class) -*//*?}*/ public class LoginQueryRequestS2CPacketMixin { -/*? if >=1.20.2 {*/ + /*? if >=1.20.2 {*/ @Shadow @Final private static int MAX_PAYLOAD_SIZE; @Inject(method = "readPayload", at = @At("HEAD"), cancellable = true) @@ -32,5 +32,5 @@ private static void readPayload(Identifier id, FriendlyByteBuf buf, CallbackInfo cir.setReturnValue(new LoginRequestPayload(id, PayloadHelper.read(buf, MAX_PAYLOAD_SIZE))); } } -/*?}*/ -} \ No newline at end of file + /*?}*/ +} diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java index d845e989d..eb63bc34e 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/LoginQueryResponseC2SPacketMixin.java @@ -1,9 +1,10 @@ package pl.skidam.automodpack.mixin.core; +import net.minecraft.network.protocol.login.ServerboundCustomQueryAnswerPacket; import org.spongepowered.asm.mixin.Mixin; + /*? if >=1.20.2 {*/ import net.minecraft.network.FriendlyByteBuf; -import net.minecraft.network.protocol.login.ServerboundCustomQueryAnswerPacket; import net.minecraft.network.protocol.login.custom.CustomQueryAnswerPayload; import net.minecraft.resources.Identifier; import org.spongepowered.asm.mixin.Final; @@ -14,15 +15,15 @@ import pl.skidam.automodpack.networking.LoginNetworkingIDs; import pl.skidam.automodpack.networking.PayloadHelper; import pl.skidam.automodpack.networking.client.LoginResponsePayload; +/*?}*/ -// TODO find better way to do this, its mixin only for 1.20.2 and above +// Below 1.20.2 the stonecutter replacement rewrites the target to its old name +// (ServerboundCustomQueryPacket) and the body is disabled — the readPayload +// injection only exists from 1.20.2 — leaving an intentional no-op mixin. @Mixin(value = ServerboundCustomQueryAnswerPacket.class, priority = 300) -/*?} else {*/ -/*import pl.skidam.automodpack.init.Common; -@Mixin(Common.class) -*//*?}*/ public class LoginQueryResponseC2SPacketMixin { -/*? if >=1.20.2 {*/ + + /*? if >=1.20.2 {*/ @Shadow @Final private static int MAX_PAYLOAD_SIZE; @@ -43,5 +44,5 @@ private static void readResponse(int queryId, FriendlyByteBuf buf, CallbackInfoR cir.setReturnValue(new LoginResponsePayload(automodpackID, PayloadHelper.read(buf, MAX_PAYLOAD_SIZE))); } -/*?}*/ -} \ No newline at end of file + /*?}*/ +} diff --git a/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java b/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java index 73c4fbad9..77b0cdca9 100644 --- a/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java +++ b/src/main/java/pl/skidam/automodpack/mixin/core/ServerLoginNetworkHandlerMixin.java @@ -2,77 +2,114 @@ import net.minecraft.network.protocol.login.ServerboundCustomQueryAnswerPacket; import net.minecraft.server.network.ServerLoginPacketListenerImpl; -import org.spongepowered.asm.mixin.*; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import pl.skidam.automodpack.networking.client.LoginResponsePayload; import pl.skidam.automodpack.networking.server.ServerLoginNetworkAddon; -import static pl.skidam.automodpack_core.Constants.LOGGER; - @Mixin(value = ServerLoginPacketListenerImpl.class, priority = 300) -public abstract class ServerLoginNetworkHandlerMixin { +public abstract class ServerLoginNetworkHandlerMixin { + + /* + * State enum per target version: + * 1.18.2 - 1.20.1: HELLO, KEY, AUTHENTICATING, NEGOTIATING, + * READY_TO_ACCEPT, DELAY_ACCEPT, ACCEPTED + * 1.21.1+: HELLO, KEY, AUTHENTICATING, NEGOTIATING, + * VERIFYING, WAITING_FOR_DUPE_DISCONNECT, + * PROTOCOL_SWITCHING, ACCEPTED + * + * tick() per state (all versions): + * HELLO, KEY, AUTHENTICATING: no-op (just timeout counter) + * NEGOTIATING: no-op (just timeout counter) + * READY_TO_ACCEPT (≤ 1.20.1): calls handleAcceptedLogin() + * — login finalization + compression setup + * VERIFYING (≥ 1.21.1): calls verifyLoginAndFinishConnectionSetup() + * — login finalization + compression setup + * DELAY_ACCEPT (≤ 1.20.1): duplicate player check + * WAITING_FOR_DUPE_DISCONNECT: duplicate player check + */ + @Shadow + private ServerLoginPacketListenerImpl.State state; - @Shadow private ServerLoginPacketListenerImpl.State state; - @Unique private ServerLoginNetworkAddon automodpack$addon; + @Unique + private ServerLoginNetworkAddon automodpack$addon; - @Inject( - method = "", - at = @At("RETURN") - ) + @Inject(method = "", at = @At("RETURN")) private void initAddon(CallbackInfo ci) { this.automodpack$addon = new ServerLoginNetworkAddon((ServerLoginPacketListenerImpl) (Object) this); } - @Inject( - method = "handleCustomQueryPacket", - at = @At("HEAD"), - cancellable = true - ) + @Inject(method = "handleCustomQueryPacket", at = @At("HEAD"), cancellable = true) private void handleCustomPayload(ServerboundCustomQueryAnswerPacket packet, CallbackInfo ci) { if (this.automodpack$addon == null) { return; } - // Handle queries if (this.automodpack$addon.handle(packet)) { - ci.cancel(); // We have handled it, cancel vanilla behavior - } else { - // Catch unhandled AutoModpack `LoginResponsePayload` packets - it generally shouldn't happen, but just in case and also fabric api does it too... - /*? if >=1.20.2 {*/ - if (packet.payload() instanceof LoginResponsePayload response) { - if (response.data() != null) { - response.data().skipBytes(response.data().readableBytes()); - } - LOGGER.debug("Unhandled LoginResponsePayload in ServerLoginPacketListenerImpl with id: {}", response.id()); - } - /*?}*/ + ci.cancel(); } } - @Inject( - method = "tick", - at = @At(value = "HEAD"), - cancellable = true - ) + /* + * We intercept tick() to hold the login in progress while our query + * exchange completes. Both NEGOTIATING and READY_TO_ACCEPT/VERIFYING + * are checked, but for different reasons: + * + * NEGOTIATING — START queries. tick() is a no-op here, so we use + * this state to call queryTick() which sends the initial handshake + * query. Cancel is harmless (tick does nothing critical). + * + * READY_TO_ACCEPT / VERIFYING — PREVENT finalization. In this state + * tick() calls the login finaliser (handleAcceptedLogin / + * verifyLoginAndFinishConnectionSetup) which sets up compression and + * places the player. We MUST cancel to delay this step until queries + * are finished. + * + * + * == Why not just NEGOTIATING? == + * + * NEGOTIATING is not always entered. On servers with no proxy (the + * common case), handleLoginHello / handleAuthentication sets state + * directly to READY_TO_ACCEPT/VERIFYING, bypassing NEGOTIATING + * entirely. If we only checked NEGOTIATING, those connections would + * never start queries, tick() would finalise immediately, and the + * late query response would hit a compression- or protocol-swapped + * pipeline ("Pipeline has no outbound protocol configured"). + * + * We check NEGOTIATING too as an optimization: when the state DOES + * pass through NEGOTIATING (online mode, proxy-enabled servers), + * we kick off queries a few ticks earlier instead of waiting for + * READY_TO_ACCEPT/VERIFYING. + * + * + * HELLO / KEY / AUTHENTICATING are not intercepted because they run + * before the connection is ready for custom queries, and tick() does + * non-trivial work in those states. + */ + @Inject(method = "tick", at = @At("HEAD"), cancellable = true) private void sendOurPackets(CallbackInfo ci) { if (this.automodpack$addon == null) { return; } - if (state != ServerLoginPacketListenerImpl.State.NEGOTIATING && state != ServerLoginPacketListenerImpl.State./*? if <1.20.2 {*/ /*READY_TO_ACCEPT *//*?} else {*/VERIFYING/*?}*/) { + /*? if <= 1.20.1 {*/ + /*if (this.state != ServerLoginPacketListenerImpl.State.NEGOTIATING && this.state != ServerLoginPacketListenerImpl.State.READY_TO_ACCEPT) { return; } + *//*?} else {*/ + if (this.state != ServerLoginPacketListenerImpl.State.NEGOTIATING && this.state != ServerLoginPacketListenerImpl.State.VERIFYING) { + return; + } + /*?}*/ - // Send first automodpack packet if (!this.automodpack$addon.queryTick()) { - // We need more time to process packets ci.cancel(); return; } this.automodpack$addon = null; } - } diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java b/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java new file mode 100644 index 000000000..d1479df76 --- /dev/null +++ b/src/main/java/pl/skidam/automodpack/mixin/dev/MinecraftMixin.java @@ -0,0 +1,18 @@ +package pl.skidam.automodpack.mixin.dev; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.main.GameConfig; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import pl.skidam.automodpack.client.autotest.AutoTestBridge; + +@Mixin(Minecraft.class) +public abstract class MinecraftMixin { + + @Inject(method = "", at = @At("RETURN")) + private void onInit(GameConfig gameConfig, CallbackInfo ci) { + AutoTestBridge.startIfEnabled(); + } +} diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java b/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java new file mode 100644 index 000000000..8196395cc --- /dev/null +++ b/src/main/java/pl/skidam/automodpack/mixin/dev/ResourceLoadStateTrackerMixin.java @@ -0,0 +1,22 @@ +package pl.skidam.automodpack.mixin.dev; + +import net.minecraft.client.ResourceLoadStateTracker; +import net.minecraft.client.gui.screens.TitleScreen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import pl.skidam.automodpack.client.autotest.AutoTestBridge; +import pl.skidam.automodpack_loader_core.screen.ScreenManager; + +@Mixin(ResourceLoadStateTracker.class) +public class ResourceLoadStateTrackerMixin { + + @Inject(method = "finishReload", at = @At("RETURN")) + private void onFinishReload(CallbackInfo ci) { + AutoTestBridge.markReloadFinished(); + if (new ScreenManager().getScreen().orElse(null) instanceof TitleScreen) { + AutoTestBridge.onClientReady(); + } + } +} diff --git a/src/main/java/pl/skidam/automodpack/mixin/dev/TestButton.java b/src/main/java/pl/skidam/automodpack/mixin/dev/TestButton.java deleted file mode 100644 index 5196eaf62..000000000 --- a/src/main/java/pl/skidam/automodpack/mixin/dev/TestButton.java +++ /dev/null @@ -1,50 +0,0 @@ -package pl.skidam.automodpack.mixin.dev; - -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import pl.skidam.automodpack.client.audio.AudioManager; -import pl.skidam.automodpack.client.ui.versioned.VersionedScreen; -import pl.skidam.automodpack.client.ui.versioned.VersionedText; -import pl.skidam.automodpack_loader_core.screen.ScreenManager; - -import static pl.skidam.automodpack_core.Constants.LOADER_MANAGER; - -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.gui.screens.TitleScreen; -import net.minecraft.network.chat.Component; - -@Mixin(TitleScreen.class) -public class TestButton extends Screen { - - protected TestButton(Component title) { - super(title); - } - - @Inject( - method = "init", - at = @At("HEAD") - ) - private void init(CallbackInfo ci) { - - // check if we are in dev environment - if (!LOADER_MANAGER.isDevelopmentEnvironment()) { - return; - } - - this.addRenderableWidget( - VersionedScreen.buttonWidget( - this.width / 2 - 124, - 90, - 20, - 20, - VersionedText.literal("AM"), - button -> { - AudioManager.playMusic(); - new ScreenManager().menu(); - } - ) - ); - } -} diff --git a/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java b/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java index e12cdad5e..ec6a75188 100644 --- a/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java +++ b/src/main/java/pl/skidam/automodpack/networking/client/ClientLoginNetworkAddon.java @@ -1,5 +1,6 @@ package pl.skidam.automodpack.networking.client; +import io.netty.buffer.Unpooled; import org.jetbrains.annotations.Nullable; import pl.skidam.automodpack.mixin.core.ClientLoginNetworkHandlerAccessor; import pl.skidam.automodpack.networking.LoginQueryParser; @@ -47,15 +48,27 @@ private boolean handlePacket(int queryId, Identifier channelName, FriendlyByteBu try { CompletableFuture future = handler.receive(this.client, this.handler, buf); - future.thenAccept(resultBuf -> { - ServerboundCustomQueryAnswerPacket packet = new ServerboundCustomQueryAnswerPacket(queryId, /*? if <1.20.2 {*/ /*resultBuf *//*?} else {*/ new LoginResponsePayload(channelName, resultBuf) /*?}*/); - ((ClientLoginNetworkHandlerAccessor) this.handler).getConnection().send(packet); + future.whenComplete((resultBuf, throwable) -> { + if (throwable != null) { + LOGGER.error("Failed to handle login query in channel \"{}\"", channelName, throwable); + resultBuf = new FriendlyByteBuf(Unpooled.buffer()); + } + if (resultBuf == null) { + resultBuf = new FriendlyByteBuf(Unpooled.buffer()); + } + sendResponse(queryId, channelName, resultBuf); }); } catch (Throwable e) { LOGGER.error("Encountered exception while handling in channel with name \"{}\"", channelName, e); + sendResponse(queryId, channelName, new FriendlyByteBuf(Unpooled.buffer())); throw e; } return true; } + + private void sendResponse(int queryId, Identifier channelName, FriendlyByteBuf resultBuf) { + ServerboundCustomQueryAnswerPacket packet = new ServerboundCustomQueryAnswerPacket(queryId, /*? if <1.20.2 {*/ /*resultBuf *//*?} else {*/ new LoginResponsePayload(channelName, resultBuf) /*?}*/); + ((ClientLoginNetworkHandlerAccessor) this.handler).getConnection().send(packet); + } } diff --git a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java index 2f05f756d..24194f927 100644 --- a/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java +++ b/src/main/java/pl/skidam/automodpack/networking/packet/DataC2SPacket.java @@ -8,6 +8,7 @@ import pl.skidam.automodpack_core.auth.Secrets; import pl.skidam.automodpack_core.auth.SecretsStore; import pl.skidam.automodpack_core.config.Jsons; +import pl.skidam.automodpack_core.protocol.DownloadClient; import pl.skidam.automodpack_core.utils.AddressHelpers; import pl.skidam.automodpack_loader_core.ReLauncher; import pl.skidam.automodpack_loader_core.client.ModpackUpdater; @@ -27,31 +28,41 @@ import static pl.skidam.automodpack_core.config.ConfigTools.GSON; public class DataC2SPacket { - public static CompletableFuture receive(Minecraft Minecraft, ClientHandshakePacketListenerImpl handler, FriendlyByteBuf buf) { + public static CompletableFuture receive(Minecraft client, ClientHandshakePacketListenerImpl handler, FriendlyByteBuf buf) { + DataPacket dataPacket; try { String serverResponse = buf.readUtf(Short.MAX_VALUE); - DataPacket dataPacket = DataPacket.fromJson(serverResponse); - - String packetAddress = dataPacket.address; - int packetPort = dataPacket.port; - String modpackName = dataPacket.modpackName; - Secrets.Secret secret = dataPacket.secret; - boolean modRequired = dataPacket.modRequired; - boolean requiresMagic = dataPacket.requiresMagic; - - if (modRequired) { - // TODO set screen to refreshed danger screen which will ask user to install modpack with two options - // 1. Disconnect and install modpack - // 2. Dont disconnect and join server - } + dataPacket = DataPacket.fromJson(serverResponse); + } catch (Exception e) { + LOGGER.error("Error parsing data packet", e); + FriendlyByteBuf error = new FriendlyByteBuf(Unpooled.buffer()); + error.writeUtf("null", Short.MAX_VALUE); + return CompletableFuture.completedFuture(error); + } - InetSocketAddress serverAddress = ModPackets.getOriginalServerAddress(); - ModPackets.setOriginalServerAddress(null); // Reset for next server reconnection - if (serverAddress == null) { - LOGGER.error("Server address is null! Something gone very wrong! Please report this issue! https://github.com/Skidamek/AutoModpack/issues"); - return CompletableFuture.completedFuture(new FriendlyByteBuf(Unpooled.buffer())); - } + String packetAddress = dataPacket.address == null ? "" : dataPacket.address; + int packetPort = dataPacket.port; + String modpackName = dataPacket.modpackName == null ? "" : dataPacket.modpackName; + Secrets.Secret secret = dataPacket.secret; + boolean modRequired = dataPacket.modRequired; + boolean requiresMagic = dataPacket.requiresMagic; + + if (modRequired) { + // TODO set screen to refreshed danger screen which will ask user to install modpack with two options + // 1. Disconnect and install modpack + // 2. Dont disconnect and join server + } + InetSocketAddress serverAddress = ModPackets.getOriginalServerAddress(); + ModPackets.setOriginalServerAddress(null); // Reset for next server reconnection + if (serverAddress == null) { + LOGGER.error("Server address is null! Something gone very wrong! Please report this issue! https://github.com/Skidamek/AutoModpack/issues"); + return CompletableFuture.completedFuture(buildResponse(null)); + } + + Path modpackDir; + Jsons.ModpackAddresses modpackAddresses; + try { // Get actual address of the server client have connected to and format it InetSocketAddress connectedAddress = (InetSocketAddress) ((ClientLoginNetworkHandlerAccessor) handler).getConnection().getRemoteAddress(); String effectiveHost; @@ -59,10 +70,11 @@ public static CompletableFuture receive(Minecraft Minecraft, Cl // If the packet specifies a non-blank address, use it or else use address from the server client have connected to. // Important! Use getAddress().getHostAddress() instead of getHostString() - // because Minecraft creates connectedAddress instance through a constructor which attempts a reverse DNS lookup - // which resolves PTR record for the IP address and stores the resolved hostname in the hostname field. + // because Minecraft creates connectedAddress instance through a constructor which attempts a reverse DNS lookup + // which resolves PTR record for the IP address and stores the resolved hostname in the hostname field. if (packetAddress.isBlank()) { - effectiveHost = connectedAddress.getAddress().getHostAddress(); + var connectedInetAddress = connectedAddress.getAddress(); + effectiveHost = connectedInetAddress == null ? connectedAddress.getHostString() : connectedInetAddress.getHostAddress(); } else { effectiveHost = packetAddress; } @@ -78,58 +90,74 @@ public static CompletableFuture receive(Minecraft Minecraft, Cl LOGGER.info("Modpack address: {}:{} Requires to follow magic protocol: {}", modpackAddress.getHostString(), modpackAddress.getPort(), requiresMagic); - Boolean needsDisconnecting = null; - FriendlyByteBuf response = new FriendlyByteBuf(Unpooled.buffer()); - - Path modpackDir = ModpackUtils.getModpackPath(modpackAddress, modpackName); - Jsons.ModpackAddresses modpackAddresses = new Jsons.ModpackAddresses(modpackAddress, serverAddress, requiresMagic); - var optionalServerModpackContent = ModpackUtils.requestServerModpackContent(modpackAddresses, secret, true); - - if (optionalServerModpackContent.isPresent()) { - ModpackUtils.UpdateCheckResult updateCheckResult = ModpackUtils.isUpdate(optionalServerModpackContent.get(), modpackDir); - - if (updateCheckResult.requiresUpdate()) { - disconnectImmediately(handler); - new ModpackUpdater(optionalServerModpackContent.get(), modpackAddresses, secret, modpackDir).processModpackUpdate(updateCheckResult); - needsDisconnecting = true; - } else { - boolean selectedModpackChanged = ModpackUtils.selectModpack(modpackDir, modpackAddresses, Set.of()); + modpackDir = ModpackUtils.getModpackPath(modpackAddress, modpackName); + modpackAddresses = new Jsons.ModpackAddresses(modpackAddress, serverAddress, requiresMagic); + } catch (Exception e) { + LOGGER.error("Error preparing modpack address from data packet", e); + return CompletableFuture.completedFuture(buildResponse(null)); + } - // save latest modpack content - var modpackContentFile = modpackDir.resolve(hostModpackContentFile.getFileName()); - if (Files.exists(modpackContentFile)) { - Files.writeString(modpackContentFile, GSON.toJson(optionalServerModpackContent.get())); + return ModpackUtils.requestServerModpackContentAsync(modpackAddresses, secret, true) + .thenApplyAsync(optionalServerModpackContent -> { + Boolean needsDisconnecting = null; + + if (optionalServerModpackContent.isPresent()) { + ModpackUtils.UpdateCheckResult updateCheckResult = ModpackUtils.isUpdate(optionalServerModpackContent.get(), modpackDir); + + if (updateCheckResult.requiresUpdate()) { + disconnectImmediately(handler); + new ModpackUpdater(optionalServerModpackContent.get(), modpackAddresses, secret, modpackDir).processModpackUpdate(updateCheckResult); + needsDisconnecting = true; + } else { + boolean selectedModpackChanged = ModpackUtils.selectModpack(modpackDir, modpackAddresses, Set.of()); + + var modpackContentFile = modpackDir.resolve(hostModpackContentFile.getFileName()); + if (Files.exists(modpackContentFile)) { + try { + Files.writeString(modpackContentFile, GSON.toJson(optionalServerModpackContent.get())); + } catch (Exception ignored) {} + } + + if (selectedModpackChanged) { + SecretsStore.saveClientSecret(clientConfig.selectedModpack, secret); + disconnectImmediately(handler); + new ReLauncher(modpackDir, UpdateType.SELECT, null).restart(false); + needsDisconnecting = true; + } else { + needsDisconnecting = false; + } + } + } else if (ModpackUtils.canConnectModpackHost(modpackAddresses)) { + // Couldn't download the modpack content (e.g. certificate not verified) but the host is reachable + needsDisconnecting = true; } - if (selectedModpackChanged) { + if (clientConfig.selectedModpack != null && !clientConfig.selectedModpack.isBlank()) { SecretsStore.saveClientSecret(clientConfig.selectedModpack, secret); - disconnectImmediately(handler); - new ReLauncher(modpackDir, UpdateType.SELECT, null).restart(false); - needsDisconnecting = true; - } else { - needsDisconnecting = false; } - } - } else if (ModpackUtils.canConnectModpackHost(modpackAddresses)) { // Can't download modpack because e.g. certificate is not verified but it can connect to the modpack host - needsDisconnecting = true; - } - if (clientConfig.selectedModpack != null && !clientConfig.selectedModpack.isBlank()) { - SecretsStore.saveClientSecret(clientConfig.selectedModpack, secret); - } + return buildResponse(needsDisconnecting); + }, DownloadClient.NET_EXECUTOR) + .exceptionally(e -> { + LOGGER.error("Error while handling data packet", e); + FriendlyByteBuf response = new FriendlyByteBuf(Unpooled.buffer()); + response.writeUtf("null", Short.MAX_VALUE); + return response; + }); + } + private static FriendlyByteBuf buildResponse(Boolean needsDisconnecting) { + FriendlyByteBuf response = new FriendlyByteBuf(Unpooled.buffer()); + if (needsDisconnecting != null) { response.writeUtf(String.valueOf(needsDisconnecting), Short.MAX_VALUE); - - return CompletableFuture.completedFuture(response); - } catch (Exception e) { - LOGGER.error("Error while handling data packet", e); - FriendlyByteBuf response = new FriendlyByteBuf(Unpooled.buffer()); + } else { response.writeUtf("null", Short.MAX_VALUE); - return CompletableFuture.completedFuture(new FriendlyByteBuf(Unpooled.buffer())); } + return response; } private static void disconnectImmediately(ClientHandshakePacketListenerImpl clientLoginNetworkHandler) { - ((ClientConnectionAccessor) ((ClientLoginNetworkHandlerAccessor) clientLoginNetworkHandler).getConnection()).getChannel().disconnect(); + var channel = ((ClientConnectionAccessor) ((ClientLoginNetworkHandlerAccessor) clientLoginNetworkHandler).getConnection()).getChannel(); + channel.disconnect(); } } diff --git a/src/main/java/pl/skidam/automodpack/networking/server/ServerLoginNetworkAddon.java b/src/main/java/pl/skidam/automodpack/networking/server/ServerLoginNetworkAddon.java index 6425c1869..3cf52d25e 100644 --- a/src/main/java/pl/skidam/automodpack/networking/server/ServerLoginNetworkAddon.java +++ b/src/main/java/pl/skidam/automodpack/networking/server/ServerLoginNetworkAddon.java @@ -19,6 +19,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; import static pl.skidam.automodpack_core.Constants.LOGGER; @@ -30,6 +31,7 @@ public class ServerLoginNetworkAddon implements PacketSender { private final MinecraftServer server; private final Collection> synchronizers = new ConcurrentLinkedQueue<>(); public final Map channels = new ConcurrentHashMap<>(); + private final AtomicInteger processingCount = new AtomicInteger(0); private boolean firstTick = true; public ServerLoginNetworkAddon(ServerLoginPacketListenerImpl serverLoginNetworkHandler) { @@ -61,7 +63,7 @@ public boolean queryTick() { return true; }); - return this.channels.isEmpty() && this.synchronizers.isEmpty(); + return this.processingCount.get() == 0 && this.channels.isEmpty() && this.synchronizers.isEmpty(); } /** @@ -71,9 +73,14 @@ public boolean queryTick() { * @return true if the packet was handled */ public boolean handle(ServerboundCustomQueryAnswerPacket packet) { - LoginQueryParser loginQuery = new LoginQueryParser(packet); - if (loginQuery.success) return handle(loginQuery.queryId, loginQuery.buf); - return false; + this.processingCount.incrementAndGet(); + try { + LoginQueryParser loginQuery = new LoginQueryParser(packet); + if (loginQuery.success) return handle(loginQuery.queryId, loginQuery.buf); + return false; + } finally { + this.processingCount.decrementAndGet(); + } } private boolean handle(int queryId, @Nullable FriendlyByteBuf originalBuf) { diff --git a/src/main/resources/automodpack-main.mixins.json b/src/main/resources/automodpack-main.mixins.json index 36fcdff38..958c599aa 100644 --- a/src/main/resources/automodpack-main.mixins.json +++ b/src/main/resources/automodpack-main.mixins.json @@ -19,7 +19,9 @@ "core.ClientLoginNetworkHandlerAccessor", "core.ClientLoginNetworkHandlerMixin", "core.ConnectScreenMixin", - "core.MusicTrackerMixin" + "core.MusicTrackerMixin", + "dev.MinecraftMixin", + "dev.ResourceLoadStateTrackerMixin" ], "injectors": { "defaultRequire": 1 diff --git a/stonecutter.gradle.kts b/stonecutter.gradle.kts index eaa120cbe..92324a91f 100644 --- a/stonecutter.gradle.kts +++ b/stonecutter.gradle.kts @@ -1,10 +1,10 @@ plugins { id("dev.kikugie.stonecutter") - kotlin("jvm") version "2.3.0" apply false - id("net.fabricmc.fabric-loom-remap") version "1.15-SNAPSHOT" apply false - id("net.fabricmc.fabric-loom") version "1.15-SNAPSHOT" apply false + kotlin("jvm") version "2.3.21" apply false + id("net.fabricmc.fabric-loom-remap") version "1.17-SNAPSHOT" apply false + id("net.fabricmc.fabric-loom") version "1.17-SNAPSHOT" apply false id("net.neoforged.moddev") version "2.0.141" apply false - id("com.gradleup.shadow") version "9.4.0" apply false + id("com.gradleup.shadow") version "9.4.3" apply false id("org.moddedmc.wiki.toolkit") version "0.4+" } diff --git a/stonecutter.properties.toml b/stonecutter.properties.toml index 83f430c99..1a6db28f2 100644 --- a/stonecutter.properties.toml +++ b/stonecutter.properties.toml @@ -11,22 +11,22 @@ publish_versions = "26.2" ["26.1-fabric"] -deps.fabric-api = "0.144.0+26.1" +deps.fabric-api = "0.149.1+26.1.2" ["26.1-neoforge"] -deps.neoforge = "26.1.0.1-beta" +deps.neoforge = "26.1.2.64-beta" ["26.1"] deps.minecraft = "26.1" -meta.minecraft = "26.1" -publish_versions = "26.1" +meta.minecraft = "~26.1" +publish_versions = "26.1\n26.1.1\n26.1.2" ["1.21.11-fabric"] -deps.fabric-api = "0.141.1+1.21.11" +deps.fabric-api = "0.141.4+1.21.11" ["1.21.11-neoforge"] -deps.neoforge = "21.11.8-beta" +deps.neoforge = "21.11.42" ["1.21.11"] deps.minecraft = "1.21.11" @@ -62,7 +62,7 @@ publish_versions = "1.21.6\n1.21.7\n1.21.8" deps.fabric-api = "0.128.2+1.21.5" ["1.21.5-neoforge"] -deps.neoforge = "21.5.75" +deps.neoforge = "21.5.97" ["1.21.5"] deps.minecraft = "1.21.5" @@ -74,7 +74,7 @@ publish_versions = "1.21.5" deps.fabric-api = "0.119.4+1.21.4" ["1.21.4-neoforge"] -deps.neoforge = "21.4.135" +deps.neoforge = "21.4.157" ["1.21.4"] deps.minecraft = "1.21.4" @@ -82,23 +82,11 @@ meta.minecraft = "1.21.4" publish_versions = "1.21.4" -["1.21.3-fabric"] -deps.fabric-api = "0.114.1+1.21.3" - -["1.21.3-neoforge"] -deps.neoforge = "21.3.76" - -["1.21.3"] -deps.minecraft = "1.21.3" -meta.minecraft = ">=1.21.2 <=1.21.3" -publish_versions = "1.21.2\n1.21.3" - - ["1.21.1-fabric"] -deps.fabric-api = "0.116.7+1.21.1" +deps.fabric-api = "0.116.12+1.21.1" ["1.21.1-neoforge"] -deps.neoforge = "21.1.169" +deps.neoforge = "21.1.230" ["1.21.1"] deps.minecraft = "1.21.1" @@ -106,30 +94,6 @@ meta.minecraft = ">=1.21 <=1.21.1" publish_versions = "1.21\n1.21.1" -["1.20.6-fabric"] -deps.fabric-api = "0.100.8+1.20.6" - -["1.20.6-neoforge"] -deps.neoforge = "20.6.135" - -["1.20.6"] -deps.minecraft = "1.20.6" -meta.minecraft = "1.20.6" -publish_versions = "1.20.6" - - -["1.20.4-fabric"] -deps.fabric-api = "0.97.3+1.20.4" - -["1.20.4-neoforge"] -deps.neoforge = "20.4.248" - -["1.20.4"] -deps.minecraft = "1.20.4" -meta.minecraft = "1.20.4" -publish_versions = "1.20.4" - - ["1.20.1-fabric"] deps.fabric-api = "0.92.7+1.20.1" @@ -142,18 +106,6 @@ meta.minecraft = ">=1.20 <=1.20.1" publish_versions = "1.20\n1.20.1" -["1.19.4-fabric"] -deps.fabric-api = "0.87.2+1.19.4" - -["1.19.4-forge"] -deps.forge = "1.19.4-45.3.0" - -["1.19.4"] -deps.minecraft = "1.19.4" -meta.minecraft = "1.19.4" -publish_versions = "1.19.4" - - ["1.19.2-fabric"] deps.fabric-api = "0.76.1+1.19.2"