diff --git a/.github/workflows/e2eLocalTests.yml b/.github/workflows/e2eLocalTests.yml index 06be78fb1d7..45310df134e 100644 --- a/.github/workflows/e2eLocalTests.yml +++ b/.github/workflows/e2eLocalTests.yml @@ -43,6 +43,7 @@ jobs: - MacOSX_Safari_Local - Windows_Chrome_Local - Windows_Managed_Lighthouse + - Ubuntu_Managed_Android - Windows_SikuliX_Local - Windows_Appium_Desktop_Local - MacOSX_Chrome_Local @@ -500,9 +501,101 @@ jobs: job-name: Windows_Edge_Cucumber_Local codecov-token: ${{ secrets.CODECOV_TOKEN }} + # Opt-in real acceptance for #4898. The packaged CLI performs the only installation + # mutation; a packaged CLI process starts the receipt-bound AVD/Appium lease, the engine + # reuses it for a real UiAutomator2 session, and a later CLI process owns final shutdown. + Ubuntu_Managed_Android: + if: github.event_name == 'workflow_dispatch' && (github.event.inputs.jobs == 'all' || contains(format(',{0},', github.event.inputs.jobs), ',Ubuntu_Managed_Android,')) + runs-on: ubuntu-22.04 + timeout-minutes: 60 + outputs: + job: ${{ steps.post_test_report.outputs.job }} + total: ${{ steps.post_test_report.outputs.total }} + passed: ${{ steps.post_test_report.outputs.passed }} + failed: ${{ steps.post_test_report.outputs.failed }} + broken: ${{ steps.post_test_report.outputs.broken }} + skipped: ${{ steps.post_test_report.outputs.skipped }} + duration: ${{ steps.post_test_report.outputs.duration }} + steps: + - name: Checkout Code + uses: actions/checkout@v7 + - name: Setup Test Environment + uses: ./.github/actions/setup-test-env + with: + node-version: '24' + - name: Provision the Android emulator host runtime + run: | + if [ -e /dev/kvm ]; then sudo chmod 666 /dev/kvm; fi + sudo apt-get update + sudo apt-get install --yes libpulse0 + - name: Package the setup CLI + run: mvn --batch-mode -pl shaft-cli -am package -DskipTests '-Dallure.automaticallyOpen=false' -Dgpg.skip + - name: Install the reviewed managed Android plan + shell: bash + run: | + cli_jar="$(find shaft-cli/target -maxdepth 1 -name 'shaft-cli-*[0-9].jar' -print -quit)" + test -n "$cli_jar" + cache_root="$RUNNER_TEMP/shaft-android-cache" + data_root="$cache_root/data" + plan="$RUNNER_TEMP/shaft-android-plan.json" + java -jar "$cli_jar" setup plan --profile MOBILE_ANDROID --mode MANAGED \ + --output "$plan" --cache-root "$cache_root" --data-root "$data_root" \ + --startup-timeout PT8M --shutdown-timeout PT1M + digest="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['digest'])" "$plan")" + if ! java -jar "$cli_jar" setup install --plan "$plan" --approve "$digest" \ + --accept-license android-sdk-license --cache-root "$cache_root" --data-root "$data_root" \ + --startup-timeout PT8M --shutdown-timeout PT1M; then + install_log="$data_root/state/logs/mobile-android-install.log" + test ! -f "$install_log" || cat "$install_log" + exit 1 + fi + - name: Start the owned runtime from the packaged CLI + shell: bash + run: | + cli_jar="$(find shaft-cli/target -maxdepth 1 -name 'shaft-cli-*[0-9].jar' -print -quit)" + cache_root="$RUNNER_TEMP/shaft-android-cache" + data_root="$cache_root/data" + plan="$RUNNER_TEMP/shaft-android-plan.json" + digest="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['digest'])" "$plan")" + java -jar "$cli_jar" setup start --plan "$plan" --approve "$digest" \ + --accept-license android-sdk-license --cache-root "$cache_root" --data-root "$data_root" \ + --startup-timeout PT8M --shutdown-timeout PT1M + test -s "$data_root/state/mobile-android-runtime.json" + - name: Run real emulator, UiAutomator2, and aapt2 acceptance + continue-on-error: true + run: >- + mvn --batch-mode -pl shaft-engine -am test + '-Dtest=testPackage.ManagedAndroidE2ETest' + -DrunManagedAndroidE2E=true + -Dinfrastructure.mode=MANAGED + -Dinfrastructure.profile=MOBILE_ANDROID + -Dinfrastructure.cacheDirectory=${{ runner.temp }}/shaft-android-cache + -Dinfrastructure.startupTimeout=PT8M + -Dinfrastructure.shutdownTimeout=PT1M + -Dallure.automaticallyOpen=false + -Dgpg.skip=true + - name: Stop the owned runtime from a later packaged CLI process + if: always() + shell: bash + run: | + cli_jar="$(find shaft-cli/target -maxdepth 1 -name 'shaft-cli-*[0-9].jar' -print -quit)" + cache_root="$RUNNER_TEMP/shaft-android-cache" + data_root="$cache_root/data" + test -s "$data_root/state/mobile-android-runtime.json" + java -jar "$cli_jar" setup stop --profile MOBILE_ANDROID \ + --cache-root "$cache_root" --data-root "$data_root" --shutdown-timeout PT1M + test ! -e "$data_root/state/mobile-android-runtime.json" + - name: Post-Test Report and Check + id: post_test_report + if: always() + uses: ./.github/actions/post-test-report + with: + job-name: Ubuntu_Managed_Android + codecov-token: ${{ secrets.CODECOV_TOKEN }} + notify_local_e2e_tests_failure: name: Notify on nightly failure - needs: [ Windows_Edge_Local, MacOSX_Safari_Local, Windows_Chrome_Local, Windows_Managed_Lighthouse, Windows_SikuliX_Local, Windows_Appium_Desktop_Local, MacOSX_Chrome_Local, Windows_Edge_Cucumber_Local ] + needs: [ Windows_Edge_Local, MacOSX_Safari_Local, Windows_Chrome_Local, Windows_Managed_Lighthouse, Ubuntu_Managed_Android, Windows_SikuliX_Local, Windows_Appium_Desktop_Local, MacOSX_Chrome_Local, Windows_Edge_Cucumber_Local ] if: always() runs-on: ubuntu-22.04 timeout-minutes: 5 diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 6481d68d59e..b08d52c60cc 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -251,6 +251,7 @@ jobs: - '.github/workflows/e2eLocalTests.yml' - 'tests/scripts/test_ocr_infrastructure_boundary.py' - 'tests/scripts/test_lighthouse_infrastructure_boundary.py' + - 'tests/scripts/test_android_infrastructure_boundary.py' infra: - '.github/workflows/pr-gate.yml' # Composite actions this gate's jobs call (issue #4440 added @@ -591,6 +592,10 @@ jobs: java -jar shaft-cli/target/shaft-cli-*[0-9].jar --help java -jar shaft-cli/target/shaft-cli-*[0-9].jar setup --help java -jar shaft-cli/target/shaft-cli-*[0-9].jar setup catalog --json + android_plan="$RUNNER_TEMP/shaft-android-plan.json" + java -jar shaft-cli/target/shaft-cli-*[0-9].jar setup plan --profile MOBILE_ANDROID \ + --mode MANAGED --offline --output "$android_plan" + python3 -c "import json,sys; p=json.load(open(sys.argv[1])); assert len(p['actions']) == 6; assert 'android-sdk-license' in p['actions'][4]['requiredLicenses']" "$android_plan" capture-e2e: name: SHAFT Capture Browser E2E @@ -854,7 +859,8 @@ jobs: python3 -m unittest tests.scripts.test_pilot_module_boundary tests.scripts.test_ocr_infrastructure_boundary - tests.scripts.test_lighthouse_infrastructure_boundary -v + tests.scripts.test_lighthouse_infrastructure_boundary + tests.scripts.test_android_infrastructure_boundary -v # Issue #4071: a TestNG suite XML file that no pom, workflow, or script # references is invisible -- unlike a failing test, nothing reports it, so diff --git a/scripts/ci/validate_quality_configuration.py b/scripts/ci/validate_quality_configuration.py index 83f215daefc..bad0136bd4e 100755 --- a/scripts/ci/validate_quality_configuration.py +++ b/scripts/ci/validate_quality_configuration.py @@ -555,10 +555,10 @@ def validate_quality_configuration(root: Path = ROOT) -> list[str]: e2e_workflow.count('"-DincludeVisualTestRuntime"') + local_e2e_workflow.count('"-DincludeVisualTestRuntime"') ) - if grid_install_count != 4 or local_install_count != 4 or activation_count != 8: + if grid_install_count != 4 or local_install_count != 4 or activation_count != 10: errors.append( "e2eTests.yml and e2eLocalTests.yml must prepare and activate the visual test runtime " - "for 4 grid/cloud and 4 local broad browser jobs" + "for 4 grid/cloud, 4 local broad-browser, and 2 OCR acceptance jobs" ) for required_local_flow in ( "Windows_SikuliX_Local", diff --git a/shaft-cli/src/main/java/com/shaft/commandline/command/SetupCommand.java b/shaft-cli/src/main/java/com/shaft/commandline/command/SetupCommand.java index 23a3e76cae7..a3dcffb6e9d 100644 --- a/shaft-cli/src/main/java/com/shaft/commandline/command/SetupCommand.java +++ b/shaft-cli/src/main/java/com/shaft/commandline/command/SetupCommand.java @@ -1,8 +1,9 @@ package com.shaft.commandline.command; -import com.shaft.infrastructure.ReportingSetupPlanner; import com.shaft.infrastructure.ReportingSetupService; import com.shaft.infrastructure.InfrastructureSetupService; +import com.shaft.infrastructure.AndroidSetupRequest; +import com.shaft.infrastructure.AndroidRuntimeManager; import com.shaft.infrastructure.SetupOptions; import com.shaft.infrastructure.SetupApproval; import com.shaft.infrastructure.SetupArchitecture; @@ -13,7 +14,6 @@ import com.shaft.infrastructure.SetupPlanStore; import com.shaft.infrastructure.SetupPlatform; import com.shaft.infrastructure.SetupProfile; -import com.shaft.infrastructure.SetupProfileStatus; import com.shaft.infrastructure.SetupReadiness; import com.shaft.infrastructure.SetupReport; import com.shaft.infrastructure.SetupSelection; @@ -95,6 +95,7 @@ static final class Plan implements Callable { @Mixin private RootOptions roots; @Mixin private PolicyOptions policy; + @Mixin private AndroidOptions android; @Option(names = "--language", description = "OCR language code (repeatable).") private List languages = new java.util.ArrayList<>(); @@ -112,9 +113,10 @@ public Integer call() { throw new IllegalArgumentException("--language is supported only for profile OCR."); } SetupOptions options = policy.options(profile, mode, roots.paths()); + SetupSelection selection = selection(profile, languages, android.request(profile)); SetupPlan plan = InfrastructureSetupService.builtIn( SetupPlatform.current(), SetupArchitecture.current()) - .plan(options, new SetupSelection(languages)); + .plan(options, selection); if (!output.isAbsolute()) { spec.commandLine().getErr().println("--output must be an absolute path."); return 2; @@ -150,6 +152,7 @@ static final class Install implements Callable { private boolean json; @Mixin private RootOptions roots; @Mixin private PolicyOptions policy; + @Mixin private AndroidOptions android; @Option(names = "--language", description = "OCR language code from the reviewed plan (repeatable).") private List languages = new java.util.ArrayList<>(); @Spec private CommandSpec spec; @@ -161,8 +164,7 @@ public Integer call() { if (plan.profile() != SetupProfile.OCR && !languages.isEmpty()) { throw new IllegalArgumentException("--language is supported only for profile OCR."); } - SetupSelection selection = plan.profile() == SetupProfile.OCR - ? ocrSelection(plan, languages) : SetupSelection.defaults(); + SetupSelection selection = selectionFromPlan(plan, languages, android); SetupOptions options = policy.options(plan.profile(), plan.mode(), roots.paths()); var receipt = InfrastructureSetupService.builtIn().install(plan, new SetupApproval(approvedDigest, Instant.now(), acceptedLicenses), options, selection); @@ -174,7 +176,7 @@ public Integer call() { spec.commandLine().getErr().println(failure.getMessage()); return 2; } catch (Exception failure) { - spec.commandLine().getErr().println(failure.getMessage()); + spec.commandLine().getErr().println(failureDetails(failure)); return 5; } } @@ -182,28 +184,101 @@ public Integer call() { @Command(name = "start", mixinStandardHelpOptions = true, description = "Start a SHAFT-owned service from its verified receipt.") - static final class Start extends UnsupportedLifecycle { } + static final class Start implements Callable { + @Option(names = "--plan", required = true) private Path planFile; + @Option(names = "--approve", required = true) private String approvedDigest; + @Option(names = "--accept-license") private Set acceptedLicenses = new LinkedHashSet<>(); + @Option(names = "--json") private boolean json; + @Option(names = "--language") private List languages = new java.util.ArrayList<>(); + @Mixin private RootOptions roots; + @Mixin private PolicyOptions policy; + @Mixin private AndroidOptions android; + @Spec private CommandSpec spec; + + @Override + public Integer call() { + try { + SetupPlan plan = SetupPlanStore.read(planFile); + if (plan.profile() != SetupProfile.MOBILE_ANDROID) return unsupported(spec, plan.profile()); + SetupSelection selection = selectionFromPlan(plan, languages, android); + SetupOptions options = policy.options(plan.profile(), plan.mode(), roots.paths()); + var environment = InfrastructureSetupService.builtIn().start(plan, + new SetupApproval(approvedDigest, Instant.now(), acceptedLicenses), options, selection); + if (json) spec.commandLine().getOut().println(Json.MAPPER.writerWithDefaultPrettyPrinter() + .writeValueAsString(java.util.Map.of("profile", environment.profile(), + "endpoint", environment.endpoint().map(Object::toString).orElse(""), + "connectionProperties", environment.connectionProperties(), + "planDigest", environment.receipt().planDigest()))); + else spec.commandLine().getOut().println("Started " + environment.profile() + " at " + + environment.endpoint().map(Object::toString).orElse("owned local runtime")); + return 0; + } catch (IllegalArgumentException failure) { + spec.commandLine().getErr().println(failure.getMessage()); + return 2; + } catch (Exception failure) { + spec.commandLine().getErr().println(failureDetails(failure)); + return 5; + } + } + } @Command(name = "stop", mixinStandardHelpOptions = true, description = "Stop a SHAFT-owned service identified by its lease.") - static final class Stop extends UnsupportedLifecycle { } + static final class Stop implements Callable { + @Option(names = "--profile", required = true) private SetupProfile profile; + @Mixin private RootOptions roots; + @Mixin private PolicyOptions policy; + @Mixin private AndroidOptions android; + @Spec private CommandSpec spec; + + @Override + public Integer call() { + try { + if (profile != SetupProfile.MOBILE_ANDROID) return unsupported(spec, profile); + ShaftCachePaths paths = roots.paths(); + AndroidSetupRequest request = android.request(profile); + boolean stopped = AndroidRuntimeManager.stop(paths, SetupPlatform.current(), + SetupArchitecture.current(), request, + policy.options(profile, SetupMode.MANAGED, paths).shutdownTimeout()); + if (!stopped) { + spec.commandLine().getErr().println("No live owned Android runtime exists."); + return 3; + } + spec.commandLine().getOut().println("Stopped the owned Android runtime."); + return 0; + } catch (IllegalArgumentException failure) { + spec.commandLine().getErr().println(failure.getMessage()); + return 2; + } catch (Exception failure) { + spec.commandLine().getErr().println(failure.getMessage()); + return 5; + } + } + } @Command(name = "logs", mixinStandardHelpOptions = true, description = "Read logs for a SHAFT-owned setup provider.") static final class Logs implements Callable { @Option(names = "--profile", required = true) private SetupProfile profile; @Mixin private RootOptions roots; + @Mixin private AndroidOptions android; @Spec private CommandSpec spec; @Override public Integer call() throws Exception { - if (profile != SetupProfile.REPORTING) return unsupported(spec, profile); - Path log = service(roots).logFile(); - if (Files.notExists(log)) { + String content; + if (profile == SetupProfile.REPORTING) { + Path log = service(roots).logFile(); + content = Files.notExists(log) ? "" : Files.readString(log); + } else if (profile == SetupProfile.MOBILE_ANDROID) { + content = AndroidRuntimeManager.logs(roots.paths(), SetupPlatform.current(), + SetupArchitecture.current(), android.request(profile)); + } else return unsupported(spec, profile); + if (content.isEmpty()) { spec.commandLine().getErr().println("No owned logs exist for profile " + profile + '.'); return 3; } - spec.commandLine().getOut().print(Files.readString(log)); + spec.commandLine().getOut().print(content); return 0; } } @@ -212,6 +287,7 @@ static class ReadinessCommand implements Callable { @Option(names = "--profile", required = true) private SetupProfile profile; @Option(names = "--json", description = "Print machine-readable JSON.") private boolean json; @Mixin private RootOptions roots; + @Mixin private AndroidOptions android; @Option(names = "--language", description = "OCR language code (repeatable).") private List languages = new java.util.ArrayList<>(); @Spec private CommandSpec spec; @@ -224,7 +300,8 @@ public Integer call() { throw new IllegalArgumentException("--language is supported only for profile OCR."); } SetupReport status = InfrastructureSetupService.builtIn().status( - SetupOptions.defaults(profile, roots.paths()), new SetupSelection(languages)); + SetupOptions.defaults(profile, roots.paths()), + selection(profile, languages, android.request(profile))); if (json) spec.commandLine().getOut().println(Json.MAPPER.writerWithDefaultPrettyPrinter() .writeValueAsString(status)); else status.targets().forEach(target -> spec.commandLine().getOut().println( @@ -254,6 +331,29 @@ private static SetupSelection ocrSelection(SetupPlan plan, List supplied return selected; } + private static SetupSelection selectionFromPlan(SetupPlan plan, List languages, + AndroidOptions android) { + if (plan.profile() != SetupProfile.OCR && !languages.isEmpty()) { + throw new IllegalArgumentException("--language is supported only for profile OCR."); + } + return switch (plan.profile()) { + case OCR -> ocrSelection(plan, languages); + case MOBILE_ANDROID -> android.selectionFromPlan(plan); + default -> { + android.rejectIfSupplied(plan.profile()); + yield SetupSelection.defaults(); + } + }; + } + + private static SetupSelection selection(SetupProfile profile, List languages, + AndroidSetupRequest androidRequest) { + if (profile == SetupProfile.OCR) return new SetupSelection(languages); + if (!languages.isEmpty()) throw new IllegalArgumentException("--language is supported only for profile OCR."); + return profile == SetupProfile.MOBILE_ANDROID + ? androidRequest.toSelection() : SetupSelection.defaults(); + } + static class UnsupportedLifecycle implements Callable { @Option(names = "--profile", required = true) private SetupProfile profile; @Spec private CommandSpec spec; @@ -310,6 +410,62 @@ SetupOptions options(SetupProfile profile, SetupMode mode, ShaftCachePaths paths } } + static final class AndroidOptions { + @Option(names = "--api-level") private Integer apiLevel; + @Option(names = "--device-profile") private String deviceProfile; + @Option(names = "--image-tag") private String imageTag; + @Option(names = "--abi") private String abi; + @Option(names = "--avd-name") private String avdName; + @Option(names = "--ram-mb") private Integer ramMb; + @Option(names = "--cores") private Integer cores; + @Option(names = "--port") private Integer port; + + AndroidSetupRequest request(SetupProfile profile) { + if (profile != SetupProfile.MOBILE_ANDROID) { + rejectIfSupplied(profile); + return AndroidSetupRequest.defaults(); + } + return apply(AndroidSetupRequest.defaults()); + } + + SetupSelection selectionFromPlan(SetupPlan plan) { + AndroidSetupRequest reviewed = AndroidSetupRequest.fromPlan(plan); + if (hasAny()) { + AndroidSetupRequest supplied = apply(reviewed); + if (!supplied.equals(reviewed)) { + throw new IllegalArgumentException("Android selectors do not match the reviewed plan."); + } + } + return reviewed.toSelection(); + } + + void rejectIfSupplied(SetupProfile profile) { + if (hasAny()) throw new IllegalArgumentException( + "Android selectors are supported only for profile MOBILE_ANDROID, not " + profile + '.'); + } + + private AndroidSetupRequest apply(AndroidSetupRequest base) { + return new AndroidSetupRequest(selected(apiLevel, base.apiLevel()), + selected(deviceProfile, base.deviceProfile()), selected(imageTag, base.imageTag()), + selected(abi, base.abi()), selected(avdName, base.avdName()), + selected(ramMb, base.ramMb()), selected(cores, base.cores()), + selected(port, base.appiumPort())); + } + + private static int selected(Integer supplied, int fallback) { + return supplied == null ? fallback : supplied; + } + + private static String selected(String supplied, String fallback) { + return supplied == null ? fallback : supplied; + } + + private boolean hasAny() { + return apiLevel != null || deviceProfile != null || imageTag != null || abi != null || avdName != null + || ramMb != null || cores != null || port != null; + } + } + private static ReportingSetupService service(RootOptions roots) { return new ReportingSetupService(roots.paths(), SetupPlatform.current(), SetupArchitecture.current()); } @@ -318,4 +474,17 @@ private static int unsupported(CommandSpec spec, SetupProfile profile) { spec.commandLine().getErr().println("No lifecycle provider is available for profile " + profile + '.'); return 4; } + + static String failureDetails(Throwable failure) { + StringBuilder details = new StringBuilder(); + for (Throwable current = failure; current != null; current = current.getCause()) { + String message = current.getMessage(); + if (message != null && !message.isBlank() + && (details.isEmpty() || !details.toString().endsWith(message))) { + if (!details.isEmpty()) details.append(": "); + details.append(message); + } + } + return details.isEmpty() ? failure.getClass().getSimpleName() : details.toString(); + } } diff --git a/shaft-cli/src/main/resources/properties/default/custom.properties b/shaft-cli/src/main/resources/properties/default/custom.properties index bd7621b38a5..7acaeacfec5 100644 --- a/shaft-cli/src/main/resources/properties/default/custom.properties +++ b/shaft-cli/src/main/resources/properties/default/custom.properties @@ -56,3 +56,14 @@ capture.api.storeSecretsLocally=false capture.api.maxTransactions=500 capture.api.urlIncludeGlobs= capture.api.urlExcludeGlobs= + +# Optional batteries-included infrastructure (safe default: diagnose only) +infrastructure.mode=EXTERNAL +infrastructure.profile=REPORTING +infrastructure.cacheDirectory= +infrastructure.offline=false +infrastructure.autoStart=false +infrastructure.preferSystemTools=true +infrastructure.reuseOwnedProcesses=true +infrastructure.startupTimeout=PT2M +infrastructure.shutdownTimeout=PT30S diff --git a/shaft-cli/src/test/java/com/shaft/commandline/command/SetupCommandTest.java b/shaft-cli/src/test/java/com/shaft/commandline/command/SetupCommandTest.java index 91a255be214..390776f67b7 100644 --- a/shaft-cli/src/test/java/com/shaft/commandline/command/SetupCommandTest.java +++ b/shaft-cli/src/test/java/com/shaft/commandline/command/SetupCommandTest.java @@ -16,6 +16,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class SetupCommandTest { + @Test + void failureDetailsPreserveOuterActionAndNestedCause() { + RuntimeException failure = new RuntimeException("Setup action failed", + new IllegalStateException("Appium manifest is invalid", new java.io.IOException("lock mismatch"))); + + assertEquals("Setup action failed: Appium manifest is invalid: lock mismatch", + SetupCommand.failureDetails(failure)); + } + private static final JsonMapper JSON = JsonMapper.builder().build(); @Test @@ -139,6 +148,72 @@ void ocrProfileHasProviderBackedStatusAndPlan(@TempDir Path temp) throws Excepti installWithoutRepeatedLanguages.stderr()); } + @Test + void androidRequestIsBoundIntoPlanAndRecoveredByInstallWithoutRepeatedSelectors(@TempDir Path temp) + throws Exception { + Path cache = temp.resolve("cache").toAbsolutePath(); + Path data = temp.resolve("data").toAbsolutePath(); + Path planFile = temp.resolve("android-plan.json").toAbsolutePath(); + + CommandResult planned = execute("setup", "plan", "--profile", "MOBILE_ANDROID", "--mode", "MANAGED", + "--output", planFile.toString(), "--cache-root", cache.toString(), "--data-root", data.toString(), + "--offline", "--avd-name", "cli_avd", "--ram-mb", "6144", "--cores", "4", + "--port", "4823", "--json"); + + assertEquals(0, planned.exitCode(), planned.stderr()); + JsonNode plan = JSON.readTree(planned.stdout()); + assertEquals("MOBILE_ANDROID", plan.get("profile").asText()); + assertEquals(6, plan.get("actions").size()); + String request = plan.get("actions").get(5).get("version").asText(); + assertTrue(request.contains("avd=cli_avd")); + assertTrue(request.contains("ramMb=6144")); + assertTrue(request.contains("cores=4")); + assertTrue(request.contains("port=4823")); + assertTrue(plan.get("actions").get(4).get("requiredLicenses").toString() + .contains("android-sdk-license")); + + CommandResult install = execute("setup", "install", "--plan", planFile.toString(), + "--approve", plan.get("digest").asText(), "--accept-license", "android-sdk-license", + "--cache-root", cache.toString(), "--data-root", data.toString(), "--offline"); + assertEquals(5, install.exitCode(), install.stderr()); + assertTrue(install.stderr().contains("complete verified installation"), install.stderr()); + assertTrue(Files.notExists(cache)); + assertTrue(Files.notExists(data)); + + CommandResult start = execute("setup", "start", "--plan", planFile.toString(), + "--approve", plan.get("digest").asText(), "--accept-license", "android-sdk-license", + "--cache-root", cache.toString(), "--data-root", data.toString(), "--offline"); + assertEquals(5, start.exitCode(), start.stderr()); + assertTrue(start.stderr().contains("install receipt"), start.stderr()); + assertTrue(Files.notExists(cache)); + assertTrue(Files.notExists(data)); + } + + @Test + void androidStopAndLogsReportMissingWithoutCreatingRuntimeState(@TempDir Path temp) { + Path cache = temp.resolve("cache").toAbsolutePath(); + Path data = temp.resolve("data").toAbsolutePath(); + CommandResult stop = execute("setup", "stop", "--profile", "MOBILE_ANDROID", + "--cache-root", cache.toString(), "--data-root", data.toString()); + CommandResult logs = execute("setup", "logs", "--profile", "MOBILE_ANDROID", + "--cache-root", cache.toString(), "--data-root", data.toString()); + + assertEquals(3, stop.exitCode(), stop.stderr()); + assertEquals(3, logs.exitCode(), logs.stderr()); + assertTrue(Files.notExists(cache)); + assertTrue(Files.notExists(data)); + } + + @Test + void androidSelectorsAreRejectedForUnrelatedProfiles(@TempDir Path temp) { + CommandResult result = execute("setup", "status", "--profile", "REPORTING", "--port", "4823", + "--cache-root", temp.resolve("cache").toAbsolutePath().toString(), + "--data-root", temp.resolve("data").toAbsolutePath().toString()); + + assertEquals(2, result.exitCode()); + assertTrue(result.stderr().contains("only for profile MOBILE_ANDROID"), result.stderr()); + } + @Test void setupHelpExposesOnlyTheApprovedVersionOneTree() { CommandResult result = execute("setup", "--help"); diff --git a/shaft-engine/src/main/java/com/shaft/driver/SHAFT.java b/shaft-engine/src/main/java/com/shaft/driver/SHAFT.java index d0c60f6cf41..8b0ef9935a1 100644 --- a/shaft-engine/src/main/java/com/shaft/driver/SHAFT.java +++ b/shaft-engine/src/main/java/com/shaft/driver/SHAFT.java @@ -1784,6 +1784,10 @@ public static com.shaft.infrastructure.SetupReport status(com.shaft.infrastructu com.shaft.infrastructure.SetupSelection selection) { return service().status(options, selection); } + public static com.shaft.infrastructure.SetupReport status(com.shaft.infrastructure.SetupOptions options, + com.shaft.infrastructure.AndroidSetupRequest request) { + return service().status(options, request); + } public static com.shaft.infrastructure.SetupPlan plan() { return plan(options()); } public static com.shaft.infrastructure.SetupPlan plan(com.shaft.infrastructure.SetupOptions options) { @@ -1793,6 +1797,10 @@ public static com.shaft.infrastructure.SetupPlan plan(com.shaft.infrastructure.S com.shaft.infrastructure.SetupSelection selection) { return service().plan(options, selection); } + public static com.shaft.infrastructure.SetupPlan plan(com.shaft.infrastructure.SetupOptions options, + com.shaft.infrastructure.AndroidSetupRequest request) { + return service().plan(options, request); + } public static com.shaft.infrastructure.SetupReport verify() { return verify(options()); } public static com.shaft.infrastructure.SetupReport verify(com.shaft.infrastructure.SetupOptions options) { @@ -1802,6 +1810,10 @@ public static com.shaft.infrastructure.SetupReport verify(com.shaft.infrastructu com.shaft.infrastructure.SetupSelection selection) { return service().verify(options, selection); } + public static com.shaft.infrastructure.SetupReport verify(com.shaft.infrastructure.SetupOptions options, + com.shaft.infrastructure.AndroidSetupRequest request) { + return service().verify(options, request); + } public static com.shaft.infrastructure.SetupReceipt install( com.shaft.infrastructure.SetupPlan plan, com.shaft.infrastructure.SetupApproval approval) @@ -1820,6 +1832,12 @@ public static com.shaft.infrastructure.SetupReceipt install( com.shaft.infrastructure.SetupSelection selection) throws java.io.IOException { return service().install(plan, approval, options, selection); } + public static com.shaft.infrastructure.SetupReceipt install( + com.shaft.infrastructure.SetupPlan plan, com.shaft.infrastructure.SetupApproval approval, + com.shaft.infrastructure.SetupOptions options, + com.shaft.infrastructure.AndroidSetupRequest request) throws java.io.IOException { + return service().install(plan, approval, options, request); + } public static com.shaft.infrastructure.ManagedEnvironment start( com.shaft.infrastructure.SetupPlan plan, com.shaft.infrastructure.SetupApproval approval) @@ -1832,6 +1850,12 @@ public static com.shaft.infrastructure.ManagedEnvironment start( com.shaft.infrastructure.SetupOptions options) throws java.io.IOException { return service().start(plan, approval, options); } + public static com.shaft.infrastructure.ManagedEnvironment start( + com.shaft.infrastructure.SetupPlan plan, com.shaft.infrastructure.SetupApproval approval, + com.shaft.infrastructure.SetupOptions options, + com.shaft.infrastructure.AndroidSetupRequest request) throws java.io.IOException { + return service().start(plan, approval, options, request); + } public static com.shaft.infrastructure.SetupOptions options() { com.shaft.properties.internal.Infrastructure configured = Properties.infrastructure; diff --git a/shaft-engine/src/main/java/com/shaft/driver/internal/DriverFactory/DriverFactoryHelper.java b/shaft-engine/src/main/java/com/shaft/driver/internal/DriverFactory/DriverFactoryHelper.java index 40d52bb5720..9423c8107bf 100644 --- a/shaft-engine/src/main/java/com/shaft/driver/internal/DriverFactory/DriverFactoryHelper.java +++ b/shaft-engine/src/main/java/com/shaft/driver/internal/DriverFactory/DriverFactoryHelper.java @@ -135,6 +135,7 @@ public class DriverFactoryHelper { private WebDriver driver; private BrowserNetworkInterceptor browserNetworkInterceptor; private RemoteGridPreflight.SessionPermit remoteGridPreflightPermit = RemoteGridPreflight.SessionPermit.noop(); + private ManagedAndroidBootstrap.Session managedAndroidSession; /** * Creates a helper instance without an attached WebDriver. @@ -142,6 +143,10 @@ public class DriverFactoryHelper { public DriverFactoryHelper() { } + DriverFactoryHelper(ManagedAndroidBootstrap.Session managedAndroidSession) { + this.managedAndroidSession = managedAndroidSession; + } + /** * Creates a helper instance attached to an existing WebDriver session. * @@ -924,11 +929,13 @@ public void closeDriver(WebDriver driver) { BrowserEmulationManager.clearAndRemove(driver); FailureTraceReporter.clearPersistentSensitiveBrowserState(driver); releaseRemoteGridPreflightPermit(); + releaseManagedAndroidSession(); clearThreadLocalDriverState(); ReportManager.log("Closed the WebDriver session."); } } else { releaseRemoteGridPreflightPermit(); + releaseManagedAndroidSession(); clearThreadLocalDriverState(); ReportManager.log("WebDriver session was already closed."); } @@ -1465,6 +1472,7 @@ public void initializeDriver(@NonNull DriverType driverType, MutableCapabilities try { var isMobileExecution = Platform.ANDROID.toString().equalsIgnoreCase(SHAFT.Properties.platform.targetPlatform()) || Platform.IOS.toString().equalsIgnoreCase(SHAFT.Properties.platform.targetPlatform()); if (isMobileExecution) { + startManagedAndroidIfConfigured(); //mobile execution if (isMobileWebExecution()) { // org.openqa.selenium.InvalidArgumentException: Parameters were incorrect. We wanted {"required":["x","y","width","height"]} and you sent ["width","height"] @@ -1481,6 +1489,7 @@ public void initializeDriver(@NonNull DriverType driverType, MutableCapabilities driverType = DriverType.APPIUM_MOBILE_NATIVE; } optionsManager.setDriverOptions(driverType, customDriverOptions); + applyManagedAndroidCapabilities(); createNewRemoteDriverInstance(driverType); } else { //desktop execution @@ -1510,7 +1519,15 @@ public void initializeDriver(@NonNull DriverType driverType, MutableCapabilities // start session recording RecordManager.startVideoRecording(driver); } catch (NullPointerException e) { + releaseManagedAndroidSession(); FailureReporter.fail(DriverFactoryHelper.class, "Unhandled exception with driver type \"" + JavaHelper.convertToSentenceCase(driverType.getValue()) + "\".", e); + } catch (RuntimeException e) { + releaseManagedAndroidSession(); + throw e; + } finally { + if (managedAndroidSession != null && driver == null) { + releaseManagedAndroidSession(); + } } startBrowserObservability(); @@ -1521,6 +1538,40 @@ public void initializeDriver(@NonNull DriverType driverType, MutableCapabilities } } + private void startManagedAndroidIfConfigured() { + try { + managedAndroidSession = ManagedAndroidBootstrap.startIfConfigured( + SHAFT.Properties.platform.executionAddress(), SHAFT.Properties.platform.targetPlatform(), + SHAFT.Infrastructure.options(), com.shaft.infrastructure.AndroidSetupRequest.defaults(), + ManagedAndroidBootstrap.builtInGateway()).orElse(null); + if (managedAndroidSession != null) { + setTargetHubUrl(managedAndroidSession.endpoint().toString()); + } + } catch (java.io.IOException failure) { + throw new IllegalStateException("Managed Android auto-start requires a compatible installed receipt. " + + "Run the explicit infrastructure plan/install flow first.", failure); + } + } + + private void applyManagedAndroidCapabilities() { + if (managedAndroidSession == null || optionsManager.getAppiumCapabilities() == null) return; + String serial = managedAndroidSession.connectionProperties().getOrDefault("ANDROID_SERIAL", ""); + if (!serial.isBlank() && optionsManager.getAppiumCapabilities().getCapability("appium:udid") == null) { + optionsManager.getAppiumCapabilities().setCapability("appium:udid", serial); + } + } + + private void releaseManagedAndroidSession() { + if (managedAndroidSession == null) return; + try { + managedAndroidSession.close(); + } catch (RuntimeException failure) { + ReportManagerHelper.logDiscrete(failure); + } finally { + managedAndroidSession = null; + } + } + /** * Attaches the helper to an already initialized native WebDriver session. * diff --git a/shaft-engine/src/main/java/com/shaft/driver/internal/DriverFactory/ManagedAndroidBootstrap.java b/shaft-engine/src/main/java/com/shaft/driver/internal/DriverFactory/ManagedAndroidBootstrap.java new file mode 100644 index 00000000000..c8a3106eae6 --- /dev/null +++ b/shaft-engine/src/main/java/com/shaft/driver/internal/DriverFactory/ManagedAndroidBootstrap.java @@ -0,0 +1,83 @@ +package com.shaft.driver.internal.DriverFactory; + +import com.shaft.infrastructure.AndroidSetupRequest; +import com.shaft.infrastructure.InfrastructureSetupService; +import com.shaft.infrastructure.ManagedEnvironment; +import com.shaft.infrastructure.SetupApproval; +import com.shaft.infrastructure.SetupMode; +import com.shaft.infrastructure.SetupOptions; +import com.shaft.infrastructure.SetupPlan; +import com.shaft.infrastructure.SetupProfile; + +import java.io.IOException; +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** Receipt-bound local Android bootstrap policy used immediately before Appium driver creation. */ +final class ManagedAndroidBootstrap { + private ManagedAndroidBootstrap() { } + + static Optional startIfConfigured(String executionAddress, String targetPlatform, SetupOptions options, + AndroidSetupRequest request, Gateway gateway) throws IOException { + if (isExplicitRemote(executionAddress) || !"Android".equalsIgnoreCase(text(targetPlatform))) { + return Optional.empty(); + } + if (options.profile() != SetupProfile.MOBILE_ANDROID || !options.autoStart() + || options.effectiveMode() != SetupMode.MANAGED) { + return Optional.empty(); + } + SetupPlan plan = gateway.plan(options, request); + Set licenses = plan.actions().stream().flatMap(action -> action.requiredLicenses().stream()) + .collect(Collectors.toUnmodifiableSet()); + ManagedEnvironment environment = gateway.start(plan, + new SetupApproval(plan.digest(), Instant.now(), licenses), options, request); + URI endpoint = environment.endpoint().orElseThrow(() -> { + environment.close(); + return new IllegalStateException("Managed Android runtime did not publish an Appium endpoint."); + }); + return Optional.of(new Session(endpoint, environment.connectionProperties(), environment)); + } + + static Gateway builtInGateway() { + InfrastructureSetupService service = InfrastructureSetupService.builtIn(); + return new Gateway() { + @Override + public SetupPlan plan(SetupOptions options, AndroidSetupRequest request) { + return service.plan(options, request); + } + + @Override + public ManagedEnvironment start(SetupPlan plan, SetupApproval approval, SetupOptions options, + AndroidSetupRequest request) throws IOException { + return service.start(plan, approval, options, request); + } + }; + } + + interface Gateway { + SetupPlan plan(SetupOptions options, AndroidSetupRequest request); + ManagedEnvironment start(SetupPlan plan, SetupApproval approval, SetupOptions options, + AndroidSetupRequest request) throws IOException; + } + + record Session(URI endpoint, Map connectionProperties, ManagedEnvironment environment) + implements AutoCloseable { + Session { + connectionProperties = Map.copyOf(connectionProperties); + } + + @Override public void close() { environment.close(); } + } + + private static boolean isExplicitRemote(String value) { + String normalized = text(value); + return !normalized.isBlank() && !normalized.equalsIgnoreCase("local") + && !normalized.equalsIgnoreCase("dockerized"); + } + + private static String text(String value) { return value == null ? "" : value.trim(); } +} diff --git a/shaft-engine/src/test/java/com/shaft/driver/internal/DriverFactory/ManagedAndroidBootstrapTest.java b/shaft-engine/src/test/java/com/shaft/driver/internal/DriverFactory/ManagedAndroidBootstrapTest.java new file mode 100644 index 00000000000..42b427fa735 --- /dev/null +++ b/shaft-engine/src/test/java/com/shaft/driver/internal/DriverFactory/ManagedAndroidBootstrapTest.java @@ -0,0 +1,121 @@ +package com.shaft.driver.internal.DriverFactory; + +import com.shaft.infrastructure.AndroidSetupRequest; +import com.shaft.infrastructure.ManagedEnvironment; +import com.shaft.infrastructure.SetupApproval; +import com.shaft.infrastructure.SetupArchitecture; +import com.shaft.infrastructure.SetupMode; +import com.shaft.infrastructure.SetupOptions; +import com.shaft.infrastructure.SetupPlan; +import com.shaft.infrastructure.SetupPlatform; +import com.shaft.infrastructure.SetupProfile; +import com.shaft.infrastructure.SetupReceipt; +import com.shaft.infrastructure.ShaftCachePaths; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicBoolean; + +public class ManagedAndroidBootstrapTest { + @Test + public void explicitRemoteWinsWithoutConsultingManagedInfrastructure() throws Exception { + var calls = new AtomicInteger(); + var gateway = new StubGateway(calls, null, null); + var options = managedOptions().withRemoteEndpoint(URI.create("https://grid.example/appium")); + + Optional result = ManagedAndroidBootstrap.startIfConfigured( + "https://grid.example/appium", "Android", options, request(), gateway); + + Assert.assertTrue(result.isEmpty()); + Assert.assertEquals(calls.get(), 0); + } + + @Test + public void localAutoStartUsesReceiptBoundStartAndKeepsConnectionMetadataScoped() throws Exception { + var calls = new AtomicInteger(); + SetupPlan plan = com.shaft.infrastructure.AndroidSetupPlanner.plan(SetupPlatform.WINDOWS, + SetupArchitecture.X64, SetupMode.MANAGED, request()); + ManagedEnvironment environment = new ManagedEnvironment(SetupProfile.MOBILE_ANDROID, + new SetupReceipt(plan.digest(), Instant.EPOCH, plan.actions()), + Optional.of(URI.create("http://127.0.0.1:4723/")), + Map.of("ANDROID_SERIAL", "emulator-5554"), () -> { }); + var gateway = new StubGateway(calls, plan, environment); + String originalSerial = System.getProperty("ANDROID_SERIAL"); + + ManagedAndroidBootstrap.Session session = ManagedAndroidBootstrap.startIfConfigured( + "local", "Android", managedOptions(), request(), gateway).orElseThrow(); + + Assert.assertEquals(calls.get(), 2); + Assert.assertEquals(session.endpoint(), URI.create("http://127.0.0.1:4723/")); + Assert.assertEquals(session.connectionProperties().get("ANDROID_SERIAL"), "emulator-5554"); + Assert.assertEquals(System.getProperty("ANDROID_SERIAL"), originalSerial); + session.close(); + } + + @Test(expectedExceptions = IOException.class, + expectedExceptionsMessageRegExp = ".*compatible install receipt.*") + public void missingReceiptFailureIsNotConvertedIntoHiddenInstallation() throws Exception { + SetupPlan plan = com.shaft.infrastructure.AndroidSetupPlanner.plan(SetupPlatform.WINDOWS, + SetupArchitecture.X64, SetupMode.MANAGED, request()); + ManagedAndroidBootstrap.startIfConfigured("local", "Android", managedOptions(), request(), + new ManagedAndroidBootstrap.Gateway() { + @Override public SetupPlan plan(SetupOptions options, AndroidSetupRequest request) { return plan; } + @Override public ManagedEnvironment start(SetupPlan ignored, SetupApproval approval, + SetupOptions options, AndroidSetupRequest request) throws IOException { + throw new IOException("A compatible install receipt is required."); + } + }); + } + + @Test + public void closingHelperWithoutALiveDriverStillReleasesManagedRuntimeLease() throws Exception { + AtomicBoolean released = new AtomicBoolean(); + SetupPlan plan = com.shaft.infrastructure.AndroidSetupPlanner.plan(SetupPlatform.WINDOWS, + SetupArchitecture.X64, SetupMode.MANAGED, request()); + ManagedEnvironment environment = new ManagedEnvironment(SetupProfile.MOBILE_ANDROID, + new SetupReceipt(plan.digest(), Instant.EPOCH, plan.actions()), + Optional.of(URI.create("http://127.0.0.1:4723/")), Map.of(), () -> released.set(true)); + ManagedAndroidBootstrap.Session session = new ManagedAndroidBootstrap.Session( + URI.create("http://127.0.0.1:4723/"), Map.of(), environment); + DriverFactoryHelper helper = new DriverFactoryHelper(session); + + helper.closeDriver(); + + Assert.assertTrue(released.get()); + } + + private SetupOptions managedOptions() throws IOException { + var root = Files.createTempDirectory("managed-android-bootstrap-").toAbsolutePath(); + var paths = new ShaftCachePaths(root.resolve("cache"), root.resolve("data"), + root.resolve("cache/downloads"), root.resolve("data/tools"), + root.resolve("data/state"), root.resolve("data/receipts")); + return SetupOptions.defaults(SetupProfile.MOBILE_ANDROID, paths) + .withMode(SetupMode.MANAGED).withAutoStart(true); + } + + private static AndroidSetupRequest request() { + return new AndroidSetupRequest(36, "pixel_8", "google_apis", "x86_64", + "shaft_pixel_8_api_36_x86_64", 4096, 2, 4723); + } + + private record StubGateway(AtomicInteger calls, SetupPlan plan, ManagedEnvironment environment) + implements ManagedAndroidBootstrap.Gateway { + @Override public SetupPlan plan(SetupOptions options, AndroidSetupRequest request) { + calls.incrementAndGet(); + return plan; + } + + @Override public ManagedEnvironment start(SetupPlan plan, SetupApproval approval, + SetupOptions options, AndroidSetupRequest request) { + calls.incrementAndGet(); + return environment; + } + } +} diff --git a/shaft-engine/src/test/java/testPackage/ManagedAndroidE2ETest.java b/shaft-engine/src/test/java/testPackage/ManagedAndroidE2ETest.java new file mode 100644 index 00000000000..4cf0965e689 --- /dev/null +++ b/shaft-engine/src/test/java/testPackage/ManagedAndroidE2ETest.java @@ -0,0 +1,61 @@ +package testPackage; + +import com.shaft.driver.SHAFT; +import com.shaft.infrastructure.AndroidSetupPlanner; +import com.shaft.infrastructure.AndroidSetupRequest; +import com.shaft.infrastructure.SetupApproval; +import io.appium.java_client.android.AndroidDriver; +import io.appium.java_client.android.options.UiAutomator2Options; +import org.testng.Assert; +import org.testng.SkipException; +import org.testng.annotations.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Set; + +/** Opt-in real acceptance for the release-pinned owned emulator, Appium, UiAutomator2, and aapt2. */ +public class ManagedAndroidE2ETest { + @Test + public void startsReceiptBoundAndroidSessionAndProbesAapt2() throws Exception { + if (!Boolean.getBoolean("runManagedAndroidE2E")) { + throw new SkipException("Set -DrunManagedAndroidE2E=true to run the real managed Android acceptance."); + } + var options = SHAFT.Infrastructure.options(); + var request = AndroidSetupRequest.defaults(); + var plan = SHAFT.Infrastructure.plan(options, request); + var approval = new SetupApproval(plan.digest(), Instant.now(), + Set.of(AndroidSetupPlanner.ANDROID_SDK_LICENSE)); + + try (var environment = SHAFT.Infrastructure.start(plan, approval, options, request)) { + probeAapt2(environment.connectionProperties()); + String serial = environment.connectionProperties().get("ANDROID_SERIAL"); + UiAutomator2Options capabilities = new UiAutomator2Options() + .setUdid(serial) + .setDeviceName(request.avdName()) + .setAppPackage("com.android.settings") + .setAppActivity(".Settings"); + AndroidDriver driver = new AndroidDriver(environment.endpoint().orElseThrow().toURL(), capabilities); + try { + Assert.assertNotNull(driver.getSessionId()); + Assert.assertFalse(driver.getPageSource().isBlank()); + } finally { + driver.quit(); + } + } + } + + private static void probeAapt2(java.util.Map connectionProperties) throws Exception { + Path sdk = Path.of(connectionProperties.get("ANDROID_SDK_ROOT")); + String executable = System.getProperty("os.name", "").toLowerCase().contains("win") + ? "aapt2.exe" : "aapt2"; + ProcessBuilder builder = new ProcessBuilder(sdk.resolve("build-tools") + .resolve(AndroidSetupPlanner.BUILD_TOOLS_VERSION).resolve(executable).toString(), "version"); + builder.environment().putAll(connectionProperties); + Process process = builder.redirectErrorStream(true).start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + Assert.assertEquals(process.waitFor(), 0, output); + Assert.assertTrue(output.trim().startsWith("Android Asset Packaging Tool (aapt) 2."), output); + } +} diff --git a/shaft-engine/src/test/java/testPackage/properties/InfrastructurePropertiesTests.java b/shaft-engine/src/test/java/testPackage/properties/InfrastructurePropertiesTests.java index 36446e8455d..a9fca8e6bd5 100644 --- a/shaft-engine/src/test/java/testPackage/properties/InfrastructurePropertiesTests.java +++ b/shaft-engine/src/test/java/testPackage/properties/InfrastructurePropertiesTests.java @@ -2,6 +2,7 @@ import com.shaft.driver.SHAFT; import com.shaft.infrastructure.SetupApproval; +import com.shaft.infrastructure.AndroidSetupRequest; import com.shaft.infrastructure.SetupMode; import com.shaft.infrastructure.SetupPlan; import com.shaft.infrastructure.SetupProfile; @@ -148,4 +149,21 @@ public void ocrSelectionIsAvailableThroughTheShaftFacade() throws Exception { Assert.assertEquals(SHAFT.Infrastructure.status(options, selection).profile(), SetupProfile.OCR); Assert.assertEquals(SHAFT.Infrastructure.verify(options, selection).profile(), SetupProfile.OCR); } + + @Test + public void typedAndroidRequestIsAvailableThroughTheShaftFacade() throws Exception { + Path root = Files.createTempDirectory("shaft-infrastructure-android-").toAbsolutePath(); + SHAFT.Properties.infrastructure.set().mode(SetupMode.MANAGED).profile(SetupProfile.MOBILE_ANDROID) + .cacheDirectory(root.toString()); + var options = SHAFT.Infrastructure.options(); + var request = new AndroidSetupRequest(36, "pixel_8", "google_apis", "host", + "facade_avd", 6144, 4, 4823); + + SetupPlan plan = SHAFT.Infrastructure.plan(options, request); + + Assert.assertEquals(AndroidSetupRequest.fromPlan(plan).avdName(), "facade_avd"); + Assert.assertEquals(SHAFT.Infrastructure.status(options, request).profile(), SetupProfile.MOBILE_ANDROID); + Assert.assertEquals(SHAFT.Infrastructure.verify(options, request).profile(), SetupProfile.MOBILE_ANDROID); + } + } diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidCommandRunner.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidCommandRunner.java new file mode 100644 index 00000000000..bcfb2eb28e5 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidCommandRunner.java @@ -0,0 +1,25 @@ +package com.shaft.infrastructure; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Sanitized child-process seam for Appium, sdkmanager, avdmanager, adb, and Emulator. */ +@FunctionalInterface +interface AndroidCommandRunner { + ReportingSetupService.ProcessResult run(List command, Path workingDirectory, + Map environment, Set removedEnvironment, + String standardInput, Path log, Duration timeout) throws IOException; + + static AndroidCommandRunner system(ShaftCachePaths paths, SetupPlatform platform, + SetupArchitecture architecture) { + Path nodeRoot = paths.tools().resolve("node").resolve(ReportingSetupPlanner.NODE_VERSION) + .resolve(platform.name().toLowerCase() + '-' + architecture.artifactName()); + return (command, workingDirectory, environment, removedEnvironment, standardInput, log, timeout) -> + ReportingSetupService.runProcess(command, log, timeout, paths.cacheRoot(), nodeRoot, + workingDirectory, environment, removedEnvironment, standardInput); + } +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidLifecycleService.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidLifecycleService.java new file mode 100644 index 00000000000..524a9256dd0 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidLifecycleService.java @@ -0,0 +1,361 @@ +package com.shaft.infrastructure; + +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +/** Lease-safe lifecycle owner for one verified Android emulator and local Appium server. */ +final class AndroidLifecycleService { + private static final JsonMapper JSON = JsonMapper.builder().build(); + private static final ConcurrentHashMap JVM_LOCKS = new ConcurrentHashMap<>(); + private static final int EMULATOR_CONSOLE_PORT = 5554; + private static final int EMULATOR_ADB_PORT = EMULATOR_CONSOLE_PORT + 1; + + private final ShaftCachePaths paths; + private final SetupPlatform platform; + private final SetupArchitecture architecture; + private final AndroidSetupRequest request; + private final AndroidToolchainOperations operations; + private final AndroidRuntimeController runtime; + private final AndroidRuntimeHealth health; + private final AndroidRuntimeLayout layout; + + AndroidLifecycleService(ShaftCachePaths paths, SetupPlatform platform, SetupArchitecture architecture, + AndroidSetupRequest request, AndroidToolchainOperations operations, + AndroidRuntimeController runtime, AndroidRuntimeHealth health) { + this.paths = java.util.Objects.requireNonNull(paths, "paths"); + this.platform = java.util.Objects.requireNonNull(platform, "platform"); + this.architecture = java.util.Objects.requireNonNull(architecture, "architecture"); + this.request = java.util.Objects.requireNonNull(request, "request").resolve(architecture); + this.operations = java.util.Objects.requireNonNull(operations, "operations"); + this.runtime = java.util.Objects.requireNonNull(runtime, "runtime"); + this.health = java.util.Objects.requireNonNull(health, "health"); + this.layout = AndroidRuntimeLayout.resolve(paths, platform, architecture, this.request); + } + + ManagedEnvironment start(SetupPlan plan, SetupApproval approval, SetupOptions options) throws IOException { + requireCompatible(plan, options); + SetupExecutor.validate(plan, approval); + SetupReceipt receipt = requireInstallReceipt(plan); + requireInstalled(plan); + Path lockPath = paths.state().resolve("mobile-android-runtime.lock").toAbsolutePath().normalize(); + VerifiedArtifactStore.requireUnlinkedAncestors(lockPath); + ReentrantLock jvmLock = JVM_LOCKS.computeIfAbsent(lockPath, ignored -> new ReentrantLock()); + try { + jvmLock.lockInterruptibly(); + Files.createDirectories(paths.state()); + try (FileChannel channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + receipt = requireInstallReceipt(plan); + requireInstalled(plan); + Optional reusable = readReusable(plan, options); + if (reusable.isPresent()) return environment(receipt, reusable.orElseThrow(), options); + return startNew(plan, receipt, options); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for the Android runtime lock.", interrupted); + } finally { + if (jvmLock.isHeldByCurrentThread()) jvmLock.unlock(); + } + } + + Path emulatorLog() { return layout.emulatorLog(); } + Path appiumLog() { return layout.appiumLog(); } + + boolean stop(Duration timeout) throws IOException { + Path lockPath = paths.state().resolve("mobile-android-runtime.lock").toAbsolutePath().normalize(); + VerifiedArtifactStore.requireUnlinkedAncestors(lockPath); + if (Files.notExists(leasePath(), LinkOption.NOFOLLOW_LINKS)) return false; + ReentrantLock jvmLock = JVM_LOCKS.computeIfAbsent(lockPath, ignored -> new ReentrantLock()); + jvmLock.lock(); + try (FileChannel channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + Optional current = readLease(); + if (current.isEmpty()) return false; + AndroidRuntimeLease lease = current.orElseThrow(); + requireLeaseRequest(lease); + Optional emulator = find(lease.emulator()); + Optional appium = find(lease.appium()); + if (emulator.isEmpty() && appium.isEmpty()) { + Files.deleteIfExists(leasePath()); + return false; + } + if (emulator.isEmpty() || appium.isEmpty()) throw new IOException( + "Android runtime lease is partially alive; refusing to kill an uncertain process identity."); + IOException cleanup = stopStarted(appium.orElseThrow(), emulator.orElseThrow(), timeout); + if (cleanup != null) throw cleanup; + Files.deleteIfExists(leasePath()); + return true; + } finally { + jvmLock.unlock(); + } + } + + private ManagedEnvironment startNew(SetupPlan plan, SetupReceipt receipt, SetupOptions options) throws IOException { + requireAvailablePort(EMULATOR_CONSOLE_PORT, "Android emulator console"); + requireAvailablePort(EMULATOR_ADB_PORT, "Android emulator adb"); + requireAvailablePort(request.appiumPort(), "Appium"); + Map androidEnvironment = androidEnvironment(); + Set removed = Set.of("APPIUM_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT", "ANDROID_AVD_HOME", + "ANDROID_SERIAL", "REPO_OS_OVERRIDE"); + AndroidOwnedProcess emulator = null; + AndroidOwnedProcess appium = null; + try { + emulator = runtime.start("emulator", List.of(layout.emulator().toString(), "-avd", request.avdName(), + "-port", Integer.toString(EMULATOR_CONSOLE_PORT), "-no-snapshot-save", "-no-boot-anim", + "-no-audio", "-no-window", "-memory", Integer.toString(request.ramMb()), "-cores", + Integer.toString(request.cores())), layout.sdkRoot(), androidEnvironment, removed, + layout.emulatorLog()); + health.awaitEmulator(layout.serial(), layout, androidEnvironment, options.startupTimeout()); + URI endpoint = URI.create("http://127.0.0.1:" + request.appiumPort() + '/'); + Map appiumEnvironment = new LinkedHashMap<>(androidEnvironment); + appiumEnvironment.put("APPIUM_HOME", layout.appiumHome().toString()); + appium = runtime.start("appium", List.of(layout.nodeExecutable().toString(), + layout.appiumEntryPoint().toString(), "--address", "127.0.0.1", "--port", + Integer.toString(request.appiumPort()), "--base-path", "/"), layout.appiumHome(), + Map.copyOf(appiumEnvironment), removed, layout.appiumLog()); + health.awaitAppium(endpoint, options.startupTimeout()); + AndroidRuntimeLease lease = new AndroidRuntimeLease(1, plan.digest(), request.avdName(), layout.serial(), + endpoint.toString(), ProcessIdentity.of(emulator), ProcessIdentity.of(appium), 1); + writeLease(lease); + return environment(receipt, new ActiveRuntime(lease, emulator, appium), options); + } catch (IOException failure) { + suppressCleanupFailure(failure, appium, emulator, options.shutdownTimeout()); + throw failure; + } catch (RuntimeException failure) { + suppressCleanupFailure(failure, appium, emulator, options.shutdownTimeout()); + throw failure; + } + } + + private void suppressCleanupFailure(Throwable failure, AndroidOwnedProcess appium, + AndroidOwnedProcess emulator, Duration timeout) { + IOException cleanup = stopStarted(appium, emulator, timeout); + if (cleanup != null) failure.addSuppressed(cleanup); + } + + private ManagedEnvironment environment(SetupReceipt receipt, ActiveRuntime active, SetupOptions options) { + Map properties = new LinkedHashMap<>(androidEnvironment()); + properties.put("APPIUM_HOME", layout.appiumHome().toString()); + properties.put("ANDROID_SERIAL", layout.serial()); + properties.put("appium.endpoint", active.lease().endpoint()); + return new ManagedEnvironment(SetupProfile.MOBILE_ANDROID, receipt, + Optional.of(URI.create(active.lease().endpoint())), Map.copyOf(properties), () -> { + try { + release(active, options.shutdownTimeout()); + } catch (IOException failure) { + throw new IllegalStateException("Failed to release the owned Android runtime.", failure); + } + }); + } + + private Optional readReusable(SetupPlan plan, SetupOptions options) throws IOException { + Optional existing = readLease(); + if (existing.isEmpty()) return Optional.empty(); + AndroidRuntimeLease lease = existing.orElseThrow(); + if (!lease.planDigest().equals(plan.digest()) || !lease.avdName().equals(request.avdName()) + || !lease.endpoint().equals("http://127.0.0.1:" + request.appiumPort() + '/')) { + throw new IOException("An existing SHAFT Android lease does not match the reviewed plan."); + } + Optional emulator = find(lease.emulator()); + Optional appium = find(lease.appium()); + if (emulator.isEmpty() && appium.isEmpty()) { + Files.deleteIfExists(leasePath()); + return Optional.empty(); + } + if (emulator.isEmpty() || appium.isEmpty()) { + throw new IOException("The SHAFT Android runtime lease is stale or only partially alive; manual recovery " + + "is required before starting another runtime."); + } + if (!options.reuseOwnedProcesses()) { + throw new IOException("A compatible SHAFT Android runtime is already active and reuse is disabled."); + } + health.awaitEmulator(lease.serial(), layout, androidEnvironment(), options.startupTimeout()); + health.awaitAppium(URI.create(lease.endpoint()), options.startupTimeout()); + AndroidRuntimeLease incremented = lease.withRefCount(lease.refCount() + 1); + writeLease(incremented); + return Optional.of(new ActiveRuntime(incremented, emulator.orElseThrow(), appium.orElseThrow())); + } + + private void requireLeaseRequest(AndroidRuntimeLease lease) throws IOException { + if (!lease.avdName().equals(request.avdName()) + || !lease.endpoint().equals("http://127.0.0.1:" + request.appiumPort() + '/')) { + throw new IOException("Android runtime lease does not match the requested AVD and Appium endpoint."); + } + } + + private void release(ActiveRuntime active, Duration timeout) throws IOException { + Path lockPath = paths.state().resolve("mobile-android-runtime.lock").toAbsolutePath().normalize(); + ReentrantLock jvmLock = JVM_LOCKS.computeIfAbsent(lockPath, ignored -> new ReentrantLock()); + jvmLock.lock(); + try (FileChannel channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + Optional current = readLease(); + if (current.isEmpty()) return; + AndroidRuntimeLease lease = current.orElseThrow(); + if (!lease.sameIdentity(active.lease())) { + throw new IOException("Android runtime lease changed; refusing to stop an unowned process."); + } + if (lease.refCount() > 1) { + writeLease(lease.withRefCount(lease.refCount() - 1)); + return; + } + IOException cleanup = stopStarted(active.appium(), active.emulator(), timeout); + if (cleanup != null) throw cleanup; + Files.deleteIfExists(leasePath()); + } finally { + jvmLock.unlock(); + } + } + + private void requireCompatible(SetupPlan plan, SetupOptions options) { + java.util.Objects.requireNonNull(plan, "plan"); + java.util.Objects.requireNonNull(options, "options"); + if (plan.profile() != SetupProfile.MOBILE_ANDROID || options.profile() != SetupProfile.MOBILE_ANDROID) { + throw new IllegalArgumentException("Android lifecycle requires profile MOBILE_ANDROID."); + } + if (plan.platform() != platform || plan.architecture() != architecture) { + throw new IllegalArgumentException("Android lifecycle plan does not match this host."); + } + if (plan.mode() == SetupMode.EXTERNAL || options.effectiveMode() == SetupMode.EXTERNAL) { + throw new IllegalArgumentException("External Android setup cannot start local processes."); + } + SetupPlan expected = AndroidSetupPlanner.plan(platform, architecture, plan.mode(), request); + if (!expected.equals(plan)) throw new IllegalArgumentException( + "Android lifecycle plan does not match the release manifest."); + } + + private SetupReceipt requireInstallReceipt(SetupPlan plan) throws IOException { + Path receiptPath = paths.receipts().resolve("mobile-android.json"); + VerifiedArtifactStore.requireUnlinkedAncestors(receiptPath); + if (!Files.isRegularFile(receiptPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("A complete compatible Android install receipt is required before start."); + } + SetupReceipt receipt; + try { + receipt = JSON.readValue(receiptPath.toFile(), SetupReceipt.class); + } catch (RuntimeException invalid) { + throw new IOException("Android install receipt is invalid.", invalid); + } + if (!receipt.planDigest().equals(plan.digest()) || !receipt.completedActions().equals(plan.actions())) { + throw new IOException("Android install receipt does not match the reviewed plan."); + } + return receipt; + } + + private void requireInstalled(SetupPlan plan) throws IOException { + for (SetupAction action : plan.actions()) { + SetupStatus status = operations.status(action); + if (status.readiness() != SetupReadiness.READY) { + throw new IOException(action.target() + " is not ready: " + status.detail()); + } + } + } + + private Map androidEnvironment() { + String path = String.join(java.io.File.pathSeparator, layout.sdkRoot().resolve("platform-tools").toString(), + layout.sdkRoot().resolve("emulator").toString(), + Optional.ofNullable(System.getenv("PATH")).orElse("")); + return Map.of("ANDROID_HOME", layout.sdkRoot().toString(), "ANDROID_SDK_ROOT", layout.sdkRoot().toString(), + "ANDROID_AVD_HOME", layout.avdHome().toString(), "ANDROID_SERIAL", layout.serial(), "PATH", path); + } + + private void requireAvailablePort(int port, String owner) throws IOException { + try (ServerSocket socket = new ServerSocket()) { + socket.setReuseAddress(false); + socket.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port)); + } catch (IOException occupied) { + throw new IOException(owner + " loopback port " + port + " is already occupied.", occupied); + } + } + + private Optional find(ProcessIdentity identity) throws IOException { + return runtime.find(identity.pid(), Instant.ofEpochMilli(identity.startEpochMilli()), identity.commandIdentity()); + } + + private Optional readLease() throws IOException { + Path path = leasePath(); + VerifiedArtifactStore.requireUnlinkedAncestors(path); + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) return Optional.empty(); + try { + AndroidRuntimeLease lease = JSON.readValue(path.toFile(), AndroidRuntimeLease.class); + if (lease.schemaVersion() != 1 || lease.refCount() < 1) throw new IOException("Invalid Android lease."); + return Optional.of(lease); + } catch (RuntimeException invalid) { + throw new IOException("Android runtime lease is invalid.", invalid); + } + } + + private void writeLease(AndroidRuntimeLease lease) throws IOException { + Files.createDirectories(paths.state()); + Path temporary = Files.createTempFile(paths.state(), "mobile-android-runtime", ".tmp"); + try { + Files.writeString(temporary, JSON.writerWithDefaultPrettyPrinter().writeValueAsString(lease)); + VerifiedArtifactStore.move(temporary, leasePath()); + } finally { + Files.deleteIfExists(temporary); + } + } + + private Path leasePath() { return paths.state().resolve("mobile-android-runtime.json"); } + + private static IOException stopStarted(AndroidOwnedProcess appium, AndroidOwnedProcess emulator, + Duration timeout) { + IOException failure = null; + Instant deadline = Instant.now().plus(timeout); + for (AndroidOwnedProcess process : new AndroidOwnedProcess[]{appium, emulator}) { + if (process == null) continue; + try { + Duration remaining = Duration.between(Instant.now(), deadline); + process.stop(remaining.isNegative() ? Duration.ZERO : remaining); + } catch (IOException cleanup) { + if (failure == null) failure = cleanup; + else failure.addSuppressed(cleanup); + } + } + return failure; + } + + private record ActiveRuntime(AndroidRuntimeLease lease, AndroidOwnedProcess emulator, + AndroidOwnedProcess appium) { } + + private record ProcessIdentity(long pid, long startEpochMilli, String commandIdentity) { + static ProcessIdentity of(AndroidOwnedProcess process) { + return new ProcessIdentity(process.pid(), process.startInstant().toEpochMilli(), + process.commandIdentity()); + } + } + + private record AndroidRuntimeLease(int schemaVersion, String planDigest, String avdName, String serial, + String endpoint, ProcessIdentity emulator, ProcessIdentity appium, + int refCount) { + AndroidRuntimeLease withRefCount(int value) { + return new AndroidRuntimeLease(schemaVersion, planDigest, avdName, serial, endpoint, emulator, appium, + value); + } + + boolean sameIdentity(AndroidRuntimeLease other) { + return planDigest.equals(other.planDigest) && emulator.equals(other.emulator) && appium.equals(other.appium); + } + } +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidOwnedProcess.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidOwnedProcess.java new file mode 100644 index 00000000000..0c799c47796 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidOwnedProcess.java @@ -0,0 +1,13 @@ +package com.shaft.infrastructure; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; + +interface AndroidOwnedProcess { + long pid(); + Instant startInstant(); + String commandIdentity(); + boolean isAlive(); + void stop(Duration timeout) throws IOException; +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeController.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeController.java new file mode 100644 index 00000000000..fb0684d9b45 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeController.java @@ -0,0 +1,17 @@ +package com.shaft.infrastructure; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +interface AndroidRuntimeController { + AndroidOwnedProcess start(String role, List command, Path workingDirectory, + Map environment, Set removedEnvironment, + Path log) throws IOException; + + Optional find(long pid, Instant startInstant, String commandIdentity) throws IOException; +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeHealth.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeHealth.java new file mode 100644 index 00000000000..2a1949d5310 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeHealth.java @@ -0,0 +1,12 @@ +package com.shaft.infrastructure; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Map; + +interface AndroidRuntimeHealth { + void awaitEmulator(String serial, AndroidRuntimeLayout layout, Map environment, + Duration timeout) throws IOException; + void awaitAppium(URI endpoint, Duration timeout) throws IOException; +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeLayout.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeLayout.java new file mode 100644 index 00000000000..2a8ab6e1ace --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeLayout.java @@ -0,0 +1,31 @@ +package com.shaft.infrastructure; + +import java.nio.file.Path; + +/** Exact SHAFT-owned paths used by the managed Android runtime. */ +record AndroidRuntimeLayout(Path nodeExecutable, Path appiumEntryPoint, Path appiumHome, + Path sdkRoot, Path adb, Path emulator, Path avdHome, Path avdRoot, + Path emulatorLog, Path appiumLog, String avdName, String serial) { + static AndroidRuntimeLayout resolve(ShaftCachePaths paths, SetupPlatform platform, + SetupArchitecture architecture, AndroidSetupRequest request) { + String platformKey = platform.name().toLowerCase() + '-' + architecture.artifactName(); + Path nodeRoot = paths.tools().resolve("node").resolve(ReportingSetupPlanner.NODE_VERSION) + .resolve(platformKey); + Path node = platform == SetupPlatform.WINDOWS ? nodeRoot.resolve("node.exe") + : nodeRoot.resolve("bin/node"); + Path appium = paths.tools().resolve("appium").resolve(AndroidSetupPlanner.APPIUM_VERSION); + Path sdk = paths.tools().resolve("android-sdk").resolve(AndroidSetupPlanner.COMMAND_LINE_TOOLS_VERSION + + "-api" + request.apiLevel() + '-' + request.abi()); + Path avdHome = paths.tools().resolve("android-avd"); + return new AndroidRuntimeLayout(node, appium.resolve("node_modules/appium/index.js"), appium, + sdk, executable(sdk.resolve("platform-tools"), "adb", platform), + executable(sdk.resolve("emulator"), "emulator", platform), avdHome, + avdHome.resolve(request.avdName() + ".avd"), + paths.state().resolve("logs/android-emulator.log"), + paths.state().resolve("logs/appium-server.log"), request.avdName(), "emulator-5554"); + } + + private static Path executable(Path directory, String name, SetupPlatform platform) { + return directory.resolve(platform == SetupPlatform.WINDOWS ? name + ".exe" : name); + } +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeManager.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeManager.java new file mode 100644 index 00000000000..5f3154d77cd --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidRuntimeManager.java @@ -0,0 +1,43 @@ +package com.shaft.infrastructure; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.time.Duration; + +/** Explicit CLI/API access to lease-safe Android runtime stop and bounded logs. */ +public final class AndroidRuntimeManager { + private static final long MAX_LOG_BYTES = 2L * 1024 * 1024; + + private AndroidRuntimeManager() { } + + public static boolean stop(ShaftCachePaths paths, SetupPlatform platform, SetupArchitecture architecture, + AndroidSetupRequest request, Duration timeout) throws IOException { + return service(paths, platform, architecture, request).stop(timeout); + } + + public static String logs(ShaftCachePaths paths, SetupPlatform platform, SetupArchitecture architecture, + AndroidSetupRequest request) throws IOException { + AndroidLifecycleService service = service(paths, platform, architecture, request); + return read("emulator", service.emulatorLog()) + read("appium", service.appiumLog()); + } + + private static AndroidLifecycleService service(ShaftCachePaths paths, SetupPlatform platform, + SetupArchitecture architecture, AndroidSetupRequest request) { + AndroidToolchainOperations operations = new DefaultAndroidToolchainOperations(paths, platform, + architecture, request, true); + return new AndroidLifecycleService(paths, platform, architecture, request, operations, + new SystemAndroidRuntimeController(), new SystemAndroidRuntimeHealth(paths, platform, architecture)); + } + + private static String read(String role, Path path) throws IOException { + VerifiedArtifactStore.requireUnlinkedAncestors(path); + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) return ""; + long size = Files.size(path); + if (size > MAX_LOG_BYTES) throw new IOException(role + " log exceeds the 2 MiB safety limit: " + path); + return "== " + role + " ==" + System.lineSeparator() + + Files.readString(path, StandardCharsets.UTF_8) + System.lineSeparator(); + } +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupPlanner.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupPlanner.java new file mode 100644 index 00000000000..323ac6d6197 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupPlanner.java @@ -0,0 +1,130 @@ +package com.shaft.infrastructure; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +/** Release-coupled planner for the managed Appium and Android emulator toolchain. */ +public final class AndroidSetupPlanner { + public static final String APPIUM_VERSION = "3.6.0"; + public static final String INSPECTOR_PLUGIN_VERSION = "2026.7.1"; + public static final String UIAUTOMATOR2_VERSION = "8.2.2"; + public static final String APPIUM_LOCK_SHA256 = + "4da109a812861e2a1fc26792e04c3128242e5b17e4c2d97e26a20e91ef8fbe98"; + public static final String COMMAND_LINE_TOOLS_VERSION = "15859902"; + public static final String PLATFORM_TOOLS_VERSION = "37.0.1"; + public static final String EMULATOR_VERSION = "37.1.11"; + public static final String ANDROID_PLATFORM_REVISION = "2"; + public static final String BUILD_TOOLS_VERSION = "36.0.0"; + public static final String SYSTEM_IMAGE_REVISION = "7"; + public static final int API_LEVEL = 36; + public static final String DEVICE_PROFILE = "pixel_8"; + public static final String IMAGE_TAG = "google_apis"; + public static final int RAM_MB = 4096; + public static final int CORES = 2; + public static final int APPIUM_PORT = 4723; + public static final String ANDROID_SDK_LICENSE = "android-sdk-license"; + + private static final String APPIUM_SHA256 = + "ea722c272d117ffac7e265e6565651f3835efbcea670f82a16f4e75de120b76e"; + private static final String INSPECTOR_SHA256 = + "fcaf8d9434a9809fc0c5df16902b87b6c5920bb8f446f05e85ab77301ed9e99d"; + private static final String UIAUTOMATOR2_SHA256 = + "a53c05850eaf08372672dc425298e77a2094f6334bb2b1ac220b705255483a22"; + + private AndroidSetupPlanner() { } + + /** Creates the exact default Android plan shipped with this release. */ + public static SetupPlan plan(SetupPlatform platform, SetupArchitecture architecture, SetupMode mode) { + return plan(platform, architecture, mode, AndroidSetupRequest.defaults()); + } + + /** Creates an exact Android plan whose AVD/runtime request is digest-bound. */ + public static SetupPlan plan(SetupPlatform platform, SetupArchitecture architecture, SetupMode mode, + AndroidSetupRequest request) { + requireSupportedHost(platform, architecture); + AndroidSetupRequest resolved = request.resolve(architecture); + SetupActionKind kind = mode == SetupMode.EXTERNAL ? SetupActionKind.DIAGNOSE : SetupActionKind.INSTALL; + String lock = "sha256:" + APPIUM_LOCK_SHA256; + SetupAction node = ReportingSetupPlanner.plan(platform, architecture, mode).actions().getFirst(); + SetupAction appium = new SetupAction(SetupTarget.APPIUM_SERVER, kind, APPIUM_VERSION, + URI.create("https://registry.npmjs.org/appium/-/appium-" + APPIUM_VERSION + ".tgz"), + "sha256:" + APPIUM_SHA256, lock, false, Set.of()); + SetupAction inspector = new SetupAction(SetupTarget.APPIUM_INSPECTOR_PLUGIN, kind, + INSPECTOR_PLUGIN_VERSION, URI.create("https://registry.npmjs.org/appium-inspector-plugin/-/" + + "appium-inspector-plugin-" + INSPECTOR_PLUGIN_VERSION + ".tgz"), + "sha256:" + INSPECTOR_SHA256, lock, false, Set.of()); + SetupAction driver = new SetupAction(SetupTarget.APPIUM_UIAUTOMATOR2_DRIVER, kind, + UIAUTOMATOR2_VERSION, URI.create("https://registry.npmjs.org/appium-uiautomator2-driver/-/" + + "appium-uiautomator2-driver-" + UIAUTOMATOR2_VERSION + ".tgz"), + "sha256:" + UIAUTOMATOR2_SHA256, lock, false, Set.of()); + String abi = resolved.abi(); + String image = "system-images;android-" + API_LEVEL + ';' + IMAGE_TAG + ';' + abi; + String packages = String.join(",", "cmdline-tools:" + COMMAND_LINE_TOOLS_VERSION, + "platform-tools@" + PLATFORM_TOOLS_VERSION, "emulator@" + EMULATOR_VERSION, + "platforms;android-" + API_LEVEL + '@' + ANDROID_PLATFORM_REVISION, + "build-tools;" + BUILD_TOOLS_VERSION + '@' + BUILD_TOOLS_VERSION, + image + '@' + SYSTEM_IMAGE_REVISION); + AndroidArchive archive = archive(platform, architecture); + SetupAction sdk = new SetupAction(SetupTarget.ANDROID_SDK, kind, packages, archive.source(), + "sha256:" + archive.sha256(), false, Set.of(ANDROID_SDK_LICENSE)); + String avd = resolved.avdName(); + String avdSpec = String.join(",", "avd=" + avd, "device=" + resolved.deviceProfile(), + "api=" + resolved.apiLevel(), "tag=" + resolved.imageTag(), "abi=" + abi, + "ramMb=" + resolved.ramMb(), "cores=" + resolved.cores(), + "port=" + resolved.appiumPort()); + SetupAction emulator = new SetupAction(SetupTarget.ANDROID_EMULATOR, kind, avdSpec, + URI.create("urn:shaft:android-avd:" + avd), sha256(avdSpec), false, + Set.of(ANDROID_SDK_LICENSE)); + return SetupPlan.create(SetupProfile.MOBILE_ANDROID, platform, architecture, mode, + List.of(node, appium, inspector, driver, sdk, emulator)); + } + + private static AndroidArchive archive(SetupPlatform platform, SetupArchitecture architecture) { + String file; + String checksum; + switch (platform) { + case WINDOWS -> { + file = "commandlinetools-win-15859902_latest.zip"; + checksum = "90ae805d20434428bffcb699c290860f19bb5f66a67e6b330067e3de801fb04a"; + } + case LINUX -> { + file = "commandlinetools-linux-15859902_latest.zip"; + checksum = "4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583"; + } + case MACOS -> { + if (architecture == SetupArchitecture.ARM64) { + file = "commandlinetools-mac_arm64-15859902_latest.zip"; + checksum = "835b62a26162b229b441d1f6d4680383815a270809eb33522c0d480fa5002c4e"; + } else { + file = "commandlinetools-mac_x86_64-15859902_latest.zip"; + checksum = "c5a6378ab5cf7e0d5701921405115befff13e9ff7417fb588389338f8bd050f3"; + } + } + default -> throw new IllegalArgumentException("Unsupported Android host platform: " + platform); + } + return new AndroidArchive(URI.create("https://dl.google.com/android/repository/" + file), checksum); + } + + private static void requireSupportedHost(SetupPlatform platform, SetupArchitecture architecture) { + if (platform == SetupPlatform.WINDOWS && architecture == SetupArchitecture.ARM64) { + throw new IllegalArgumentException("Android Emulator is not supported on Windows ARM64."); + } + } + + private static String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return "sha256:" + HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is required by the Java platform.", impossible); + } + } + + private record AndroidArchive(URI source, String sha256) { } +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupProvider.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupProvider.java new file mode 100644 index 00000000000..baa01fbea71 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupProvider.java @@ -0,0 +1,78 @@ +package com.shaft.infrastructure; + +import java.io.IOException; +import java.util.List; + +/** Built-in provider for the release-pinned Appium and Android emulator toolchain. */ +final class AndroidSetupProvider implements SetupProvider { + @Override + public SetupProfile profile() { + return SetupProfile.MOBILE_ANDROID; + } + + @Override + public SetupPlan plan(SetupOptions options, SetupPlatform platform, SetupArchitecture architecture) { + return AndroidSetupPlanner.plan(platform, architecture, options.effectiveMode()); + } + + @Override + public SetupPlan plan(SetupOptions options, SetupSelection selection, + SetupPlatform platform, SetupArchitecture architecture) { + return AndroidSetupPlanner.plan(platform, architecture, options.effectiveMode(), + AndroidSetupRequest.fromSelection(selection)); + } + + @Override + public SetupReport status(SetupOptions options, SetupPlatform platform, SetupArchitecture architecture) { + return status(options, SetupSelection.defaults(), platform, architecture); + } + + @Override + public SetupReport status(SetupOptions options, SetupSelection selection, + SetupPlatform platform, SetupArchitecture architecture) { + AndroidSetupRequest request = AndroidSetupRequest.fromSelection(selection); + String detail = options.effectiveMode() == SetupMode.EXTERNAL + ? "External mode is diagnostic-only and does not execute managed tools." + : "The managed Android toolchain is not installed."; + if (options.effectiveMode() == SetupMode.EXTERNAL) { + List targets = AndroidSetupPlanner.plan(platform, architecture, SetupMode.EXTERNAL, request) + .actions().stream().map(action -> new SetupStatus(action.target(), SetupReadiness.MISSING, "", detail)) + .toList(); + return SetupReport.from(new SetupProfileStatus(1, profile(), SetupReadiness.MISSING, targets)); + } + AndroidToolchainOperations operations = new DefaultAndroidToolchainOperations(options.paths(), platform, + architecture, request, options.offline()); + return SetupReport.from(new AndroidSetupService(options.paths(), platform, architecture, request, + operations, options.offline()).status()); + } + + @Override + public SetupReceipt install(SetupPlan plan, SetupApproval approval, SetupOptions options) throws IOException { + AndroidSetupRequest request = AndroidSetupRequest.fromPlan(plan); + AndroidSetupService service = new AndroidSetupService(options.paths(), plan.platform(), plan.architecture(), + request, new DefaultAndroidToolchainOperations(options.paths(), plan.platform(), + plan.architecture(), request, options.offline()), options.offline()); + SetupPlan providerPlan = AndroidSetupPlanner.plan(plan.platform(), plan.architecture(), plan.mode(), request); + SetupReceipt receipt = service.install(providerPlan, + new SetupApproval(providerPlan.digest(), approval.approvedAt(), approval.acceptedLicenses())); + return new SetupReceipt(plan.digest(), receipt.completedAt(), receipt.completedActions()); + } + + @Override + public ManagedEnvironment start(SetupPlan plan, SetupApproval approval, SetupOptions options) throws IOException { + AndroidSetupRequest request = AndroidSetupRequest.fromPlan(plan); + SetupPlan providerPlan = AndroidSetupPlanner.plan(plan.platform(), plan.architecture(), plan.mode(), request); + SetupApproval providerApproval = new SetupApproval(providerPlan.digest(), approval.approvedAt(), + approval.acceptedLicenses()); + AndroidToolchainOperations operations = new DefaultAndroidToolchainOperations(options.paths(), + plan.platform(), plan.architecture(), request, options.offline()); + AndroidLifecycleService lifecycle = new AndroidLifecycleService(options.paths(), plan.platform(), + plan.architecture(), request, operations, new SystemAndroidRuntimeController(), + new SystemAndroidRuntimeHealth(options.paths(), plan.platform(), plan.architecture())); + ManagedEnvironment inner = lifecycle.start(providerPlan, providerApproval, options); + SetupReceipt receipt = new SetupReceipt(plan.digest(), inner.receipt().completedAt(), + inner.receipt().completedActions()); + return new ManagedEnvironment(profile(), receipt, inner.endpoint(), inner.connectionProperties(), inner::close); + } + +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupRequest.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupRequest.java new file mode 100644 index 00000000000..e75ec46dcbf --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupRequest.java @@ -0,0 +1,153 @@ +package com.shaft.infrastructure; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +/** Immutable Android virtual-device selection bound into a reviewed setup plan. */ +public record AndroidSetupRequest(int apiLevel, String deviceProfile, String imageTag, String abi, + String avdName, int ramMb, int cores, int appiumPort) { + public AndroidSetupRequest { + deviceProfile = requireToken(deviceProfile, "deviceProfile"); + imageTag = requireToken(imageTag, "imageTag"); + abi = requireToken(abi, "abi"); + avdName = requireToken(avdName, "avdName"); + if (apiLevel != AndroidSetupPlanner.API_LEVEL) { + throw new IllegalArgumentException("Unsupported Android API level: " + apiLevel); + } + if (!deviceProfile.equals(AndroidSetupPlanner.DEVICE_PROFILE) + || !imageTag.equals(AndroidSetupPlanner.IMAGE_TAG)) { + throw new IllegalArgumentException("Android device profile and image tag must match the release manifest."); + } + if (!abi.equals("host") && !abi.equals("x86_64") && !abi.equals("arm64-v8a")) { + throw new IllegalArgumentException("Unsupported Android ABI: " + abi); + } + if (ramMb < 2048 || ramMb > 32768) { + throw new IllegalArgumentException("Android emulator RAM must be between 2048 and 32768 MB."); + } + if (cores < 1 || cores > 16) { + throw new IllegalArgumentException("Android emulator cores must be between 1 and 16."); + } + if (appiumPort < 1024 || appiumPort > 65535) { + throw new IllegalArgumentException("Appium port must be between 1024 and 65535."); + } + if (appiumPort == 5554 || appiumPort == 5555) { + throw new IllegalArgumentException("Appium port must not overlap the owned emulator ports 5554/5555."); + } + } + + /** Returns the release defaults; the host ABI is resolved during planning. */ + public static AndroidSetupRequest defaults() { + return new AndroidSetupRequest(AndroidSetupPlanner.API_LEVEL, AndroidSetupPlanner.DEVICE_PROFILE, + AndroidSetupPlanner.IMAGE_TAG, "host", + "shaft_pixel_8_api_" + AndroidSetupPlanner.API_LEVEL, AndroidSetupPlanner.RAM_MB, + AndroidSetupPlanner.CORES, AndroidSetupPlanner.APPIUM_PORT); + } + + /** Encodes this typed request into the provider-neutral selection boundary. */ + public SetupSelection toSelection() { + return new SetupSelection(List.of("api_" + apiLevel, + "device_" + hex(deviceProfile), "tag_" + hex(imageTag), "abi_" + hex(abi), + "avd_" + hex(avdName), "ram_" + ramMb, "cores_" + cores, "port_" + appiumPort)); + } + + /** Decodes a provider-neutral selection into one exact Android request. */ + public static AndroidSetupRequest fromSelection(SetupSelection selection) { + Objects.requireNonNull(selection, "selection"); + if (selection.components().isEmpty()) return defaults(); + Map values = new LinkedHashMap<>(); + for (String component : selection.components()) { + int separator = component.indexOf('_'); + if (separator <= 0 || values.put(component.substring(0, separator), + component.substring(separator + 1)) != null) { + throw new IllegalArgumentException("Android setup selection contains duplicate or invalid fields."); + } + } + if (!values.keySet().equals(java.util.Set.of("api", "device", "tag", "abi", "avd", "ram", "cores", "port"))) { + throw new IllegalArgumentException("Android setup selection must bind every supported field exactly once."); + } + try { + return new AndroidSetupRequest(Integer.parseInt(values.get("api")), unhex(values.get("device")), + unhex(values.get("tag")), unhex(values.get("abi")), unhex(values.get("avd")), + Integer.parseInt(values.get("ram")), Integer.parseInt(values.get("cores")), + Integer.parseInt(values.get("port"))); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException("Android setup selection contains invalid numeric values.", failure); + } + } + + /** Reconstructs the exact request embedded in the Android-emulator plan action. */ + public static AndroidSetupRequest fromPlan(SetupPlan plan) { + Objects.requireNonNull(plan, "plan"); + if (plan.profile() != SetupProfile.MOBILE_ANDROID) { + throw new IllegalArgumentException("Plan is not an Android mobile setup plan."); + } + SetupAction action = plan.actions().stream() + .filter(candidate -> candidate.target() == SetupTarget.ANDROID_EMULATOR) + .findFirst().orElseThrow(() -> new IllegalArgumentException( + "Android plan does not contain an emulator action.")); + Map values = new LinkedHashMap<>(); + for (String pair : action.version().split(",")) { + int separator = pair.indexOf('='); + if (separator <= 0 || values.put(pair.substring(0, separator), pair.substring(separator + 1)) != null) { + throw new IllegalArgumentException("Android emulator action contains invalid request metadata."); + } + } + try { + AndroidSetupRequest request = new AndroidSetupRequest(Integer.parseInt(required(values, "api")), + required(values, "device"), required(values, "tag"), required(values, "abi"), + required(values, "avd"), Integer.parseInt(required(values, "ramMb")), + Integer.parseInt(required(values, "cores")), Integer.parseInt(required(values, "port"))); + if (!values.isEmpty()) { + throw new IllegalArgumentException("Android emulator action contains unsupported metadata: " + + values.keySet()); + } + return request; + } catch (NumberFormatException failure) { + throw new IllegalArgumentException("Android emulator action contains invalid numeric metadata.", failure); + } + } + + AndroidSetupRequest resolve(SetupArchitecture architecture) { + String hostAbi = architecture == SetupArchitecture.ARM64 ? "arm64-v8a" : "x86_64"; + String resolvedAbi = abi.equals("host") ? hostAbi : abi; + if (!resolvedAbi.equals(hostAbi)) { + throw new IllegalArgumentException("Android ABI " + resolvedAbi + " does not match host architecture " + + architecture + '.'); + } + String resolvedName = avdName.equals("shaft_pixel_8_api_" + AndroidSetupPlanner.API_LEVEL) + ? avdName + '_' + resolvedAbi.replace('-', '_') : avdName; + return new AndroidSetupRequest(apiLevel, deviceProfile, imageTag, resolvedAbi, resolvedName, + ramMb, cores, appiumPort); + } + + private static String requireToken(String value, String name) { + if (value == null || !value.matches("[a-zA-Z0-9][a-zA-Z0-9_.-]{0,79}")) { + throw new IllegalArgumentException(name + " must be a safe Android identifier."); + } + return value; + } + + private static String required(Map values, String name) { + String value = values.remove(name); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Android emulator action is missing " + name + '.'); + } + return value; + } + + private static String hex(String value) { + return HexFormat.of().formatHex(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String unhex(String value) { + try { + return new String(HexFormat.of().parseHex(value), StandardCharsets.UTF_8); + } catch (IllegalArgumentException failure) { + throw new IllegalArgumentException("Android setup selection contains invalid encoded text.", failure); + } + } +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupService.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupService.java new file mode 100644 index 00000000000..9fb27eea442 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidSetupService.java @@ -0,0 +1,138 @@ +package com.shaft.infrastructure; + +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +/** Transaction coordinator for one exact Appium and Android emulator setup plan. */ +final class AndroidSetupService { + private static final JsonMapper JSON = JsonMapper.builder().build(); + private static final ConcurrentHashMap JVM_LOCKS = new ConcurrentHashMap<>(); + + private final ShaftCachePaths paths; + private final SetupPlatform platform; + private final SetupArchitecture architecture; + private final AndroidSetupRequest request; + private final AndroidToolchainOperations operations; + private final boolean offline; + + AndroidSetupService(ShaftCachePaths paths, SetupPlatform platform, SetupArchitecture architecture, + AndroidSetupRequest request, AndroidToolchainOperations operations, boolean offline) { + this.paths = java.util.Objects.requireNonNull(paths, "paths"); + this.platform = java.util.Objects.requireNonNull(platform, "platform"); + this.architecture = java.util.Objects.requireNonNull(architecture, "architecture"); + this.request = java.util.Objects.requireNonNull(request, "request"); + this.operations = java.util.Objects.requireNonNull(operations, "operations"); + this.offline = offline; + } + + SetupProfileStatus status() { + List targets = AndroidSetupPlanner.plan(platform, architecture, SetupMode.MANAGED, request) + .actions().stream().map(operations::status).toList(); + SetupReadiness readiness = targets.stream().allMatch(target -> target.readiness() == SetupReadiness.READY) + ? SetupReadiness.READY + : targets.stream().anyMatch(target -> target.readiness() == SetupReadiness.DEGRADED) + ? SetupReadiness.DEGRADED : SetupReadiness.MISSING; + if (readiness == SetupReadiness.READY && !hasCompatibleReceipt()) { + java.util.ArrayList adjusted = new java.util.ArrayList<>(targets); + SetupStatus last = adjusted.getLast(); + adjusted.set(adjusted.size() - 1, new SetupStatus(last.target(), SetupReadiness.DEGRADED, + last.detectedVersion(), "Managed files exist without a compatible SHAFT receipt.")); + targets = List.copyOf(adjusted); + readiness = SetupReadiness.DEGRADED; + } + return new SetupProfileStatus(1, SetupProfile.MOBILE_ANDROID, readiness, targets); + } + + SetupReceipt install(SetupPlan plan, SetupApproval approval) throws IOException { + requireCompatible(plan); + SetupExecutor.validate(plan, approval); + operations.preflight(plan.actions(), offline); + Path lockPath = paths.state().resolve("mobile-android.lock").toAbsolutePath().normalize(); + VerifiedArtifactStore.requireUnlinkedAncestors(lockPath); + ReentrantLock jvmLock = JVM_LOCKS.computeIfAbsent(lockPath, ignored -> new ReentrantLock()); + boolean acquired = false; + try { + jvmLock.lockInterruptibly(); + acquired = true; + Files.createDirectories(paths.state()); + try (FileChannel channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + operations.preflight(plan.actions(), offline); + SetupReceipt receipt = SetupExecutor.execute(plan, approval, action -> { + try { + operations.install(action); + } catch (IOException failure) { + throw new SetupOperationException(failure); + } + }); + writeReceipt(receipt); + return receipt; + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for the Android setup lock.", interrupted); + } finally { + if (acquired) jvmLock.unlock(); + } + } + + private void requireCompatible(SetupPlan plan) { + if (plan.profile() != SetupProfile.MOBILE_ANDROID) { + throw new IllegalArgumentException("Not an Android mobile setup plan."); + } + if (plan.platform() != platform || plan.architecture() != architecture) { + throw new IllegalArgumentException("Plan platform does not match this host."); + } + if (plan.mode() == SetupMode.EXTERNAL) { + throw new IllegalArgumentException("External plans are diagnostic and cannot be installed."); + } + SetupPlan expected = AndroidSetupPlanner.plan(platform, architecture, plan.mode(), request); + if (!expected.equals(plan)) { + throw new IllegalArgumentException("Plan does not match the Android manifest shipped with this release."); + } + } + + private void writeReceipt(SetupReceipt receipt) throws IOException { + Files.createDirectories(paths.receipts()); + Path destination = paths.receipts().resolve("mobile-android.json"); + Path temporary = Files.createTempFile(paths.receipts(), "mobile-android", ".tmp"); + try { + Files.writeString(temporary, JSON.writerWithDefaultPrettyPrinter().writeValueAsString(receipt)); + VerifiedArtifactStore.move(temporary, destination); + } finally { + Files.deleteIfExists(temporary); + } + } + + private boolean hasCompatibleReceipt() { + Path receiptPath = paths.receipts().resolve("mobile-android.json"); + try { + VerifiedArtifactStore.requireUnlinkedAncestors(receiptPath); + if (!Files.isRegularFile(receiptPath, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return false; + SetupReceipt receipt = JSON.readValue(receiptPath.toFile(), SetupReceipt.class); + for (SetupMode mode : List.of(SetupMode.MANAGED, SetupMode.HYBRID)) { + SetupPlan expected = AndroidSetupPlanner.plan(platform, architecture, mode, request); + if (receipt.planDigest().equals(expected.digest()) + && receipt.completedActions().equals(expected.actions())) return true; + } + return false; + } catch (IOException | RuntimeException invalid) { + return false; + } + } + + private static final class SetupOperationException extends RuntimeException { + private SetupOperationException(IOException cause) { + super(cause); + } + } +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidToolchainOperations.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidToolchainOperations.java new file mode 100644 index 00000000000..faf8e2ef9b3 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/AndroidToolchainOperations.java @@ -0,0 +1,13 @@ +package com.shaft.infrastructure; + +import java.io.IOException; +import java.util.List; + +/** Injectable mutation boundary used by the Android setup transaction coordinator. */ +interface AndroidToolchainOperations { + void preflight(List actions, boolean offline) throws IOException; + + void install(SetupAction action) throws IOException; + + SetupStatus status(SetupAction action); +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/DefaultAndroidToolchainOperations.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/DefaultAndroidToolchainOperations.java new file mode 100644 index 00000000000..59084b3f6c3 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/DefaultAndroidToolchainOperations.java @@ -0,0 +1,603 @@ +package com.shaft.infrastructure; + +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Real filesystem/network/process implementation behind the Android setup transaction. */ +final class DefaultAndroidToolchainOperations implements AndroidToolchainOperations { + private static final JsonMapper JSON = JsonMapper.builder().build(); + private final ShaftCachePaths paths; + private final SetupPlatform platform; + private final SetupArchitecture architecture; + private final AndroidSetupRequest request; + private final ReportingSetupService.ArtifactFetcher fetcher; + private final AndroidCommandRunner runner; + private final ReportingSetupService nodeService; + private final boolean offline; + + DefaultAndroidToolchainOperations(ShaftCachePaths paths, SetupPlatform platform, + SetupArchitecture architecture, AndroidSetupRequest request, + boolean offline) { + this(paths, platform, architecture, request, + action -> new VerifiedArtifactStore(paths.downloads()).fetch(action, offline), + AndroidCommandRunner.system(paths, platform, architecture), offline); + } + + DefaultAndroidToolchainOperations(ShaftCachePaths paths, SetupPlatform platform, + SetupArchitecture architecture, AndroidSetupRequest request, + ReportingSetupService.ArtifactFetcher fetcher, + AndroidCommandRunner runner, boolean offline) { + this.paths = paths; + this.platform = platform; + this.architecture = architecture; + this.request = request.resolve(architecture); + this.fetcher = fetcher; + this.runner = runner; + this.offline = offline; + this.nodeService = new ReportingSetupService(paths, platform, architecture, fetcher, + (command, log, timeout) -> runner.run(command, paths.cacheRoot(), Map.of(), Set.of(), null, + log, timeout), offline); + } + + @Override + public void preflight(List actions, boolean requireOffline) throws IOException { + requireSafePaths(); + if (!requireOffline) return; + if (!actions.stream().allMatch(this::structurallyInstalled)) { + throw new IOException("Offline Android setup requires a complete verified installation; " + + "cold or partial setup cannot run sdkmanager or npm without network access."); + } + boolean ready = actions.stream().allMatch(action -> status(action).readiness() == SetupReadiness.READY); + if (!ready) { + throw new IOException("Offline Android setup requires a complete verified installation; " + + "cold or partial setup cannot run sdkmanager or npm without network access."); + } + } + + private boolean structurallyInstalled(SetupAction action) { + try { + return switch (action.target()) { + case NODE -> Files.isRegularFile(nodeExecutable(), LinkOption.NOFOLLOW_LINKS); + case APPIUM_SERVER -> appiumBundleStructureReady(); + case APPIUM_INSPECTOR_PLUGIN -> Files.isRegularFile( + appiumRoot().resolve("node_modules/appium-inspector-plugin/package.json"), + LinkOption.NOFOLLOW_LINKS); + case APPIUM_UIAUTOMATOR2_DRIVER -> Files.isRegularFile( + appiumRoot().resolve("node_modules/appium-uiautomator2-driver/package.json"), + LinkOption.NOFOLLOW_LINKS); + case ANDROID_SDK -> sdkFilesReady(sdkRoot()); + case ANDROID_EMULATOR -> Files.isRegularFile(avdRoot().resolve("config.ini"), + LinkOption.NOFOLLOW_LINKS) && Files.isRegularFile( + avdRoot().resolve("shaft-request.properties"), LinkOption.NOFOLLOW_LINKS); + default -> false; + }; + } catch (IOException failure) { + return false; + } + } + + @Override + public void install(SetupAction action) throws IOException { + switch (action.target()) { + case NODE -> nodeService.installNodeAction(action); + case APPIUM_SERVER -> installAppiumBundle(action); + case APPIUM_INSPECTOR_PLUGIN, APPIUM_UIAUTOMATOR2_DRIVER -> requireReady(action); + case ANDROID_SDK -> installAndroidSdk(action); + case ANDROID_EMULATOR -> installAvd(action); + default -> throw new IOException("Android provider cannot install " + action.target()); + } + } + + @Override + public SetupStatus status(SetupAction action) { + try { + return switch (action.target()) { + case NODE -> nodeService.nodeStatus(); + case APPIUM_SERVER -> appiumStatus(action); + case APPIUM_INSPECTOR_PLUGIN -> extensionStatus(action, "plugin", "inspector", + "appium-inspector-plugin"); + case APPIUM_UIAUTOMATOR2_DRIVER -> extensionStatus(action, "driver", "uiautomator2", + "appium-uiautomator2-driver"); + case ANDROID_SDK -> sdkStatus(action); + case ANDROID_EMULATOR -> avdStatus(action); + default -> new SetupStatus(action.target(), SetupReadiness.DEGRADED, "", + "Unexpected target in Android plan."); + }; + } catch (IOException failure) { + return new SetupStatus(action.target(), SetupReadiness.DEGRADED, "", failure.getMessage()); + } + } + + private void installAppiumBundle(SetupAction serverAction) throws IOException { + if (appiumBundleReady()) return; + if (nodeService.nodeStatus().readiness() != SetupReadiness.READY) { + throw new IOException("Portable Node must be ready before Appium installation."); + } + SetupPlan manifest = AndroidSetupPlanner.plan(platform, architecture, SetupMode.MANAGED, request); + List packages = manifest.actions().subList(1, 4); + List archives = new ArrayList<>(); + for (SetupAction action : packages) archives.add(fetcher.fetch(action)); + Path destination = appiumRoot(); + Files.createDirectories(destination.getParent()); + Path staging = Files.createTempDirectory(destination.getParent(), "appium.staging-"); + try { + copyAppiumManifest(staging, serverAction.dependencyLockChecksum()); + Path log = logFile(); + Files.createDirectories(log.getParent()); + for (Path archive : archives) { + List cache = new ArrayList<>(List.of(nodeExecutable().toString(), npmCli().toString(), + "cache", "add", archive.toString())); + if (offline) cache.add("--offline"); + requireSuccess(run(cache, staging, null, log, Duration.ofMinutes(2)), + "Appium npm cache preparation failed"); + } + List install = new ArrayList<>(List.of(nodeExecutable().toString(), npmCli().toString(), "ci", + "--prefix", staging.toString(), "--ignore-scripts", "--no-audit", "--no-fund")); + if (offline) install.add("--offline"); + requireSuccess(run(install, staging, null, log, Duration.ofMinutes(10)), + "Appium npm installation failed"); + requireExactPackage(staging.resolve("node_modules/appium/package.json"), + AndroidSetupPlanner.APPIUM_VERSION); + requireExactPackage(staging.resolve("node_modules/appium-inspector-plugin/package.json"), + AndroidSetupPlanner.INSPECTOR_PLUGIN_VERSION); + requireExactPackage(staging.resolve("node_modules/appium-uiautomator2-driver/package.json"), + AndroidSetupPlanner.UIAUTOMATOR2_VERSION); + ReportingSetupService.ProcessResult version = run(List.of(nodeExecutable().toString(), + staging.resolve("node_modules/appium/index.js").toString(), "--version"), staging, + null, log, Duration.ofSeconds(30)); + requireSuccess(version, "Appium verification failed"); + requireExactVersion(version.output(), AndroidSetupPlanner.APPIUM_VERSION, "Appium"); + requireExtension(staging, "plugin", "inspector", "appium-inspector-plugin", + AndroidSetupPlanner.INSPECTOR_PLUGIN_VERSION, log); + requireExtension(staging, "driver", "uiautomator2", "appium-uiautomator2-driver", + AndroidSetupPlanner.UIAUTOMATOR2_VERSION, log); + clearAppiumExtensionCache(staging); + ReportingSetupService.publish(staging, destination, VerifiedArtifactStore::move); + } finally { + deleteTree(staging); + } + } + + private void installAndroidSdk(SetupAction action) throws IOException { + if (sdkStatus(action).readiness() == SetupReadiness.READY) return; + Path archive = fetcher.fetch(action); + Path destination = sdkRoot(); + Files.createDirectories(destination.getParent()); + Path staging = Files.createTempDirectory(destination.getParent(), "android-sdk.staging-"); + Path extracted = Files.createDirectory(staging.resolve("extract")); + try { + SafeZipExtractor.extract(archive, extracted); + Path commandTools = extracted.resolve("cmdline-tools"); + if (!Files.isDirectory(commandTools, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Android command-line-tools archive has an unexpected layout."); + } + Path latest = staging.resolve("cmdline-tools/latest"); + Files.createDirectories(latest.getParent()); + VerifiedArtifactStore.move(commandTools, latest); + deleteTree(extracted); + makeSdkCommandsExecutable(staging); + Path sdkManager = sdkManager(staging); + Map environment = androidEnvironment(staging, avdHome()); + Path log = logFile(); + Files.createDirectories(log.getParent()); + List command = new ArrayList<>(List.of(sdkManager.toString(), "--sdk_root=" + staging)); + command.addAll(sdkPackages()); + // The reviewed plan binds the one stable Android SDK license required by this exact package set. + // Feed consent only to that package-scoped install; `sdkmanager --licenses` would accept unrelated + // repository licenses that were never present in the approved plan. + requireSuccess(run(command, staging, "y\n", log, Duration.ofMinutes(30), environment), + "Android SDK package installation failed"); + requireSdkFiles(staging); + requireSdkTools(staging); + ReportingSetupService.publish(staging, destination, VerifiedArtifactStore::move); + } finally { + deleteTree(staging); + } + } + + private void installAvd(SetupAction action) throws IOException { + if (avdStatus(action).readiness() == SetupReadiness.READY) return; + if (sdkStatus(AndroidSetupPlanner.plan(platform, architecture, SetupMode.MANAGED, request) + .actions().get(4)).readiness() != SetupReadiness.READY) { + throw new IOException("Android SDK must be ready before AVD creation."); + } + Path destination = avdRoot(); + if (avdDirectoryReady()) { + writeAvdPointer(destination); + requireReady(action); + return; + } + Files.createDirectories(destination.getParent()); + Path staging = Files.createTempDirectory(destination.getParent(), request.avdName() + ".staging-"); + try { + List command = List.of(avdManager(sdkRoot()).toString(), "create", "avd", "--force", + "--name", request.avdName(), "--package", systemImage(), "--device", request.deviceProfile(), + "--path", staging.toString()); + requireSuccess(run(command, sdkRoot(), "no\n", logFile(), Duration.ofMinutes(3), + androidEnvironment(sdkRoot(), avdHome())), "Android AVD creation failed"); + Files.writeString(staging.resolve("shaft-request.properties"), requestMetadata()); + ReportingSetupService.publish(staging, destination, VerifiedArtifactStore::move); + writeAvdPointer(destination); + } finally { + deleteTree(staging); + } + } + + private SetupStatus appiumStatus(SetupAction action) throws IOException { + if (!appiumBundleStructureReady()) return missing(action, "Appium is not installed."); + ReportingSetupService.ProcessResult result = run(List.of(nodeExecutable().toString(), + appiumRoot().resolve("node_modules/appium/index.js").toString(), "--version"), appiumRoot(), + null, null, Duration.ofSeconds(20)); + String version = reportedVersion(result.output()); + return result.exitCode() == 0 && version.equals(action.version()) + ? ready(action, version) : degraded(action, version, "Appium version or execution check failed."); + } + + private SetupStatus extensionStatus(SetupAction action, String type, String extensionName, + String packageName) throws IOException { + Path manifest = appiumRoot().resolve("node_modules").resolve(packageName).resolve("package.json"); + VerifiedArtifactStore.requireUnlinkedAncestors(manifest); + if (!Files.isRegularFile(manifest, LinkOption.NOFOLLOW_LINKS)) return missing(action, "Not installed."); + String version = packageVersion(manifest); + if (!version.equals(action.version())) { + return degraded(action, version, "Installed npm package version does not match the release manifest."); + } + try { + requireExtension(appiumRoot(), type, extensionName, packageName, action.version(), null); + return ready(action, version); + } catch (IOException failure) { + return degraded(action, version, failure.getMessage()); + } + } + + private void requireExtension(Path root, String type, String extensionName, String packageName, + String expectedVersion, Path log) throws IOException { + ReportingSetupService.ProcessResult result = run(List.of(nodeExecutable().toString(), + root.resolve("node_modules/appium/index.js").toString(), type, "list", "--installed", "--json"), + root, null, log, Duration.ofSeconds(30)); + requireSuccess(result, "Appium " + type + " discovery failed"); + tools.jackson.databind.JsonNode extension; + try { + String output = result.output(); + int objectStart = output.indexOf('{'); + int objectEnd = output.lastIndexOf('}'); + if (objectStart < 0 || objectEnd < objectStart) { + throw new IOException("Appium " + type + " list did not contain a JSON object."); + } + extension = JSON.readTree(output.substring(objectStart, objectEnd + 1)).path(extensionName); + } catch (tools.jackson.core.JacksonException invalid) { + throw new IOException("Appium " + type + " list returned invalid JSON.", invalid); + } + if (!packageName.equals(extension.path("pkgName").asText()) + || !expectedVersion.equals(extension.path("version").asText())) { + throw new IOException("Appium " + type + ' ' + extensionName + + " is not registered at the approved version."); + } + } + + private static void clearAppiumExtensionCache(Path root) throws IOException { + Path cache = root.resolve("node_modules/.cache/appium"); + VerifiedArtifactStore.requireUnlinkedAncestors(cache); + deleteTree(cache); + } + + private SetupStatus sdkStatus(SetupAction action) throws IOException { + VerifiedArtifactStore.requireUnlinkedAncestors(sdkRoot()); + if (!sdkFilesReady(sdkRoot())) return missing(action, "Android SDK package set is incomplete."); + requireSdkTools(sdkRoot()); + return ready(action, action.version()); + } + + private SetupStatus avdStatus(SetupAction action) throws IOException { + VerifiedArtifactStore.requireUnlinkedAncestors(avdRoot()); + if (!avdDirectoryReady()) { + return missing(action, "SHAFT-owned AVD is not configured."); + } + Path pointer = avdHome().resolve(request.avdName() + ".ini"); + VerifiedArtifactStore.requireUnlinkedAncestors(pointer); + if (!Files.isRegularFile(pointer, LinkOption.NOFOLLOW_LINKS)) { + return missing(action, "SHAFT-owned AVD pointer is not published."); + } + if (!Files.readString(pointer, StandardCharsets.UTF_8).equals(avdPointerContent(avdRoot()))) { + return degraded(action, "", "AVD pointer does not match the reviewed request."); + } + if (!avdImageMatchesReviewedRequest()) { + return degraded(action, action.version(), + "AVD config does not reference the reviewed Android system image."); + } + ReportingSetupService.ProcessResult acceleration = run(List.of( + executable(sdkRoot().resolve("emulator"), "emulator").toString(), "-accel-check"), + sdkRoot(), null, null, Duration.ofSeconds(30), androidEnvironment(sdkRoot(), avdHome())); + if (acceleration.exitCode() != 0) { + String diagnostic = acceleration.output().strip(); + if (diagnostic.length() > 512) diagnostic = diagnostic.substring(0, 512) + "..."; + if (diagnostic.isEmpty()) diagnostic = "emulator -accel-check exited with code " + + acceleration.exitCode() + '.'; + return degraded(action, action.version(), "Android emulator acceleration is unavailable: " + diagnostic); + } + return ready(action, action.version()); + } + + private boolean avdDirectoryReady() throws IOException { + Path metadata = avdRoot().resolve("shaft-request.properties"); + Path config = avdRoot().resolve("config.ini"); + if (!Files.isRegularFile(metadata, LinkOption.NOFOLLOW_LINKS) + || !Files.isRegularFile(config, LinkOption.NOFOLLOW_LINKS)) return false; + return Files.readString(metadata, StandardCharsets.UTF_8).equals(requestMetadata()); + } + + private boolean avdImageMatchesReviewedRequest() throws IOException { + String expected = systemImage().replace(';', '/'); + return Files.readAllLines(avdRoot().resolve("config.ini"), StandardCharsets.UTF_8).stream() + .filter(line -> line.startsWith("image.sysdir.1=")) + .map(line -> line.substring("image.sysdir.1=".length()).trim().replace('\\', '/')) + .map(value -> value.endsWith("/") ? value.substring(0, value.length() - 1) : value) + .anyMatch(expected::equals); + } + + private boolean appiumBundleReady() throws IOException { + return appiumBundleStructureReady() + && appiumStatus(AndroidSetupPlanner.plan(platform, architecture, SetupMode.MANAGED, request) + .actions().get(1)).readiness() == SetupReadiness.READY; + } + + private boolean appiumBundleStructureReady() throws IOException { + Path lock = appiumRoot().resolve("package-lock.json"); + VerifiedArtifactStore.requireUnlinkedAncestors(appiumRoot()); + return Files.isRegularFile(appiumRoot().resolve("node_modules/appium/index.js"), LinkOption.NOFOLLOW_LINKS) + && Files.isRegularFile(lock, LinkOption.NOFOLLOW_LINKS) + && VerifiedArtifactStore.digest(lock).equalsIgnoreCase(AndroidSetupPlanner.APPIUM_LOCK_SHA256); + } + + private void requireReady(SetupAction action) throws IOException { + SetupStatus status = status(action); + if (status.readiness() != SetupReadiness.READY) { + throw new IOException(action.target() + " verification failed: " + status.detail()); + } + } + + private void copyAppiumManifest(Path staging, String expectedLock) throws IOException { + for (String name : List.of("package.json", "package-lock.json")) { + try (InputStream input = getClass().getResourceAsStream("/com/shaft/infrastructure/appium/" + name)) { + if (input == null) throw new IOException("Missing bundled Appium manifest: " + name); + byte[] content = input.readAllBytes(); + if (name.equals("package-lock.json")) content = new String(content, StandardCharsets.UTF_8) + .replace("\r\n", "\n").replace('\r', '\n').getBytes(StandardCharsets.UTF_8); + Files.write(staging.resolve(name), content); + } + } + String digest = "sha256:" + VerifiedArtifactStore.digest(staging.resolve("package-lock.json")); + if (!digest.equalsIgnoreCase(expectedLock)) { + throw new IOException("Bundled Appium lock does not match the approved plan."); + } + } + + private void requireSdkFiles(Path root) throws IOException { + if (!sdkFilesReady(root)) throw new IOException("Android SDK installation is missing a required package."); + } + + private boolean sdkFilesReady(Path root) throws IOException { + for (Path file : List.of(sdkManager(root), avdManager(root), executable(root.resolve("platform-tools"), "adb"), + executable(root.resolve("emulator"), "emulator"), + root.resolve("platforms/android-" + request.apiLevel() + "/android.jar"), + executable(root.resolve("build-tools").resolve(AndroidSetupPlanner.BUILD_TOOLS_VERSION), "aapt2"), + root.resolve(systemImage().replace(';', java.io.File.separatorChar)).resolve("package.xml"))) { + VerifiedArtifactStore.requireUnlinkedAncestors(file); + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) return false; + } + return true; + } + + private List sdkPackages() { + return List.of("platform-tools", "emulator", "platforms;android-" + request.apiLevel(), + "build-tools;" + AndroidSetupPlanner.BUILD_TOOLS_VERSION, systemImage()); + } + + private void requireSdkTools(Path root) throws IOException { + Map environment = androidEnvironment(root, avdHome()); + ReportingSetupService.ProcessResult installed = run(List.of(sdkManager(root).toString(), + "--sdk_root=" + root, "--list_installed"), root, null, null, Duration.ofMinutes(1), environment); + requireSuccess(installed, "Android SDK installed-package discovery failed"); + Map installedPackages = new LinkedHashMap<>(); + installed.output().lines().forEach(line -> { + String[] columns = line.split("\\|", 3); + if (columns.length >= 2) installedPackages.put(columns[0].trim(), columns[1].trim()); + }); + for (Map.Entry required : sdkPackageRevisions().entrySet()) { + String actual = installedPackages.get(required.getKey()); + if (actual == null) { + throw new IOException("Android SDK installed package set is missing " + required.getKey() + '.'); + } + if (!actual.equals(required.getValue())) { + throw new IOException("Android SDK package " + required.getKey() + " has revision " + actual + + " instead of the approved " + required.getValue() + '.'); + } + } + for (List probe : List.of( + List.of(executable(root.resolve("platform-tools"), "adb").toString(), "version"), + List.of(executable(root.resolve("emulator"), "emulator").toString(), "-version"), + List.of(executable(root.resolve("build-tools").resolve(AndroidSetupPlanner.BUILD_TOOLS_VERSION), + "aapt2").toString(), "version"))) { + requireSuccess(run(probe, root, null, null, Duration.ofSeconds(30), environment), + "Android SDK tool probe failed: " + probe.getFirst()); + } + } + + private Map sdkPackageRevisions() { + return Map.of("platform-tools", AndroidSetupPlanner.PLATFORM_TOOLS_VERSION, + "emulator", AndroidSetupPlanner.EMULATOR_VERSION, + "platforms;android-" + request.apiLevel(), AndroidSetupPlanner.ANDROID_PLATFORM_REVISION, + "build-tools;" + AndroidSetupPlanner.BUILD_TOOLS_VERSION, + AndroidSetupPlanner.BUILD_TOOLS_VERSION, + systemImage(), AndroidSetupPlanner.SYSTEM_IMAGE_REVISION); + } + + private String systemImage() { + return "system-images;android-" + request.apiLevel() + ';' + request.imageTag() + ';' + request.abi(); + } + + private Map androidEnvironment(Path sdk, Path avd) { + return Map.of("ANDROID_HOME", sdk.toString(), "ANDROID_SDK_ROOT", sdk.toString(), + "ANDROID_AVD_HOME", avd.toString()); + } + + private ReportingSetupService.ProcessResult run(List command, Path workingDirectory, String input, + Path log, Duration timeout) throws IOException { + return run(command, workingDirectory, input, log, timeout, Map.of()); + } + + private ReportingSetupService.ProcessResult run(List command, Path workingDirectory, String input, + Path log, Duration timeout, + Map environment) throws IOException { + return runner.run(command, workingDirectory, environment, + Set.of("APPIUM_HOME", "REPO_OS_OVERRIDE"), input, log, timeout); + } + + private void requireSafePaths() throws IOException { + for (Path path : List.of(paths.cacheRoot(), paths.dataRoot(), paths.downloads(), paths.tools(), paths.state(), + paths.receipts(), nodeRoot(), appiumRoot(), sdkRoot(), avdHome(), avdRoot())) { + VerifiedArtifactStore.requireUnlinkedAncestors(path); + } + } + + private Path nodeRoot() { + return paths.tools().resolve("node").resolve(ReportingSetupPlanner.NODE_VERSION) + .resolve(platform.name().toLowerCase() + '-' + architecture.artifactName()); + } + + private Path nodeExecutable() { + return platform == SetupPlatform.WINDOWS ? nodeRoot().resolve("node.exe") + : nodeRoot().resolve("bin/node"); + } + + private Path npmCli() { + return platform == SetupPlatform.WINDOWS ? nodeRoot().resolve("node_modules/npm/bin/npm-cli.js") + : nodeRoot().resolve("lib/node_modules/npm/bin/npm-cli.js"); + } + + private Path appiumRoot() { return paths.tools().resolve("appium").resolve(AndroidSetupPlanner.APPIUM_VERSION); } + + private Path sdkRoot() { + return paths.tools().resolve("android-sdk").resolve(AndroidSetupPlanner.COMMAND_LINE_TOOLS_VERSION + + "-api" + request.apiLevel() + '-' + request.abi()); + } + + private Path avdHome() { return paths.tools().resolve("android-avd"); } + + private Path avdRoot() { return avdHome().resolve(request.avdName() + ".avd"); } + + private Path sdkManager(Path root) { + return executable(root.resolve("cmdline-tools/latest/bin"), "sdkmanager"); + } + + private Path avdManager(Path root) { + return executable(root.resolve("cmdline-tools/latest/bin"), "avdmanager"); + } + + private void makeSdkCommandsExecutable(Path root) throws IOException { + if (platform == SetupPlatform.WINDOWS) return; + for (Path command : List.of(sdkManager(root), avdManager(root))) { + VerifiedArtifactStore.requireUnlinkedAncestors(command); + if (!Files.isRegularFile(command, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Android command-line-tools archive is missing " + command.getFileName() + '.'); + } + ReportingSetupService.makeExecutable(command); + } + } + + private Path executable(Path directory, String name) { + if (platform != SetupPlatform.WINDOWS) return directory.resolve(name); + return directory.resolve(name + switch (name) { + case "sdkmanager", "avdmanager" -> ".bat"; + default -> ".exe"; + }); + } + + private Path logFile() { return paths.state().resolve("logs/mobile-android-install.log"); } + + private void writeAvdPointer(Path destination) throws IOException { + Path pointer = avdHome().resolve(request.avdName() + ".ini"); + VerifiedArtifactStore.requireUnlinkedAncestors(pointer); + Path temporary = Files.createTempFile(avdHome(), request.avdName(), ".ini.tmp"); + try { + Files.writeString(temporary, avdPointerContent(destination)); + VerifiedArtifactStore.move(temporary, pointer); + } finally { + Files.deleteIfExists(temporary); + } + } + + private String avdPointerContent(Path destination) { + return "path=" + destination + System.lineSeparator() + + "path.rel=avd/" + destination.getFileName() + System.lineSeparator() + + "target=android-" + request.apiLevel() + System.lineSeparator(); + } + + private String requestMetadata() { + return String.join("\n", "api=" + request.apiLevel(), "device=" + request.deviceProfile(), + "tag=" + request.imageTag(), "abi=" + request.abi(), "avd=" + request.avdName(), + "ramMb=" + request.ramMb(), "cores=" + request.cores(), "port=" + request.appiumPort()) + "\n"; + } + + private static void requireExactPackage(Path manifest, String expected) throws IOException { + String actual = packageVersion(manifest); + if (!actual.equals(expected)) throw new IOException("Installed npm package version " + actual + + " does not match " + expected + '.'); + } + + private static String packageVersion(Path manifest) throws IOException { + VerifiedArtifactStore.requireUnlinkedAncestors(manifest); + if (!Files.isRegularFile(manifest, LinkOption.NOFOLLOW_LINKS)) return ""; + String json = Files.readString(manifest, StandardCharsets.UTF_8); + var matcher = java.util.regex.Pattern.compile("\\\"version\\\"\\s*:\\s*\\\"([^\\\"]+)\\\"") + .matcher(json); + if (!matcher.find()) throw new IOException("npm package manifest has no version: " + manifest); + return matcher.group(1); + } + + private static void requireExactVersion(String output, String expected, String tool) throws IOException { + if (!reportedVersion(output).equals(expected)) { + throw new IOException(tool + " verification returned unexpected version: " + output.trim()); + } + } + + private static String reportedVersion(String output) { + return output.lines().map(String::trim).filter(line -> !line.isEmpty()) + .reduce((ignored, last) -> last).orElse(""); + } + + private static void requireSuccess(ReportingSetupService.ProcessResult result, String message) throws IOException { + if (result.exitCode() != 0) throw new IOException(message + System.lineSeparator() + result.output()); + } + + private static SetupStatus ready(SetupAction action, String version) { + return new SetupStatus(action.target(), SetupReadiness.READY, version, "Verified managed installation."); + } + + private static SetupStatus missing(SetupAction action, String detail) { + return new SetupStatus(action.target(), SetupReadiness.MISSING, "", detail); + } + + private static SetupStatus degraded(SetupAction action, String version, String detail) { + return new SetupStatus(action.target(), SetupReadiness.DEGRADED, version, detail); + } + + private static void deleteTree(Path root) throws IOException { + if (root == null || Files.notExists(root)) return; + try (var stream = Files.walk(root)) { + for (Path path : stream.sorted(java.util.Comparator.reverseOrder()).toList()) Files.deleteIfExists(path); + } + } +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/InfrastructureSetupService.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/InfrastructureSetupService.java index d7f3e923686..559ae824442 100644 --- a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/InfrastructureSetupService.java +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/InfrastructureSetupService.java @@ -23,7 +23,8 @@ public static InfrastructureSetupService builtIn() { public static InfrastructureSetupService builtIn(SetupPlatform platform, SetupArchitecture architecture) { return new InfrastructureSetupService(new SetupProviderRegistry(List.of( - new ReportingSetupProvider(), new OcrSetupProvider(), new LighthouseSetupProvider())), + new ReportingSetupProvider(), new OcrSetupProvider(), new LighthouseSetupProvider(), + new AndroidSetupProvider())), platform, architecture); } @@ -53,6 +54,12 @@ public SetupPlan plan(SetupOptions options, SetupSelection selection) { return SetupPlan.bind(plan, value.policyDigest()); } + /** Plans one typed Android emulator request without exposing provider encoding details. */ + public SetupPlan plan(SetupOptions options, AndroidSetupRequest request) { + requireAndroidProfile(options); + return plan(options, Objects.requireNonNull(request, "request").toSelection()); + } + public SetupReport doctor(SetupOptions options) { return status(options); } @@ -72,6 +79,12 @@ public SetupReport status(SetupOptions options, SetupSelection selection) { return report; } + /** Reports readiness for one exact typed Android emulator request. */ + public SetupReport status(SetupOptions options, AndroidSetupRequest request) { + requireAndroidProfile(options); + return status(options, Objects.requireNonNull(request, "request").toSelection()); + } + public SetupReport verify(SetupOptions options) { return status(options); } @@ -80,6 +93,11 @@ public SetupReport verify(SetupOptions options, SetupSelection selection) { return status(options, selection); } + /** Verifies one exact typed Android emulator request. */ + public SetupReport verify(SetupOptions options, AndroidSetupRequest request) { + return status(options, request); + } + public SetupReceipt install(SetupPlan plan, SetupApproval approval, SetupOptions options) throws IOException { return install(plan, approval, options, SetupSelection.defaults()); } @@ -92,9 +110,21 @@ public SetupReceipt install(SetupPlan plan, SetupApproval approval, SetupOptions return receipt; } + /** Installs one exact typed Android emulator request after approval. */ + public SetupReceipt install(SetupPlan plan, SetupApproval approval, SetupOptions options, + AndroidSetupRequest request) throws IOException { + requireAndroidProfile(options); + return install(plan, approval, options, Objects.requireNonNull(request, "request").toSelection()); + } + public ManagedEnvironment start(SetupPlan plan, SetupApproval approval, SetupOptions options) throws IOException { - SetupProvider provider = authorize(plan, approval, options, SetupSelection.defaults(), "start a managed service"); + return start(plan, approval, options, SetupSelection.defaults()); + } + + public ManagedEnvironment start(SetupPlan plan, SetupApproval approval, SetupOptions options, + SetupSelection selection) throws IOException { + SetupProvider provider = authorize(plan, approval, options, selection, "start a managed service"); ManagedEnvironment environment = provider.start(plan, approval, options); try { if (environment.profile() != plan.profile()) { @@ -112,6 +142,13 @@ public ManagedEnvironment start(SetupPlan plan, SetupApproval approval, SetupOpt return environment; } + /** Starts one exact typed Android emulator request from its verified install receipt. */ + public ManagedEnvironment start(SetupPlan plan, SetupApproval approval, SetupOptions options, + AndroidSetupRequest request) throws IOException { + requireAndroidProfile(options); + return start(plan, approval, options, Objects.requireNonNull(request, "request").toSelection()); + } + private SetupProvider authorize(SetupPlan plan, SetupApproval approval, SetupOptions options, SetupSelection selection, String operation) { Objects.requireNonNull(plan, "plan"); @@ -148,4 +185,10 @@ private static void requireReceiptIdentity(SetupReceipt receipt, SetupPlan plan) throw new IllegalStateException("Setup provider returned a mismatched receipt."); } } + + private static void requireAndroidProfile(SetupOptions options) { + if (Objects.requireNonNull(options, "options").profile() != SetupProfile.MOBILE_ANDROID) { + throw new IllegalArgumentException("Android setup requests require profile MOBILE_ANDROID."); + } + } } diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/ReportingSetupService.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/ReportingSetupService.java index 45a2b05ea9a..df381d2b5fa 100644 --- a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/ReportingSetupService.java +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/ReportingSetupService.java @@ -18,13 +18,12 @@ import java.util.ArrayList; import java.util.EnumSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; /** Managed, release-pinned lifecycle for the REPORTING profile. */ public final class ReportingSetupService { @@ -235,6 +234,14 @@ static ProcessResult runProcess(List command, Path log, Duration timeout static ProcessResult runProcess(List command, Path log, Duration timeout, Path cacheRoot, Path nodeRoot, Path workingDirectory) throws IOException { + return runProcess(command, log, timeout, cacheRoot, nodeRoot, workingDirectory, + Map.of(), Set.of(), null); + } + + static ProcessResult runProcess(List command, Path log, Duration timeout, + Path cacheRoot, Path nodeRoot, Path workingDirectory, + Map environment, + Set removedEnvironment, String standardInput) throws IOException { ProcessBuilder builder = new ProcessBuilder(command).redirectErrorStream(true); if (workingDirectory != null) builder.directory(workingDirectory.toFile()); String nodeBin = Files.isRegularFile(nodeRoot.resolve("node.exe")) @@ -242,7 +249,22 @@ static ProcessResult runProcess(List command, Path log, Duration timeout builder.environment().put("PATH", nodeBin + java.io.File.pathSeparator + builder.environment().getOrDefault("PATH", "")); builder.environment().put("npm_config_cache", cacheRoot.resolve("npm").toString()); + removedEnvironment.forEach(builder.environment()::remove); + builder.environment().putAll(environment); Process process = builder.start(); + try { + if (standardInput != null) { + process.getOutputStream().write(standardInput.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + process.getOutputStream().close(); + } catch (IOException inputFailure) { + try { + destroyProcessTree(process); + } catch (IOException cleanupFailure) { + inputFailure.addSuppressed(cleanupFailure); + } + throw inputFailure; + } long deadlineNanos = System.nanoTime() + timeout.toNanos(); InputStream input = process.getInputStream(); var outputFuture = java.util.concurrent.CompletableFuture.supplyAsync(() -> { @@ -458,37 +480,72 @@ static void publish(Path staging, Path destination, MoveOperation mover) throws } private static void extractZip(Path archive, Path destination) throws IOException { - try (ZipInputStream input = new ZipInputStream(Files.newInputStream(archive))) { - for (ZipEntry entry; (entry = input.getNextEntry()) != null;) { - Path target = archiveTarget(destination, entry.getName()); - if (target == null) continue; - if (entry.isDirectory()) Files.createDirectories(target); - else { - Files.createDirectories(target.getParent()); - Files.copy(input, target); + Path expanded = Files.createTempDirectory(destination.getParent(), "node.zip-"); + try { + SafeZipExtractor.extract(archive, expanded); + List roots; + try (var stream = Files.list(expanded)) { + roots = stream.toList(); + } + if (roots.size() != 1 || !Files.isDirectory(roots.getFirst(), + java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Portable Node ZIP must contain exactly one top-level directory."); + } + try (var stream = Files.list(roots.getFirst())) { + for (Path child : stream.toList()) { + VerifiedArtifactStore.move(child, destination.resolve(child.getFileName())); } } + } finally { + deleteTree(expanded); } } private static void extractTar(Path archive, Path destination) throws IOException { + List links = new ArrayList<>(); try (InputStream raw = Files.newInputStream(archive); InputStream compressed = new GzipCompressorInputStream(raw); TarArchiveInputStream input = new TarArchiveInputStream(compressed)) { for (TarArchiveEntry entry; (entry = input.getNextEntry()) != null;) { - if (entry.isSymbolicLink() || entry.isLink()) throw new IOException("Archive links are not allowed."); Path target = archiveTarget(destination, entry.getName()); if (target == null) continue; - if (entry.isDirectory()) Files.createDirectories(target); + if (entry.isLink()) throw new IOException("Archive hard links are not allowed: " + entry.getName()); + if (entry.isSymbolicLink()) { + links.add(new DeferredArchiveLink(target, + containedRelativeLinkTarget(destination, target, entry.getLinkName()), entry.getMode())); + } else if (entry.isDirectory()) Files.createDirectories(target); else if (entry.isFile()) { Files.createDirectories(target.getParent()); Files.copy(input, target); if ((entry.getMode() & 0111) != 0) makeExecutable(target); - } + } else throw new IOException("Archive special entries are not allowed: " + entry.getName()); + } + } + for (DeferredArchiveLink link : links) { + if (!Files.isRegularFile(link.source(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Archive link target is not an extracted regular file: " + link.source()); } + Files.createDirectories(link.target().getParent()); + Files.copy(link.source(), link.target()); + if ((link.mode() & 0111) != 0) makeExecutable(link.target()); } } + private static Path containedRelativeLinkTarget(Path destination, Path link, String rawTarget) + throws IOException { + String normalized = rawTarget == null ? "" : rawTarget.replace('\\', '/'); + if (normalized.isBlank() || normalized.startsWith("/") || normalized.contains(":")) { + throw new IOException("Archive link target must be relative: " + rawTarget); + } + Path target = link.getParent().resolve(normalized).normalize(); + if (!target.startsWith(destination)) { + throw new IOException("Archive link target escapes its destination: " + rawTarget); + } + return target; + } + + private record DeferredArchiveLink(Path target, Path source, int mode) { } + private static Path archiveTarget(Path destination, String name) throws IOException { String normalized = name.replace('\\', '/'); int firstSlash = normalized.indexOf('/'); @@ -498,7 +555,7 @@ private static Path archiveTarget(Path destination, String name) throws IOExcept return target; } - private static void makeExecutable(Path file) throws IOException { + static void makeExecutable(Path file) throws IOException { try { Set permissions = EnumSet.copyOf(Files.getPosixFilePermissions(file)); permissions.add(PosixFilePermission.OWNER_EXECUTE); diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/SafeZipExtractor.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/SafeZipExtractor.java new file mode 100644 index 00000000000..ace092ddf64 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/SafeZipExtractor.java @@ -0,0 +1,155 @@ +package com.shaft.infrastructure; + +import org.apache.commons.compress.archivers.zip.UnixStat; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +/** Bounded ZIP extraction for verified setup archives. */ +final class SafeZipExtractor { + private static final int MAX_ENTRIES = 100_000; + private static final long MAX_ENTRY_BYTES = 512L * 1024 * 1024; + private static final long MAX_TOTAL_BYTES = 2L * 1024 * 1024 * 1024; + private static final long MAX_RATIO = 200; + + private SafeZipExtractor() { } + + static void extract(Path archive, Path destination) throws IOException { + Path root = destination.toAbsolutePath().normalize(); + VerifiedArtifactStore.requireUnlinkedAncestors(root); + Files.createDirectories(root); + ExtractionState state = new ExtractionState(); + try (ZipFile input = new ZipFile(archive.toFile())) { + var entriesInOrder = input.getEntriesInPhysicalOrder(); + while (entriesInOrder.hasMoreElements()) { + extractEntry(input, entriesInOrder.nextElement(), root, state); + } + } + } + + private static void extractEntry(ZipFile input, ZipArchiveEntry entry, Path root, + ExtractionState state) throws IOException { + state.countEntry(); + String name = validatedName(entry.getName()); + state.addName(name); + requireOrdinaryEntry(entry, name); + requireReadable(input, entry, name); + requireSafeDeclaredSize(entry, name); + Path target = resolvedTarget(root, name); + if (entry.isDirectory()) { + Files.createDirectories(target); + } else { + writeEntry(input, entry, target, state); + } + } + + private static void requireReadable(ZipFile input, ZipArchiveEntry entry, String name) throws IOException { + if (!input.canReadEntryData(entry)) { + throw new IOException("ZIP entry uses an unsupported encoding or feature: " + name); + } + } + + private static void requireSafeDeclaredSize(ZipArchiveEntry entry, String name) throws IOException { + long declared = entry.getSize(); + long compressed = entry.getCompressedSize(); + boolean excessiveRatio = declared >= 0 && compressed > 0 + && declared / Math.max(1, compressed) > MAX_RATIO; + if (declared > MAX_ENTRY_BYTES || excessiveRatio) { + throw new IOException("ZIP entry exceeds a safety bound: " + name); + } + } + + private static Path resolvedTarget(Path root, String name) throws IOException { + Path target = root.resolve(name).normalize(); + if (!target.startsWith(root)) throw new IOException("ZIP entry escapes its target: " + name); + return target; + } + + private static void writeEntry(ZipFile input, ZipArchiveEntry entry, Path target, + ExtractionState state) throws IOException { + Files.createDirectories(target.getParent()); + long written = 0; + byte[] buffer = new byte[64 * 1024]; + try (var entryInput = input.getInputStream(entry); + var output = Files.newOutputStream(target, java.nio.file.StandardOpenOption.CREATE_NEW)) { + for (int read; (read = entryInput.read(buffer)) >= 0;) { + written += read; + state.addBytes(read); + requireExpandedBounds(written, state.totalBytes); + output.write(buffer, 0, read); + } + } + } + + private static void requireExpandedBounds(long entryBytes, long totalBytes) throws IOException { + if (entryBytes > MAX_ENTRY_BYTES || totalBytes > MAX_TOTAL_BYTES) { + throw new IOException("ZIP expanded data exceeds a safety bound."); + } + } + + private static void requireOrdinaryEntry(ZipArchiveEntry entry, String name) throws IOException { + if (entry.isUnixSymlink()) throw new IOException("ZIP contains a symbolic link: " + name); + int mode = entry.getUnixMode(); + if (mode == 0) return; + int type = mode & 0170000; + if (type != UnixStat.FILE_FLAG && type != UnixStat.DIR_FLAG) { + throw new IOException("ZIP contains a link, device, or other special entry: " + name); + } + } + + private static String validatedName(String raw) throws IOException { + requireNonBlankName(raw); + String name = raw.replace('\\', '/'); + requireRelativeName(raw, name); + for (String part : name.split("/")) requireSafePart(raw, part); + return name; + } + + private static void requireNonBlankName(String raw) throws IOException { + if (raw == null || raw.isBlank() || raw.indexOf('\0') >= 0) { + throw new IOException("ZIP contains a blank or NUL path."); + } + } + + private static void requireRelativeName(String raw, String name) throws IOException { + if (name.startsWith("/") || name.startsWith("//") || name.matches("^[a-zA-Z]:.*") + || name.contains(":")) { + throw new IOException("ZIP contains an absolute, drive, UNC, or ADS path: " + raw); + } + } + + private static void requireSafePart(String raw, String part) throws IOException { + if (part.equals("..") || part.equals(".")) throw new IOException("ZIP contains traversal: " + raw); + int dot = part.indexOf('.'); + String base = dot < 0 ? part : part.substring(0, dot); + if (base.matches("(?i)con|prn|aux|nul|com[1-9]|lpt[1-9]")) { + throw new IOException("ZIP contains a reserved Windows path: " + raw); + } + } + + private static final class ExtractionState { + private final Set names = new HashSet<>(); + private int entries; + private long totalBytes; + + private void countEntry() throws IOException { + if (++entries > MAX_ENTRIES) throw new IOException("ZIP entry count exceeds the safety limit."); + } + + private void addName(String name) throws IOException { + if (!names.add(name.toLowerCase(Locale.ROOT))) { + throw new IOException("ZIP contains a duplicate path: " + name); + } + } + + private void addBytes(long bytes) { + totalBytes += bytes; + } + } +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/SystemAndroidRuntimeController.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/SystemAndroidRuntimeController.java new file mode 100644 index 00000000000..3907f3cf75f --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/SystemAndroidRuntimeController.java @@ -0,0 +1,106 @@ +package com.shaft.infrastructure; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +final class SystemAndroidRuntimeController implements AndroidRuntimeController { + @Override + public AndroidOwnedProcess start(String role, List command, Path workingDirectory, + Map environment, Set removedEnvironment, + Path log) throws IOException { + VerifiedArtifactStore.requireUnlinkedAncestors(workingDirectory); + VerifiedArtifactStore.requireUnlinkedAncestors(log); + Files.createDirectories(log.getParent()); + ProcessBuilder builder = new ProcessBuilder(command).directory(workingDirectory.toFile()) + .redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.appendTo(log.toFile())); + removedEnvironment.forEach(builder.environment()::remove); + builder.environment().putAll(environment); + Process process = builder.start(); + return new SystemOwnedProcess(process.toHandle(), command.getFirst()); + } + + @Override + public Optional find(long pid, Instant startInstant, String commandIdentity) + throws IOException { + Optional found = ProcessHandle.of(pid); + if (found.isEmpty() || !found.orElseThrow().isAlive()) return Optional.empty(); + ProcessHandle handle = found.orElseThrow(); + Instant actualStart = handle.info().startInstant().orElseThrow(() -> + new IOException("Owned process has no start-instant identity: " + pid)); + String actualCommand = identity(handle, ""); + if (!actualStart.equals(startInstant) || !actualCommand.equals(commandIdentity)) { + throw new IOException("Live process identity does not match the SHAFT runtime lease: " + pid); + } + return Optional.of(new SystemOwnedProcess(handle, actualCommand)); + } + + private static String identity(ProcessHandle handle, String fallback) { + String command = handle.info().command() + .map(path -> Path.of(path).toAbsolutePath().normalize().toString()) + .orElseGet(() -> Path.of(fallback).toAbsolutePath().normalize().toString()); + String[] arguments = handle.info().arguments().orElseGet(() -> new String[0]); + return command + '\0' + String.join("\0", arguments); + } + + private record SystemOwnedProcess(ProcessHandle handle, String launchCommand) implements AndroidOwnedProcess { + @Override public long pid() { return handle.pid(); } + @Override public Instant startInstant() { return handle.info().startInstant().orElse(Instant.EPOCH); } + @Override public String commandIdentity() { return identity(handle, launchCommand); } + @Override public boolean isAlive() { return handle.isAlive(); } + + @Override + public void stop(Duration timeout) throws IOException { + Instant deadline = Instant.now().plus(timeout); + List descendants = handle.descendants() + .sorted(Comparator.comparingInt(SystemAndroidRuntimeController::depth).reversed()).toList(); + descendants.forEach(ProcessHandle::destroy); + handle.destroy(); + for (ProcessHandle process : descendants) awaitOrForce(process, deadline); + awaitOrForce(handle, deadline); + } + + private static void awaitOrForce(ProcessHandle process, Instant deadline) throws IOException { + if (!process.isAlive()) return; + long millis = Math.max(1, Duration.between(Instant.now(), deadline).toMillis()); + try { + process.onExit().get(millis, TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.TimeoutException timeout) { + process.destroyForcibly(); + millis = Math.max(1, Duration.between(Instant.now(), deadline).toMillis()); + try { + process.onExit().get(millis, TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.TimeoutException stillAlive) { + throw new IOException("Owned process tree did not terminate before the shutdown deadline: " + + process.pid(), stillAlive); + } catch (java.util.concurrent.ExecutionException failure) { + throw new IOException("Failed while awaiting owned process termination.", failure); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while stopping the owned Android process tree.", interrupted); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while stopping the owned Android process tree.", interrupted); + } catch (java.util.concurrent.ExecutionException failure) { + throw new IOException("Failed while awaiting owned process termination.", failure); + } + } + } + + private static int depth(ProcessHandle handle) { + int depth = 0; + for (Optional parent = handle.parent(); parent.isPresent(); parent = parent.get().parent()) { + depth++; + } + return depth; + } +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/SystemAndroidRuntimeHealth.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/SystemAndroidRuntimeHealth.java new file mode 100644 index 00000000000..78701e27e96 --- /dev/null +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/SystemAndroidRuntimeHealth.java @@ -0,0 +1,124 @@ +package com.shaft.infrastructure; + +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class SystemAndroidRuntimeHealth implements AndroidRuntimeHealth { + private static final JsonMapper JSON = JsonMapper.builder().build(); + private final AndroidCommandRunner runner; + private final HttpClient http; + + SystemAndroidRuntimeHealth(ShaftCachePaths paths, SetupPlatform platform, SetupArchitecture architecture) { + this(AndroidCommandRunner.system(paths, platform, architecture), + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)) + .followRedirects(HttpClient.Redirect.NEVER).build()); + } + + SystemAndroidRuntimeHealth(AndroidCommandRunner runner, HttpClient http) { + this.runner = runner; + this.http = http; + } + + @Override + public void awaitEmulator(String serial, AndroidRuntimeLayout layout, Map environment, + Duration timeout) throws IOException { + Instant deadline = Instant.now().plus(timeout); + IOException last = new IOException("Android emulator did not become ready."); + while (Instant.now().isBefore(deadline)) { + try { + requireOutput(layout, environment, serial, List.of("get-state"), "device"); + requireOutput(layout, environment, serial, List.of("shell", "getprop", "sys.boot_completed"), "1"); + requireContains(layout, environment, serial, List.of("shell", "pm", "path", "android"), + "package:"); + requireAvdIdentity(layout, environment, serial); + return; + } catch (IOException notReady) { + last = notReady; + pause(deadline); + } + } + throw new IOException("Android emulator readiness timed out for " + serial + + "; device, boot completion, package manager, and AVD identity are required.", last); + } + + @Override + public void awaitAppium(URI endpoint, Duration timeout) throws IOException { + Instant deadline = Instant.now().plus(timeout); + IOException last = new IOException("Appium did not become ready."); + URI status = endpoint.resolve("status"); + while (Instant.now().isBefore(deadline)) { + try { + HttpRequest request = HttpRequest.newBuilder(status).timeout(Duration.ofSeconds(2)).GET().build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + String version = JSON.readTree(response.body()).path("value").path("build").path("version").asText(); + if (response.statusCode() == 200 && AndroidSetupPlanner.APPIUM_VERSION.equals(version)) return; + last = new IOException("Appium /status returned HTTP " + response.statusCode() + + " and version " + version + '.'); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for Appium readiness.", interrupted); + } catch (IOException notReady) { + last = notReady; + } catch (Exception notReady) { + last = new IOException(notReady); + } + pause(deadline); + } + throw new IOException("Appium readiness timed out at " + status + '.', last); + } + + private void requireOutput(AndroidRuntimeLayout layout, Map environment, String serial, + List arguments, String expected) throws IOException { + ReportingSetupService.ProcessResult result = adb(layout, environment, serial, arguments); + if (result.exitCode() != 0 || !result.output().trim().equals(expected)) { + throw new IOException("adb " + String.join(" ", arguments) + " is not ready."); + } + } + + private void requireContains(AndroidRuntimeLayout layout, Map environment, String serial, + List arguments, String expected) throws IOException { + ReportingSetupService.ProcessResult result = adb(layout, environment, serial, arguments); + if (result.exitCode() != 0 || !result.output().contains(expected)) { + throw new IOException("adb " + String.join(" ", arguments) + " is not ready."); + } + } + + private void requireAvdIdentity(AndroidRuntimeLayout layout, Map environment, + String serial) throws IOException { + List arguments = List.of("emu", "avd", "name"); + ReportingSetupService.ProcessResult result = adb(layout, environment, serial, arguments); + List response = result.output().lines().map(String::trim).filter(line -> !line.isEmpty()).toList(); + if (result.exitCode() != 0 || !response.equals(List.of(layout.avdName(), "OK"))) { + throw new IOException("adb emu avd name did not confirm the reviewed AVD identity."); + } + } + + private ReportingSetupService.ProcessResult adb(AndroidRuntimeLayout layout, Map environment, + String serial, List arguments) throws IOException { + java.util.ArrayList command = new java.util.ArrayList<>(List.of(layout.adb().toString(), + "-s", serial)); + command.addAll(arguments); + return runner.run(command, layout.sdkRoot(), environment, Set.of("ANDROID_SERIAL"), null, null, + Duration.ofSeconds(5)); + } + + private static void pause(Instant deadline) throws IOException { + long millis = Math.min(500, Math.max(1, Duration.between(Instant.now(), deadline).toMillis())); + try { + Thread.sleep(millis); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for Android runtime readiness.", interrupted); + } + } +} diff --git a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/VerifiedArtifactStore.java b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/VerifiedArtifactStore.java index 119de7c2acb..587f3dc091a 100644 --- a/shaft-infrastructure/src/main/java/com/shaft/infrastructure/VerifiedArtifactStore.java +++ b/shaft-infrastructure/src/main/java/com/shaft/infrastructure/VerifiedArtifactStore.java @@ -16,6 +16,7 @@ /** Download cache that publishes an artifact only after its approved SHA-256 matches. */ public final class VerifiedArtifactStore { static final long MAX_ARTIFACT_BYTES = 128L * 1024 * 1024; + private static final long MAX_ANDROID_SDK_BYTES = 256L * 1024 * 1024; private final Path downloads; public VerifiedArtifactStore(Path downloads) { @@ -45,7 +46,7 @@ public Path fetch(SetupAction action, boolean offline) throws IOException { Path quarantine = downloads.resolve(destination.getFileName() + ".quarantine"); try (InputStream input = open(action.source()); OutputStream output = Files.newOutputStream(temporary)) { - copyBounded(input, output, action.source()); + copyBounded(input, output, action.source(), maximumArtifactBytes(action.target())); String actual = digest(temporary); if (!expected.equals(actual)) { throw new IOException("SHA-256 mismatch for " + action.source() + ": expected " + expected @@ -92,13 +93,17 @@ interface MoveOperation { void move(Path source, Path destination) throws IOException; } - private static void copyBounded(InputStream input, OutputStream output, URI source) throws IOException { + static long maximumArtifactBytes(SetupTarget target) { + return target == SetupTarget.ANDROID_SDK ? MAX_ANDROID_SDK_BYTES : MAX_ARTIFACT_BYTES; + } + + private static void copyBounded(InputStream input, OutputStream output, URI source, long maximum) throws IOException { byte[] buffer = new byte[64 * 1024]; long total = 0; for (int read; (read = input.read(buffer)) >= 0;) { total += read; - if (total > MAX_ARTIFACT_BYTES) { - throw new IOException("Artifact exceeds the " + MAX_ARTIFACT_BYTES + " byte safety limit: " + source); + if (total > maximum) { + throw new IOException("Artifact exceeds the " + maximum + " byte safety limit: " + source); } output.write(buffer, 0, read); } diff --git a/shaft-infrastructure/src/main/resources/com/shaft/infrastructure/appium/package-lock.json b/shaft-infrastructure/src/main/resources/com/shaft/infrastructure/appium/package-lock.json new file mode 100644 index 00000000000..36864171079 --- /dev/null +++ b/shaft-infrastructure/src/main/resources/com/shaft/infrastructure/appium/package-lock.json @@ -0,0 +1,4348 @@ +{ + "name": "shaft-managed-appium", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shaft-managed-appium", + "version": "1.0.0", + "dependencies": { + "appium": "3.6.0", + "appium-inspector-plugin": "2026.7.1", + "appium-uiautomator2-driver": "8.2.2" + } + }, + "node_modules/@appium/base-driver": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@appium/base-driver/-/base-driver-10.7.2.tgz", + "integrity": "sha512-uYY84XZ0LFbXgNCePkVQXRshfSIbnajYSg1QBWYW3zvZodQyoXOInP5sylH8d/B0KM8b2Unn+VP9yoejX6v9CQ==", + "license": "Apache-2.0", + "dependencies": { + "@appium/support": "7.2.6", + "@appium/types": "1.6.0", + "async-lock": "1.4.1", + "asyncbox": "6.3.5", + "axios": "1.18.1", + "body-parser": "2.3.0", + "express": "5.2.1", + "fastest-levenshtein": "1.0.16", + "http-status-codes": "2.3.0", + "lru-cache": "11.5.2", + "method-override": "3.0.0", + "morgan": "1.11.0", + "path-to-regexp": "8.4.2", + "serve-favicon": "2.5.1", + "type-fest": "5.8.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + }, + "optionalDependencies": { + "spdy": "4.0.2" + } + }, + "node_modules/@appium/base-plugin": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@appium/base-plugin/-/base-plugin-3.3.3.tgz", + "integrity": "sha512-U9IkSWMNYnoOInLa8sIEGtdG/kcfK4Av1UxIAcLIIpooXg4EPkUzta268PMVIeW/LKXzrATfIOLs5MuPgIdUpQ==", + "license": "Apache-2.0", + "dependencies": { + "@appium/base-driver": "10.7.2", + "@appium/support": "7.2.6", + "@appium/types": "1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/@appium/css-locator-to-native": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@appium/css-locator-to-native/-/css-locator-to-native-1.0.6.tgz", + "integrity": "sha512-65UfoooziCETtDWZZ7Tb+MC8YEjJK5iKsGks4Cn/rJAwWFLjlRkW/pdK0Kr1Lf25Mo/JnB1bk7PEes/clvdMhA==", + "license": "Apache-2.0", + "dependencies": { + "css-selector-parser": "^3.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/@appium/docutils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@appium/docutils/-/docutils-2.5.2.tgz", + "integrity": "sha512-kseUbImhXFWka1dZ5FPthHKZtqCKzHPwuFheYimQeqMsXKQV9Nd3TJ5HMRUqonaIw6bfAW81TvzP6gx132cMlg==", + "license": "Apache-2.0", + "dependencies": { + "@appium/support": "7.2.6", + "consola": "3.4.2", + "diff": "9.0.0", + "lilconfig": "3.1.3", + "normalize-package-data": "8.0.0", + "teen_process": "4.1.9", + "type-fest": "5.8.0", + "yaml": "2.9.0", + "yargs": "18.0.0", + "yargs-parser": "22.0.0" + }, + "bin": { + "appium-docs": "bin/appium-docs.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/@appium/logger": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@appium/logger/-/logger-2.0.10.tgz", + "integrity": "sha512-RFR+9erki0Wqv3pElkbocoE1v2UsYUEvBybqEOTNl3dniA377vqKR9MiQl6W32XlruuIYZH8kkkdQJ3/0TEbyQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "11.5.2" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/@appium/schema": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@appium/schema/-/schema-1.3.0.tgz", + "integrity": "sha512-A/1zs8jUr9q/0Ft3dXSvWQN7JMo/bIcFv5o34fWMRtxZwtHsbl44t5PP5nirZOLZfD8G4oFvm0NSyvjs+sqAzg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "0.4.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/@appium/support": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/@appium/support/-/support-7.2.6.tgz", + "integrity": "sha512-WkfHSocC6bZGLHloVfOpTT2/300FPkrOu2PifOiFvfOCPTILIQsiqFIr/TUqeB72oJDarTdfX3doTmsAufXxoQ==", + "license": "Apache-2.0", + "dependencies": { + "@appium/logger": "2.0.10", + "@appium/types": "1.6.0", + "archiver": "8.0.0", + "asyncbox": "6.3.5", + "axios": "1.18.1", + "bluebird": "3.7.2", + "bplist-creator": "0.1.1", + "bplist-parser": "0.3.2", + "form-data": "4.0.6", + "glob": "13.0.6", + "jsftp": "2.1.3", + "klaw": "4.1.0", + "lockfile": "1.0.4", + "normalize-package-data": "8.0.0", + "plist": "4.0.0", + "pluralize": "8.0.0", + "sanitize-filename": "1.6.4", + "semver": "7.8.5", + "shell-quote": "1.10.0", + "teen_process": "4.1.9", + "type-fest": "5.8.0", + "uuid": "14.0.1", + "which": "6.0.1", + "yauzl": "3.4.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + }, + "optionalDependencies": { + "sharp": "0.35.3" + } + }, + "node_modules/@appium/types": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@appium/types/-/types-1.6.0.tgz", + "integrity": "sha512-7xHJ0AsW/B/uR3m62jKMhX3KvrtV6HcFzOLyCulkjrgwfLNlscVJKdmKul4U3Cj0rEDhCaUCcyagBdiSKDiAfw==", + "license": "Apache-2.0", + "dependencies": { + "@appium/schema": "1.3.0", + "type-fest": "5.8.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + }, + "peerDependencies": { + "@appium/logger": "^2.0.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@sidvind/better-ajv-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@sidvind/better-ajv-errors/-/better-ajv-errors-5.0.0.tgz", + "integrity": "sha512-FeI/V2KGtOaDX+r0akidCGYy79lVR4YnAqk1GFgZFuHADErCAEmtZL4+IdCAcDXHqfZsII3fs9DrfC1pIR+19w==", + "license": "Apache-2.0", + "dependencies": { + "kleur": "^4.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >= 24.0" + }, + "peerDependencies": { + "ajv": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.11.tgz", + "integrity": "sha512-tW8bcK3hsG0/uqSnNz6TK4BkcuZSezoU7DlnYssILmZDktPnSHHuDJJFM0AJv+13gz2r0iGdrj6qqKeUnxXEDg==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/appium": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/appium/-/appium-3.6.0.tgz", + "integrity": "sha512-qmsIj1VReEgW4tLZHX/1MXLCcV970gnTTgdQ1/aaWSisRJ6OeHbb582qJJmiyGl11wunx04il9YvTsy71J6dbw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@appium/base-driver": "10.7.2", + "@appium/base-plugin": "3.3.3", + "@appium/docutils": "2.5.2", + "@appium/logger": "2.0.10", + "@appium/schema": "1.3.0", + "@appium/support": "7.2.6", + "@appium/types": "1.6.0", + "@sidvind/better-ajv-errors": "5.0.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "argparse": "3.0.0", + "asyncbox": "6.3.5", + "axios": "1.18.1", + "lilconfig": "3.1.3", + "lru-cache": "11.5.2", + "ora": "5.4.1", + "semver": "7.8.5", + "teen_process": "4.1.9", + "type-fest": "5.8.0", + "winston": "3.19.0", + "ws": "8.21.1", + "yaml": "2.9.0" + }, + "bin": { + "appium": "index.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/appium-adb": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/appium-adb/-/appium-adb-16.0.3.tgz", + "integrity": "sha512-pUpD2IZR5HUmAIPxeBMA1issB2BroLk6VauzeV630QZmAYIlCowa+eVtHp47vDOM0DGLkFNWNYb5SX1EZ5T/8w==", + "license": "Apache-2.0", + "dependencies": { + "@appium/support": "^7.2.2", + "async-lock": "^1.0.0", + "asyncbox": "^6.0.1", + "ini": "^6.0.0", + "lru-cache": "^11.1.0", + "semver": "^7.0.0", + "teen_process": "^4.0.4" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/appium-android-driver": { + "version": "14.0.6", + "resolved": "https://registry.npmjs.org/appium-android-driver/-/appium-android-driver-14.0.6.tgz", + "integrity": "sha512-cG+R174hD0VdMk9+XiiHEHjixtkDSTGf3SD+JD4faltuH8D4TU7K86ZA/Jhmsvb5xchLR8OMDHFmti+ancztpw==", + "license": "Apache-2.0", + "dependencies": { + "@appium/support": "^7.2.5", + "appium-adb": "^16.0.0", + "appium-chromedriver": "^9.0.1", + "asyncbox": "^6.1.0", + "axios": "^1.16.0", + "dayjs": "^1.11.21", + "io.appium.settings": "^8.0.1", + "lru-cache": "^11.1.0", + "portscanner": "^2.2.0", + "semver": "^7.0.0", + "teen_process": "^4.0.7", + "ws": "^8.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + }, + "peerDependencies": { + "appium": "^3.0.0-rc.2" + } + }, + "node_modules/appium-chromedriver": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/appium-chromedriver/-/appium-chromedriver-9.0.9.tgz", + "integrity": "sha512-MahDwO0uAlVdVCoAmauShcp8LOqagRluKrxfv3jnz9T7oFfALDdnJtD096VJFK+0CYzjKj0e6CsMVn5wQTFCHA==", + "license": "Apache-2.0", + "dependencies": { + "@appium/base-driver": "^10.0.0-rc.2", + "@appium/support": "^7.2.2", + "@xmldom/xmldom": "^0.x", + "appium-adb": "^16.0.0", + "asyncbox": "^6.0.1", + "axios": "^1.16.0", + "compare-versions": "^6.0.0", + "semver": "^7.0.0", + "teen_process": "^4.0.4", + "xpath": "^0.x" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/appium-inspector-plugin": { + "version": "2026.7.1", + "resolved": "https://registry.npmjs.org/appium-inspector-plugin/-/appium-inspector-plugin-2026.7.1.tgz", + "integrity": "sha512-mOPTnRAzb2EWQosDDu0b3PjXRFQNbYcc6FKjDercXd5UWV7r2uNZQGGCegpCWltDrCKH4Hk8NA1r0nTpUQxlOQ==", + "license": "Apache-2.0", + "dependencies": { + "@appium/base-plugin": "3.3.3" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10.x" + }, + "peerDependencies": { + "appium": "^3.0.0-beta.0" + } + }, + "node_modules/appium-uiautomator2-driver": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/appium-uiautomator2-driver/-/appium-uiautomator2-driver-8.2.2.tgz", + "integrity": "sha512-mKleKNCbpd5SPMLGUu4BIEHoLDHjz3tHLWuHt+kFRBrSnXC6XJo3jIGw7HimhHQw4vrvF+wvVIMtWXMk2fbnzA==", + "license": "Apache-2.0", + "dependencies": { + "@appium/css-locator-to-native": "^1.0.1", + "appium-adb": "^16.0.0", + "appium-android-driver": "^14.0.2", + "appium-uiautomator2-server": "^10.3.3", + "asyncbox": "^6.0.1", + "axios": "^1.16.0", + "io.appium.settings": "^8.0.1", + "portscanner": "^2.2.0", + "teen_process": "^4.0.4" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + }, + "optionalDependencies": { + "sharp": "^0.x" + }, + "peerDependencies": { + "appium": "^3.0.0-rc.2" + } + }, + "node_modules/appium-uiautomator2-server": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/appium-uiautomator2-server/-/appium-uiautomator2-server-10.6.1.tgz", + "integrity": "sha512-KkUk4Y8YDG8tLPzS1rdMSir2ApY2Z8ywf8Y5QXnm6Uivgp41qNVKubTHcxO8OOcX22wHPXqAOqJf0urlFVyiXg==", + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "is-stream": "^4.0.0", + "lazystream": "^1.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/argparse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.0.tgz", + "integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "Python-2.0" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "license": "MIT" + }, + "node_modules/asyncbox": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-6.3.5.tgz", + "integrity": "sha512-did9yFgGiHq9CzFirtQtVZKJfKo68IQzhqWmUd3R0WdO1V5LkJUy2NPQZOJZHKvNWma+ETwoJEA8ALt9h8pN1w==", + "license": "Apache-2.0", + "dependencies": { + "p-limit": "^7.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.0.tgz", + "integrity": "sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.2.tgz", + "integrity": "sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bplist-creator": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.1.tgz", + "integrity": "sha512-Ese7052fdWrxp/vqSJkydgx/1MdBnNOCV2XVfbmdGWD2H6EYza+Q4pyYSuVSnCUD22hfI/BFI4jHaC3NLXLlJQ==", + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, + "node_modules/compress-commons": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/css-selector-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", + "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT", + "optional": true + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ftp-response-parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ftp-response-parser/-/ftp-response-parser-1.0.1.tgz", + "integrity": "sha512-++Ahlo2hs/IC7UVQzjcSAfeUpCwTTzs4uvG5XfGnsinIFkWUYF4xWwPd5qZuK8MJrmUIxFMuHcfqaosCDjvIWw==", + "dependencies": { + "readable-stream": "^1.0.31" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ftp-response-parser/node_modules/readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/ftp-response-parser/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "license": "MIT", + "optional": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT", + "optional": true + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "optional": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "license": "MIT", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/io.appium.settings": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/io.appium.settings/-/io.appium.settings-8.0.8.tgz", + "integrity": "sha512-FiMO8IvA1AkvbdenHyN57OPOByt9zZ5MY5iMRJHo09qrk2Njxg1rs4fC8ztyiCm1NtaNMDVKcF9u9Zm3JBkKyQ==", + "license": "Apache-2.0", + "dependencies": { + "@appium/logger": "^2.0.0-rc.1", + "asyncbox": "^6.0.1", + "semver": "^7.5.4", + "teen_process": "^4.0.4" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number-like": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/is-number-like/-/is-number-like-1.0.8.tgz", + "integrity": "sha512-6rZi3ezCyFcn5L71ywzz2bS5b2Igl1En3eTlZlvKjpz1n3IZLAYMbKYAIQgFmEu0GENg92ziU/faEOA/aixjbA==", + "license": "ISC", + "dependencies": { + "lodash.isfinite": "^3.3.2" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsftp": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/jsftp/-/jsftp-2.1.3.tgz", + "integrity": "sha512-r79EVB8jaNAZbq8hvanL8e8JGu2ZNr2bXdHC4ZdQhRImpSPpnWwm5DYVzQ5QxJmtGtKhNNuvqGgbNaFl604fEQ==", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "ftp-response-parser": "^1.0.1", + "once": "^1.4.0", + "parse-listing": "^1.1.3", + "stream-combiner": "^0.2.2", + "unorm": "^1.4.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsftp/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/klaw": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-4.1.0.tgz", + "integrity": "sha512-1zGZ9MF9H22UnkpVeuaGKOjfA2t6WrfdrJmGjy16ykcjnKQDmHVX+KI477rpbGevz/5FD4MC3xf1oxylBgcaQw==", + "license": "MIT", + "engines": { + "node": ">=14.14.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lockfile": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lockfile/-/lockfile-1.0.4.tgz", + "integrity": "sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==", + "license": "ISC", + "dependencies": { + "signal-exit": "^3.0.2" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.isfinite": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz", + "integrity": "sha512-7FGG40uhC8Mm633uKW1r58aElFlBlxCrg9JfSi3P6aYiWmfiWF0PgMd86ZUsxE5GwWPdHoS2+48bwTh2VPkIQA==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/method-override": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/method-override/-/method-override-3.0.0.tgz", + "integrity": "sha512-IJ2NNN/mSl9w3kzWB92rcdHpz+HjkxhDJWNDBqSlas+zQdP8wBiJzITPg08M/k2uVvMow7Sk41atndNtt/PHSA==", + "license": "MIT", + "dependencies": { + "debug": "3.1.0", + "methods": "~1.1.2", + "parseurl": "~1.3.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/method-override/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/method-override/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC", + "optional": true + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/morgan": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", + "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.4.1", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "license": "MIT", + "optional": true + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz", + "integrity": "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-listing": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/parse-listing/-/parse-listing-1.1.3.tgz", + "integrity": "sha512-a1p1i+9Qyc8pJNwdrSvW1g5TPxRH0sywVi6OzVvYHRo6xwF9bDWBxtH0KkxeOOvhUE8vAMtiSfsYQFOuK901eA==", + "engines": { + "node": ">=0.6.21" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/plist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-4.0.0.tgz", + "integrity": "sha512-4dOqNo0Y2NpfSf9q4+zr4bh7pzNWeckIam34Z0KYJhg8qtNNfh59VbD+Yna5SjwcxawVvLKx5w5FtuCijpEF4Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/portscanner": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/portscanner/-/portscanner-2.2.0.tgz", + "integrity": "sha512-IFroCz/59Lqa2uBvzK3bKDbDDIEaAY8XJ1jFxcLWTqosrsc32//P4VuSB2vZXoHiHqOmx8B5L5hnKOxL/7FlPw==", + "license": "MIT", + "dependencies": { + "async": "^2.6.0", + "is-number-like": "^1.0.3" + }, + "engines": { + "node": ">=0.4", + "npm": ">=1.0.0" + } + }, + "node_modules/portscanner/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/readdir-glob": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/yqnn" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "license": "MIT", + "optional": true + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-favicon": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.1.tgz", + "integrity": "sha512-JndLBslCLA/ebr7rS3d+/EKkzTsTi1jI2T9l+vHfAaGJ7A7NhtDpSZ0lx81HCNWnnE0yHncG+SSnVf9IMxOwXQ==", + "license": "MIT", + "dependencies": { + "etag": "~1.8.1", + "fresh": "~0.5.2", + "ms": "~2.1.3", + "parseurl": "~1.3.2", + "safe-buffer": "~5.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-favicon/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-favicon/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shell-quote": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "license": "CC0-1.0" + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/spdy-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/stream-combiner": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz", + "integrity": "sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "through": "~2.3.4" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teen_process": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/teen_process/-/teen_process-4.1.9.tgz", + "integrity": "sha512-MUOuIDBvjJGrm5AgrrWyGbpoKbpSJWvc7xf76Hac4ONDB2BbKzMNWjCAjFZ3sX+KuioReNVJdXTO4o4Bj/gRaQ==", + "license": "Apache-2.0", + "dependencies": { + "shell-quote": "^1.8.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unorm": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/unorm/-/unorm-1.6.0.tgz", + "integrity": "sha512-b2/KCUlYZUeA7JFUuRJZPUtr4gZvBh7tavtv4fvk4+KV9pfGiR6CQAQAWl49ZpR3ts2dk4FYkP7EIgDJoiOLDA==", + "license": "MIT or GPL-2.0", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "license": "MIT", + "optional": true, + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xpath": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", + "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-stream": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", + "license": "MIT", + "dependencies": { + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/shaft-infrastructure/src/main/resources/com/shaft/infrastructure/appium/package.json b/shaft-infrastructure/src/main/resources/com/shaft/infrastructure/appium/package.json new file mode 100644 index 00000000000..6b7971db844 --- /dev/null +++ b/shaft-infrastructure/src/main/resources/com/shaft/infrastructure/appium/package.json @@ -0,0 +1,10 @@ +{ + "name": "shaft-managed-appium", + "version": "1.0.0", + "private": true, + "dependencies": { + "appium": "3.6.0", + "appium-inspector-plugin": "2026.7.1", + "appium-uiautomator2-driver": "8.2.2" + } +} diff --git a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/AndroidLifecycleServiceTest.java b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/AndroidLifecycleServiceTest.java new file mode 100644 index 00000000000..3e10b71b01a --- /dev/null +++ b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/AndroidLifecycleServiceTest.java @@ -0,0 +1,349 @@ +package com.shaft.infrastructure; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AndroidLifecycleServiceTest { + @Test + void linkedRuntimeStateAncestorStartsNoProcess(@TempDir Path temp) throws Exception { + Fixture fixture = installed(temp); + Path state = fixture.paths().state(); + Path actualState = temp.resolve("actual-state"); + if (Files.exists(state)) Files.move(state, actualState); + else Files.createDirectories(actualState); + try { + Files.createSymbolicLink(state, actualState); + } catch (UnsupportedOperationException | IOException unsupported) { + org.junit.jupiter.api.Assumptions.abort("Directory links unavailable: " + unsupported.getMessage()); + } + RecordingRuntime runtime = new RecordingRuntime(); + AndroidLifecycleService lifecycle = new AndroidLifecycleService(fixture.paths(), SetupPlatform.LINUX, + SetupArchitecture.X64, fixture.request(), fixture.operations(), runtime, new RecordingHealth()); + + assertThrows(IOException.class, + () -> lifecycle.start(fixture.plan(), fixture.approval(), fixture.options())); + assertTrue(runtime.commands.isEmpty()); + } + + @Test + void fullyStaleLeaseIsReplacedButPartialLeaseIsRejectedWithoutKillingSurvivor(@TempDir Path temp) + throws Exception { + Fixture staleFixture = installed(temp.resolve("stale")); + RecordingRuntime staleRuntime = new RecordingRuntime(); + AndroidLifecycleService staleLifecycle = new AndroidLifecycleService(staleFixture.paths(), SetupPlatform.LINUX, + SetupArchitecture.X64, staleFixture.request(), staleFixture.operations(), staleRuntime, + new RecordingHealth()); + staleLifecycle.start(staleFixture.plan(), staleFixture.approval(), staleFixture.options()); + staleRuntime.setAllAlive(false); + + ManagedEnvironment replacement = staleLifecycle.start(staleFixture.plan(), staleFixture.approval(), + staleFixture.options()); + + assertEquals(4, staleRuntime.commands.size()); + replacement.close(); + + Fixture partialFixture = installed(temp.resolve("partial")); + RecordingRuntime partialRuntime = new RecordingRuntime(); + AndroidLifecycleService partialLifecycle = new AndroidLifecycleService(partialFixture.paths(), + SetupPlatform.LINUX, SetupArchitecture.X64, partialFixture.request(), partialFixture.operations(), + partialRuntime, new RecordingHealth()); + ManagedEnvironment partial = partialLifecycle.start(partialFixture.plan(), partialFixture.approval(), + partialFixture.options()); + partialRuntime.setAlive("appium", false); + + IOException failure = assertThrows(IOException.class, () -> partialLifecycle.start(partialFixture.plan(), + partialFixture.approval(), partialFixture.options())); + + assertTrue(failure.getMessage().contains("partially alive")); + assertEquals(2, partialRuntime.commands.size()); + assertTrue(partialRuntime.process("emulator").isAlive()); + assertTrue(partialRuntime.stopped.isEmpty()); + partialRuntime.setAlive("appium", true); + partial.close(); + } + + @Test + void shutdownSharesOneDeadlineAcrossAppiumAndEmulator(@TempDir Path temp) throws Exception { + Fixture fixture = installed(temp); + RecordingRuntime runtime = new RecordingRuntime(); + runtime.stopDelays.put("appium", Duration.ofMillis(100)); + AndroidLifecycleService lifecycle = new AndroidLifecycleService(fixture.paths(), SetupPlatform.LINUX, + SetupArchitecture.X64, fixture.request(), fixture.operations(), runtime, new RecordingHealth()); + SetupOptions options = fixture.options().withTimeouts(Duration.ofSeconds(5), Duration.ofMillis(500)); + ManagedEnvironment environment = lifecycle.start(fixture.plan(), fixture.approval(), options); + + environment.close(); + + assertTrue(runtime.stopTimeouts.get("emulator").compareTo(runtime.stopTimeouts.get("appium")) < 0); + } + + @Test + void leaseRoundTripReusesProcessesAndStopsOnlyAfterFinalRelease(@TempDir Path temp) throws Exception { + Fixture fixture = installed(temp); + RecordingRuntime runtime = new RecordingRuntime(); + RecordingHealth health = new RecordingHealth(); + AndroidLifecycleService firstLifecycle = new AndroidLifecycleService(fixture.paths(), SetupPlatform.LINUX, + SetupArchitecture.X64, fixture.request(), fixture.operations(), runtime, health); + ManagedEnvironment first = firstLifecycle.start(fixture.plan(), fixture.approval(), fixture.options()); + AndroidLifecycleService secondLifecycle = new AndroidLifecycleService(fixture.paths(), SetupPlatform.LINUX, + SetupArchitecture.X64, fixture.request(), fixture.operations(), runtime, health); + + ManagedEnvironment second = secondLifecycle.start(fixture.plan(), fixture.approval(), fixture.options()); + + assertEquals(2, runtime.commands.size()); + assertEquals(4, health.events.size()); + first.close(); + assertTrue(runtime.stopped.isEmpty()); + assertTrue(Files.isRegularFile(fixture.paths().state().resolve("mobile-android-runtime.json"))); + + second.close(); + + assertEquals(List.of("appium", "emulator"), runtime.stopped); + assertFalse(Files.exists(fixture.paths().state().resolve("mobile-android-runtime.json"))); + } + + @ParameterizedTest + @ValueSource(ints = {5554, 5555}) + void occupiedEmulatorPortStartsNoProcess(int occupiedPort, @TempDir Path temp) throws Exception { + Fixture fixture = installed(temp); + RecordingRuntime runtime = new RecordingRuntime(); + AndroidLifecycleService lifecycle = new AndroidLifecycleService(fixture.paths(), SetupPlatform.LINUX, + SetupArchitecture.X64, fixture.request(), fixture.operations(), runtime, new RecordingHealth()); + + try (ServerSocket occupied = new ServerSocket()) { + occupied.bind(new InetSocketAddress("127.0.0.1", occupiedPort)); + + IOException failure = assertThrows(IOException.class, + () -> lifecycle.start(fixture.plan(), fixture.approval(), fixture.options())); + + assertTrue(failure.getMessage().contains(Integer.toString(occupiedPort))); + assertTrue(runtime.commands.isEmpty()); + } + } + + @Test + void startsFullyBootedEmulatorBeforeLocalAppiumAndCloseStopsOwnedTree(@TempDir Path temp) throws Exception { + Fixture fixture = installed(temp); + RecordingRuntime runtime = new RecordingRuntime(); + RecordingHealth health = new RecordingHealth(); + AndroidLifecycleService lifecycle = new AndroidLifecycleService(fixture.paths(), SetupPlatform.LINUX, + SetupArchitecture.X64, fixture.request(), fixture.operations(), runtime, health); + + ManagedEnvironment environment = lifecycle.start(fixture.plan(), fixture.approval(), fixture.options()); + + assertEquals(2, runtime.commands.size()); + assertTrue(runtime.commands.get(0).contains("-avd")); + assertTrue(runtime.commands.get(0).contains(fixture.request().avdName())); + assertTrue(runtime.commands.get(1).getFirst().replace('\\', '/').endsWith("/linux-x64/bin/node")); + assertTrue(runtime.commands.get(1).containsAll(List.of("--address", "127.0.0.1", "--port", "4823"))); + assertFalse(runtime.commands.get(1).contains("--relaxed-security")); + assertEquals(List.of("emulator:emulator-5554", "appium:http://127.0.0.1:4823/"), health.events); + assertEquals(URI.create("http://127.0.0.1:4823/"), environment.endpoint().orElseThrow()); + assertEquals(fixture.plan().digest(), environment.receipt().planDigest()); + assertTrue(Files.isRegularFile(fixture.paths().state().resolve("mobile-android-runtime.json"))); + + environment.close(); + + assertEquals(List.of("appium", "emulator"), runtime.stopped); + assertFalse(Files.exists(fixture.paths().state().resolve("mobile-android-runtime.json"))); + } + + @Test + void appiumReadinessFailureCleansOnlyProcessesStartedByThisCallAndRetainsLogs(@TempDir Path temp) + throws Exception { + Fixture fixture = installed(temp); + RecordingRuntime runtime = new RecordingRuntime(); + RecordingHealth health = new RecordingHealth(); + health.failAppium = true; + AndroidLifecycleService lifecycle = new AndroidLifecycleService(fixture.paths(), SetupPlatform.LINUX, + SetupArchitecture.X64, fixture.request(), fixture.operations(), runtime, health); + + IOException failure = assertThrows(IOException.class, + () -> lifecycle.start(fixture.plan(), fixture.approval(), fixture.options())); + + assertTrue(failure.getMessage().contains("Appium readiness")); + assertEquals(List.of("appium", "emulator"), runtime.stopped); + assertFalse(Files.exists(fixture.paths().state().resolve("mobile-android-runtime.json"))); + assertTrue(Files.isDirectory(fixture.paths().state().resolve("logs"))); + } + + @Test + void missingCompatibleInstallReceiptStartsNoProcess(@TempDir Path temp) { + ShaftCachePaths paths = paths(temp); + AndroidSetupRequest request = request(); + ReadyOperations operations = new ReadyOperations(); + RecordingRuntime runtime = new RecordingRuntime(); + AndroidLifecycleService lifecycle = new AndroidLifecycleService(paths, SetupPlatform.LINUX, + SetupArchitecture.X64, request, operations, runtime, new RecordingHealth()); + SetupPlan plan = AndroidSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED, request); + SetupOptions options = SetupOptions.defaults(SetupProfile.MOBILE_ANDROID, paths) + .withMode(SetupMode.MANAGED); + + assertThrows(IOException.class, () -> lifecycle.start(plan, approval(plan), options)); + assertTrue(runtime.commands.isEmpty()); + assertFalse(Files.exists(paths.state())); + } + + private static Fixture installed(Path temp) throws Exception { + ShaftCachePaths paths = paths(temp); + AndroidSetupRequest request = request(); + ReadyOperations operations = new ReadyOperations(); + SetupPlan plan = AndroidSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED, request); + SetupOptions options = SetupOptions.defaults(SetupProfile.MOBILE_ANDROID, paths) + .withMode(SetupMode.MANAGED).withTimeouts(Duration.ofSeconds(5), Duration.ofSeconds(5)); + new AndroidSetupService(paths, SetupPlatform.LINUX, SetupArchitecture.X64, request, operations, false) + .install(plan, approval(plan)); + return new Fixture(paths, request, operations, plan, approval(plan), options); + } + + private static SetupApproval approval(SetupPlan plan) { + return new SetupApproval(plan.digest(), Instant.EPOCH, Set.of(AndroidSetupPlanner.ANDROID_SDK_LICENSE)); + } + + private static AndroidSetupRequest request() { + return new AndroidSetupRequest(36, "pixel_8", "google_apis", "x86_64", "runtime_avd", + 4096, 2, 4823); + } + + private static ShaftCachePaths paths(Path temp) { + Path cache = temp.resolve("cache").toAbsolutePath(); + Path data = temp.resolve("data").toAbsolutePath(); + return new ShaftCachePaths(cache, data, cache.resolve("downloads"), data.resolve("tools"), + data.resolve("state"), data.resolve("receipts")); + } + + private record Fixture(ShaftCachePaths paths, AndroidSetupRequest request, ReadyOperations operations, + SetupPlan plan, SetupApproval approval, SetupOptions options) { } + + private static final class ReadyOperations implements AndroidToolchainOperations { + @Override public void preflight(List actions, boolean offline) { } + @Override public void install(SetupAction action) { } + @Override public SetupStatus status(SetupAction action) { + return new SetupStatus(action.target(), SetupReadiness.READY, action.version(), "ready"); + } + } + + private static final class RecordingRuntime implements AndroidRuntimeController { + private final List> commands = new ArrayList<>(); + private final List stopped = new ArrayList<>(); + private final List processes = new ArrayList<>(); + private final Map stopDelays = new LinkedHashMap<>(); + private final Map stopTimeouts = new LinkedHashMap<>(); + private long nextPid = 100; + + @Override + public AndroidOwnedProcess start(String role, List command, Path workingDirectory, + Map environment, Set removedEnvironment, + Path log) throws IOException { + commands.add(List.copyOf(command)); + Files.createDirectories(log.getParent()); + Files.writeString(log, role + " log"); + FakeProcess process = new FakeProcess(role, nextPid++, stopped, stopDelays, stopTimeouts); + processes.add(process); + return process; + } + + @Override + public Optional find(long pid, Instant startInstant, String commandIdentity) { + return processes.stream() + .filter(process -> process.isAlive() && process.pid() == pid + && process.startInstant().equals(startInstant) + && process.commandIdentity().equals(commandIdentity)) + .map(process -> (AndroidOwnedProcess) process) + .findFirst(); + } + + private FakeProcess process(String role) { + return processes.stream().filter(process -> process.role.equals(role)).reduce((first, second) -> second) + .orElseThrow(); + } + + private void setAlive(String role, boolean alive) { + process(role).alive = alive; + } + + private void setAllAlive(boolean alive) { + processes.forEach(process -> process.alive = alive); + } + } + + private static final class FakeProcess implements AndroidOwnedProcess { + private final String role; + private final long pid; + private final List stopped; + private final Map stopDelays; + private final Map stopTimeouts; + private final Instant start = Instant.ofEpochSecond(100); + private boolean alive = true; + + private FakeProcess(String role, long pid, List stopped, Map stopDelays, + Map stopTimeouts) { + this.role = role; + this.pid = pid; + this.stopped = stopped; + this.stopDelays = stopDelays; + this.stopTimeouts = stopTimeouts; + } + + @Override public long pid() { return pid; } + @Override public Instant startInstant() { return start; } + @Override public String commandIdentity() { return role; } + @Override public boolean isAlive() { return alive; } + @Override + public void stop(Duration timeout) throws IOException { + stopTimeouts.put(role, timeout); + Duration delay = stopDelays.getOrDefault(role, Duration.ZERO); + try { + Thread.sleep(delay); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted test process stop.", interrupted); + } + if (alive) { + alive = false; + stopped.add(role); + } + } + } + + private static final class RecordingHealth implements AndroidRuntimeHealth { + private final List events = new ArrayList<>(); + private boolean failAppium; + + @Override + public void awaitEmulator(String serial, AndroidRuntimeLayout layout, Map environment, + Duration timeout) { + events.add("emulator:" + serial); + } + + @Override + public void awaitAppium(URI endpoint, Duration timeout) throws IOException { + events.add("appium:" + endpoint); + if (failAppium) throw new IOException("Appium readiness failed"); + } + } +} diff --git a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/AndroidSetupPlannerTest.java b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/AndroidSetupPlannerTest.java new file mode 100644 index 00000000000..aaa4c07f196 --- /dev/null +++ b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/AndroidSetupPlannerTest.java @@ -0,0 +1,137 @@ +package com.shaft.infrastructure; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AndroidSetupPlannerTest { + @Test + void defaultRequestPinsEveryHostArtifactAndAndroidPackage() { + assertSdkArchive(SetupPlatform.WINDOWS, SetupArchitecture.X64, + "commandlinetools-win-15859902_latest.zip", + "90ae805d20434428bffcb699c290860f19bb5f66a67e6b330067e3de801fb04a"); + assertSdkArchive(SetupPlatform.LINUX, SetupArchitecture.X64, + "commandlinetools-linux-15859902_latest.zip", + "4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583"); + assertSdkArchive(SetupPlatform.LINUX, SetupArchitecture.ARM64, + "commandlinetools-linux-15859902_latest.zip", + "4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583"); + assertSdkArchive(SetupPlatform.MACOS, SetupArchitecture.X64, + "commandlinetools-mac_x86_64-15859902_latest.zip", + "c5a6378ab5cf7e0d5701921405115befff13e9ff7417fb588389338f8bd050f3"); + assertSdkArchive(SetupPlatform.MACOS, SetupArchitecture.ARM64, + "commandlinetools-mac_arm64-15859902_latest.zip", + "835b62a26162b229b441d1f6d4680383815a270809eb33522c0d480fa5002c4e"); + assertThrows(IllegalArgumentException.class, () -> AndroidSetupPlanner.plan(SetupPlatform.WINDOWS, + SetupArchitecture.ARM64, SetupMode.MANAGED, AndroidSetupRequest.defaults())); + } + + @Test + void requestIsNormalizedAndBoundIntoTheReviewedPlan() { + AndroidSetupRequest defaults = AndroidSetupRequest.defaults(); + AndroidSetupRequest custom = new AndroidSetupRequest(36, "pixel_8", "google_apis", "x86_64", + "custom_avd", 6144, 4, 4823); + + SetupPlan defaultPlan = AndroidSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED, defaults); + SetupPlan customPlan = AndroidSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED, custom); + + assertNotEquals(defaultPlan.digest(), customPlan.digest()); + assertTrue(customPlan.actions().getLast().version().contains("avd=custom_avd")); + assertTrue(customPlan.actions().getLast().version().contains("ramMb=6144")); + assertTrue(customPlan.actions().getLast().version().contains("cores=4")); + assertTrue(customPlan.actions().getLast().version().contains("port=4823")); + assertEquals(custom, AndroidSetupRequest.fromPlan(customPlan)); + assertEquals(custom, AndroidSetupRequest.fromSelection(custom.toSelection())); + assertThrows(IllegalArgumentException.class, () -> new AndroidSetupRequest( + 36, "pixel_8", "google_apis", "x86_64", "../escape", 4096, 2, 4723)); + assertThrows(IllegalArgumentException.class, () -> new AndroidSetupRequest( + 35, "pixel_8", "google_apis", "x86_64", "safe", 4096, 2, 4723)); + assertThrows(IllegalArgumentException.class, () -> new AndroidSetupRequest( + 36, "pixel_8", "google_apis", "x86_64", "safe", 4096, 2, 5554)); + assertThrows(IllegalArgumentException.class, () -> new AndroidSetupRequest( + 36, "pixel_8", "google_apis", "x86_64", "safe", 4096, 2, 5555)); + SetupAction emulator = customPlan.actions().getLast(); + SetupAction injected = new SetupAction(emulator.target(), emulator.kind(), + emulator.version() + ",extra=unapproved", emulator.source(), emulator.checksum(), + emulator.dependencyLockChecksum(), emulator.privileged(), emulator.requiredLicenses()); + SetupPlan injectedPlan = SetupPlan.create(customPlan.profile(), customPlan.platform(), + customPlan.architecture(), customPlan.mode(), List.of(customPlan.actions().get(0), + customPlan.actions().get(1), customPlan.actions().get(2), customPlan.actions().get(3), + customPlan.actions().get(4), injected)); + assertThrows(IllegalArgumentException.class, () -> AndroidSetupRequest.fromPlan(injectedPlan)); + } + + @Test + void coordinatorAcceptsTheTypedAndroidRequestWithoutWeakeningGenericProviders( + @org.junit.jupiter.api.io.TempDir java.nio.file.Path temp) { + ShaftCachePaths paths = new ShaftCachePaths(temp.resolve("cache").toAbsolutePath(), + temp.resolve("data").toAbsolutePath(), temp.resolve("cache/downloads").toAbsolutePath(), + temp.resolve("data/tools").toAbsolutePath(), temp.resolve("data/state").toAbsolutePath(), + temp.resolve("data/receipts").toAbsolutePath()); + AndroidSetupRequest request = new AndroidSetupRequest(36, "pixel_8", "google_apis", "x86_64", + "typed_avd", 8192, 6, 4923); + SetupOptions options = SetupOptions.defaults(SetupProfile.MOBILE_ANDROID, paths) + .withMode(SetupMode.MANAGED); + + SetupPlan plan = InfrastructureSetupService.builtIn(SetupPlatform.LINUX, SetupArchitecture.X64) + .plan(options, request); + + assertEquals(request, AndroidSetupRequest.fromPlan(plan)); + assertThrows(IllegalArgumentException.class, () -> InfrastructureSetupService + .builtIn(SetupPlatform.LINUX, SetupArchitecture.X64) + .plan(SetupOptions.defaults(SetupProfile.REPORTING, paths), request)); + } + + @Test + void bundledAppiumLockMatchesEveryPlannedPackage() throws Exception { + byte[] packageJson; + byte[] lock; + try (var packageInput = getClass().getResourceAsStream("/com/shaft/infrastructure/appium/package.json"); + var lockInput = getClass().getResourceAsStream("/com/shaft/infrastructure/appium/package-lock.json")) { + packageJson = java.util.Objects.requireNonNull(packageInput).readAllBytes(); + lock = java.util.Objects.requireNonNull(lockInput).readAllBytes(); + } + String manifest = new String(packageJson, StandardCharsets.UTF_8); + String canonicalLock = new String(lock, StandardCharsets.UTF_8) + .replace("\r\n", "\n").replace('\r', '\n'); + assertTrue(manifest.contains("\"appium\": \"3.6.0\"")); + assertTrue(manifest.contains("\"appium-inspector-plugin\": \"2026.7.1\"")); + assertTrue(manifest.contains("\"appium-uiautomator2-driver\": \"8.2.2\"")); + assertTrue(canonicalLock.contains("\"node_modules/appium\"")); + assertTrue(canonicalLock.contains("\"node_modules/appium-inspector-plugin\"")); + assertTrue(canonicalLock.contains("\"node_modules/appium-uiautomator2-driver\"")); + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(canonicalLock.getBytes(StandardCharsets.UTF_8))); + assertEquals(AndroidSetupPlanner.APPIUM_LOCK_SHA256, digest); + } + + private static void assertSdkArchive(SetupPlatform platform, SetupArchitecture architecture, + String fileName, String checksum) { + SetupPlan plan = AndroidSetupPlanner.plan(platform, architecture, SetupMode.MANAGED, + AndroidSetupRequest.defaults()); + SetupAction sdk = plan.actions().stream().filter(action -> action.target() == SetupTarget.ANDROID_SDK) + .findFirst().orElseThrow(); + assertTrue(sdk.source().toString().endsWith(fileName)); + assertEquals("sha256:" + checksum, sdk.checksum()); + assertTrue(sdk.version().contains("platform-tools@37.0.1")); + assertTrue(sdk.version().contains("emulator@37.1.11")); + assertTrue(sdk.version().contains("platforms;android-36@2")); + assertTrue(sdk.version().contains("build-tools;36.0.0@36.0.0")); + assertTrue(sdk.version().contains("system-images;android-36;google_apis;")); + assertTrue(sdk.version().contains("@7")); + assertTrue(sdk.requiredLicenses().contains(AndroidSetupPlanner.ANDROID_SDK_LICENSE)); + List appiumActions = plan.actions().subList(1, 4); + assertTrue(appiumActions.stream().allMatch(action -> action.dependencyLockChecksum() + .equals("sha256:" + AndroidSetupPlanner.APPIUM_LOCK_SHA256))); + } +} diff --git a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/AndroidSetupServiceTest.java b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/AndroidSetupServiceTest.java new file mode 100644 index 00000000000..be7ddb27ea4 --- /dev/null +++ b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/AndroidSetupServiceTest.java @@ -0,0 +1,706 @@ +package com.shaft.infrastructure; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AndroidSetupServiceTest { + @Test + @EnabledOnOs(OS.LINUX) + void extractedLinuxSdkCommandsAreExecutableBeforeFirstInvocation(@TempDir Path temp) throws Exception { + ShaftCachePaths paths = paths(temp); + Path commandTools = createLinuxCommandToolsZip(temp.resolve("command-tools.zip")); + AndroidSetupRequest request = AndroidSetupRequest.defaults(); + AndroidCommandRunner runner = (command, workingDirectory, environment, removed, input, log, timeout) -> { + Path executable = Path.of(command.getFirst()); + assertTrue(Files.isExecutable(executable), "SDK command must be executable: " + executable); + if (command.contains("--sdk_root=" + workingDirectory)) createLinuxSdkFixture(workingDirectory); + return new ReportingSetupService.ProcessResult(0, + command.contains("--list_installed") ? exactInstalledPackages() : "fixture"); + }; + DefaultAndroidToolchainOperations operations = new DefaultAndroidToolchainOperations(paths, + SetupPlatform.LINUX, SetupArchitecture.X64, request, + action -> commandTools, runner, false); + SetupAction sdkAction = AndroidSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED, request).actions().get(4); + + operations.install(sdkAction); + + assertEquals(SetupReadiness.READY, operations.status(sdkAction).readiness()); + } + + @Test + void separateJvmInstallersConvergeOnOneReceiptAndPublicationSet(@TempDir Path temp) throws Exception { + Path javaExecutable = Path.of(System.getProperty("java.home"), "bin", + SetupPlatform.current() == SetupPlatform.WINDOWS ? "java.exe" : "java").toAbsolutePath(); + Path gate = temp.resolve("start.gate"); + List children = new ArrayList<>(); + try { + for (int index = 0; index < 2; index++) { + Path output = temp.resolve("child-" + index + ".log"); + children.add(new ProcessBuilder(javaExecutable.toString(), "-Xmx64m", "-XX:+UseSerialGC", "-cp", + System.getProperty("java.class.path"), SeparateProcessInstaller.class.getName(), + temp.toString(), gate.toString(), temp.resolve("result-" + index).toString()) + .redirectErrorStream(true).redirectOutput(output.toFile()).start()); + } + Files.writeString(gate, "go"); + + for (int index = 0; index < children.size(); index++) { + Process child = children.get(index); + assertTrue(child.waitFor(15, java.util.concurrent.TimeUnit.SECONDS), + "Timed out: " + Files.readString(temp.resolve("child-" + index + ".log"))); + assertEquals(0, child.exitValue(), Files.readString(temp.resolve("child-" + index + ".log"))); + } + + try (var markers = Files.list(temp.resolve("data/markers"))) { + assertEquals(6, markers.count()); + } + String first = Files.readString(temp.resolve("result-0")); + assertEquals(first, Files.readString(temp.resolve("result-1"))); + assertTrue(Files.isRegularFile(temp.resolve("data/receipts/mobile-android.json"))); + } finally { + children.stream().filter(Process::isAlive).forEach(Process::destroyForcibly); + } + } + + @Test + void threeJvmWaitersConvergeWithoutOverlappingMutation(@TempDir Path temp) throws Exception { + ShaftCachePaths paths = paths(temp); + AndroidSetupRequest request = AndroidSetupRequest.defaults(); + SetupPlan plan = AndroidSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED, request); + SetupApproval approval = new SetupApproval(plan.digest(), Instant.EPOCH, + Set.of(AndroidSetupPlanner.ANDROID_SDK_LICENSE)); + ConvergingOperations operations = new ConvergingOperations(); + java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(3); + java.util.concurrent.CountDownLatch callersReady = new java.util.concurrent.CountDownLatch(3); + java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1); + List> futures = new ArrayList<>(); + try { + for (int index = 0; index < 3; index++) { + futures.add(executor.submit(() -> { + callersReady.countDown(); + start.await(); + return new AndroidSetupService(paths, SetupPlatform.LINUX, SetupArchitecture.X64, + request, operations, false).install(plan, approval); + })); + } + assertTrue(callersReady.await(5, java.util.concurrent.TimeUnit.SECONDS)); + start.countDown(); + + List receipts = new ArrayList<>(); + for (java.util.concurrent.Future future : futures) { + receipts.add(future.get(10, java.util.concurrent.TimeUnit.SECONDS)); + } + + assertTrue(receipts.stream().allMatch(receipt -> receipt.planDigest().equals(plan.digest()) + && receipt.completedActions().equals(plan.actions()))); + assertEquals(plan.actions().size(), operations.firstPublications.get()); + assertEquals(1, operations.maximumActiveMutations.get()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void retryRecoversAvdDirectoryPublishedBeforePointerFailure(@TempDir Path temp) throws Exception { + ShaftCachePaths paths = paths(temp); + AndroidSetupRequest request = AndroidSetupRequest.defaults().resolve(SetupArchitecture.X64); + Path sdk = paths.tools().resolve("android-sdk/15859902-api36-x86_64"); + createSdkFixture(sdk); + for (Path tool : List.of(sdk.resolve("cmdline-tools/latest/bin/sdkmanager.bat"), + sdk.resolve("cmdline-tools/latest/bin/avdmanager.bat"))) { + Files.createDirectories(tool.getParent()); + Files.writeString(tool, "fixture"); + } + AndroidCommandRunner runner = (command, workingDirectory, environment, removed, input, log, timeout) -> { + if (command.stream().anyMatch(part -> part.contains("avdmanager"))) { + Path staging = Path.of(command.get(command.indexOf("--path") + 1)); + Files.createDirectories(staging); + Files.writeString(staging.resolve("config.ini"), exactAvdConfig(request)); + } + return new ReportingSetupService.ProcessResult(0, command.contains("--list_installed") + ? exactInstalledPackages() : "fixture"); + }; + DefaultAndroidToolchainOperations operations = new DefaultAndroidToolchainOperations(paths, + SetupPlatform.WINDOWS, SetupArchitecture.X64, request, + action -> { throw new AssertionError("AVD recovery must not fetch."); }, runner, false); + SetupAction avdAction = AndroidSetupPlanner.plan(SetupPlatform.WINDOWS, SetupArchitecture.X64, + SetupMode.MANAGED, request).actions().getLast(); + Path avdHome = paths.tools().resolve("android-avd"); + Path pointer = avdHome.resolve(request.avdName() + ".ini"); + Files.createDirectories(pointer); + + assertThrows(IOException.class, () -> operations.install(avdAction)); + assertTrue(Files.isDirectory(avdHome.resolve(request.avdName() + ".avd"))); + Files.delete(pointer); + + operations.install(avdAction); + + assertTrue(Files.isRegularFile(pointer)); + assertEquals(SetupReadiness.READY, operations.status(avdAction).readiness()); + } + + @Test + void sdkStatusRejectsWrongInstalledPackageRevision(@TempDir Path temp) throws Exception { + ShaftCachePaths paths = paths(temp); + Path sdk = paths.tools().resolve("android-sdk/15859902-api36-x86_64"); + createSdkFixture(sdk); + for (Path tool : List.of(sdk.resolve("cmdline-tools/latest/bin/sdkmanager.bat"), + sdk.resolve("cmdline-tools/latest/bin/avdmanager.bat"))) { + Files.createDirectories(tool.getParent()); + Files.writeString(tool, "fixture"); + } + AndroidCommandRunner runner = (command, workingDirectory, environment, removed, input, log, timeout) -> + new ReportingSetupService.ProcessResult(0, + "Installed packages:\nPath | Version | Description\n" + + "platform-tools | 37.0.1 | fixture\n" + + "emulator | 37.1.11 | fixture\n" + + "platforms;android-36 | 2 | fixture\n" + + "build-tools;36.0.0 | 35.0.0 | fixture\n" + + "system-images;android-36;google_apis;x86_64 | 7 | fixture\n"); + DefaultAndroidToolchainOperations operations = new DefaultAndroidToolchainOperations(paths, + SetupPlatform.WINDOWS, SetupArchitecture.X64, AndroidSetupRequest.defaults(), + action -> { throw new AssertionError("Status must not fetch."); }, runner, false); + SetupAction sdkAction = AndroidSetupPlanner.plan(SetupPlatform.WINDOWS, SetupArchitecture.X64, + SetupMode.MANAGED, AndroidSetupRequest.defaults()).actions().get(4); + + SetupStatus status = operations.status(sdkAction); + + assertEquals(SetupReadiness.DEGRADED, status.readiness()); + assertTrue(status.detail().contains("build-tools;36.0.0")); + assertTrue(status.detail().contains("35.0.0")); + } + + @Test + void avdStatusUsesOfficialAccelerationProbeAndReportsMissingHostSupport(@TempDir Path temp) throws Exception { + ShaftCachePaths paths = paths(temp); + AndroidSetupRequest request = AndroidSetupRequest.defaults().resolve(SetupArchitecture.X64); + Path sdk = paths.tools().resolve("android-sdk/15859902-api36-x86_64"); + createSdkFixture(sdk); + Path avdHome = paths.tools().resolve("android-avd"); + Path avd = avdHome.resolve(request.avdName() + ".avd"); + Files.createDirectories(avd); + Files.writeString(avd.resolve("config.ini"), exactAvdConfig(request)); + Files.writeString(avd.resolve("shaft-request.properties"), requestMetadata(request)); + Files.writeString(avdHome.resolve(request.avdName() + ".ini"), + "path=" + avd.toAbsolutePath().normalize() + System.lineSeparator() + + "path.rel=avd/" + request.avdName() + ".avd" + System.lineSeparator() + + "target=android-" + request.apiLevel() + System.lineSeparator()); + List> commands = new ArrayList<>(); + AndroidCommandRunner runner = (command, workingDirectory, environment, removed, input, log, timeout) -> { + commands.add(List.copyOf(command)); + if (command.contains("-accel-check")) { + return new ReportingSetupService.ProcessResult(1, "accel: host virtualization is unavailable"); + } + return new ReportingSetupService.ProcessResult(0, "fixture"); + }; + DefaultAndroidToolchainOperations operations = new DefaultAndroidToolchainOperations(paths, + SetupPlatform.WINDOWS, SetupArchitecture.X64, request, + action -> { throw new AssertionError("Status must not fetch."); }, runner, false); + SetupAction avdAction = AndroidSetupPlanner.plan(SetupPlatform.WINDOWS, SetupArchitecture.X64, + SetupMode.MANAGED, request).actions().getLast(); + + SetupStatus status = operations.status(avdAction); + + assertEquals(SetupReadiness.DEGRADED, status.readiness()); + assertTrue(status.detail().contains("host virtualization is unavailable")); + assertTrue(commands.stream().anyMatch(command -> command.equals(List.of( + sdk.resolve("emulator/emulator.exe").toString(), "-accel-check")))); + } + + @Test + void avdStatusRejectsConfigPointingAtDifferentSystemImage(@TempDir Path temp) throws Exception { + ShaftCachePaths paths = paths(temp); + AndroidSetupRequest request = AndroidSetupRequest.defaults().resolve(SetupArchitecture.X64); + Path sdk = paths.tools().resolve("android-sdk/15859902-api36-x86_64"); + createSdkFixture(sdk); + Path avdHome = paths.tools().resolve("android-avd"); + Path avd = avdHome.resolve(request.avdName() + ".avd"); + Files.createDirectories(avd); + Files.writeString(avd.resolve("config.ini"), + "image.sysdir.1=system-images/android-35/default/x86_64/\n"); + Files.writeString(avd.resolve("shaft-request.properties"), requestMetadata(request)); + Files.writeString(avdHome.resolve(request.avdName() + ".ini"), + "path=" + avd.toAbsolutePath().normalize() + System.lineSeparator() + + "path.rel=avd/" + request.avdName() + ".avd" + System.lineSeparator() + + "target=android-" + request.apiLevel() + System.lineSeparator()); + AndroidCommandRunner runner = (command, workingDirectory, environment, removed, input, log, timeout) -> + new ReportingSetupService.ProcessResult(0, "accel: available"); + DefaultAndroidToolchainOperations operations = new DefaultAndroidToolchainOperations(paths, + SetupPlatform.WINDOWS, SetupArchitecture.X64, request, + action -> { throw new AssertionError("Status must not fetch."); }, runner, false); + SetupAction avdAction = AndroidSetupPlanner.plan(SetupPlatform.WINDOWS, SetupArchitecture.X64, + SetupMode.MANAGED, request).actions().getLast(); + + SetupStatus status = operations.status(avdAction); + + assertEquals(SetupReadiness.DEGRADED, status.readiness()); + assertTrue(status.detail().contains("system image")); + } + + @Test + void wrongRegisteredAppiumExtensionFailsBeforePublication(@TempDir Path temp) throws Exception { + ShaftCachePaths paths = paths(temp); + Path nodeArchive = ReportingSetupServiceTest.createNodeZip(temp.resolve("node.zip")); + Path npmArchive = Files.writeString(temp.resolve("package.tgz"), "package"); + AndroidSetupRequest request = AndroidSetupRequest.defaults(); + AndroidCommandRunner runner = (command, workingDirectory, environment, removed, input, log, timeout) -> { + int prefix = command.indexOf("--prefix"); + if (command.contains("ci") && prefix >= 0) createAppiumFixture(Path.of(command.get(prefix + 1))); + String output; + if (command.contains("plugin")) { + output = "{\"inspector\":{\"pkgName\":\"appium-inspector-plugin\",\"version\":\"0.0.0\"}}"; + } else if (command.contains("driver")) { + output = "{\"uiautomator2\":{\"pkgName\":\"appium-uiautomator2-driver\",\"version\":\"8.2.2\"}}"; + } else { + output = command.stream().anyMatch(part -> part.endsWith("appium/index.js") + || part.endsWith("appium\\index.js")) ? "3.6.0" : "v24.19.0"; + } + return new ReportingSetupService.ProcessResult(0, output); + }; + DefaultAndroidToolchainOperations operations = new DefaultAndroidToolchainOperations(paths, + SetupPlatform.WINDOWS, SetupArchitecture.X64, request, + action -> action.target() == SetupTarget.NODE ? nodeArchive : npmArchive, runner, false); + AndroidSetupService service = new AndroidSetupService(paths, SetupPlatform.WINDOWS, + SetupArchitecture.X64, request, operations, false); + SetupPlan plan = AndroidSetupPlanner.plan(SetupPlatform.WINDOWS, SetupArchitecture.X64, + SetupMode.MANAGED, request); + + SetupExecutionException failure = assertThrows(SetupExecutionException.class, () -> service.install(plan, + new SetupApproval(plan.digest(), Instant.EPOCH, + Set.of(AndroidSetupPlanner.ANDROID_SDK_LICENSE)))); + + assertTrue(failure.getCause().getCause().getMessage().contains("not registered at the approved version")); + assertFalse(Files.exists(paths.tools().resolve("appium/3.6.0"))); + assertFalse(Files.exists(paths.receipts().resolve("mobile-android.json"))); + } + + @Test + void missingAndroidLicenseIsRejectedBeforePreflightOrFilesystemMutation(@TempDir Path temp) { + ShaftCachePaths paths = paths(temp); + RecordingOperations operations = new RecordingOperations(); + AndroidSetupService service = new AndroidSetupService(paths, SetupPlatform.LINUX, + SetupArchitecture.X64, AndroidSetupRequest.defaults(), operations, false); + SetupPlan plan = AndroidSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED, AndroidSetupRequest.defaults()); + + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> service.install(plan, + new SetupApproval(plan.digest(), Instant.EPOCH, Set.of()))); + assertTrue(failure.getMessage().contains(AndroidSetupPlanner.ANDROID_SDK_LICENSE)); + + assertEquals(0, operations.preflights); + assertTrue(operations.installs.isEmpty()); + assertFalse(Files.exists(paths.cacheRoot())); + assertFalse(Files.exists(paths.dataRoot())); + } + + @Test + void exactApprovedPlanInstallsInOrderAndWritesOneCompleteReceipt(@TempDir Path temp) throws Exception { + ShaftCachePaths paths = paths(temp); + RecordingOperations operations = new RecordingOperations(); + AndroidSetupRequest request = new AndroidSetupRequest(36, "pixel_8", "google_apis", "x86_64", + "approved_avd", 6144, 4, 4823); + AndroidSetupService service = new AndroidSetupService(paths, SetupPlatform.LINUX, + SetupArchitecture.X64, request, operations, false); + SetupPlan plan = AndroidSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED, request); + + SetupReceipt receipt = service.install(plan, new SetupApproval(plan.digest(), Instant.EPOCH, + Set.of(AndroidSetupPlanner.ANDROID_SDK_LICENSE))); + + assertEquals(2, operations.preflights, "Preflight must run before mutation and again under the lock."); + assertEquals(plan.actions(), operations.installs); + assertEquals(plan.actions(), receipt.completedActions()); + assertTrue(Files.isRegularFile(paths.receipts().resolve("mobile-android.json"))); + assertEquals(SetupReadiness.READY, service.status().readiness()); + } + + @Test + void failedActionExposesPartialReceiptAndNeverWritesFinalReceipt(@TempDir Path temp) { + ShaftCachePaths paths = paths(temp); + RecordingOperations operations = new RecordingOperations(); + operations.failTarget = SetupTarget.ANDROID_SDK; + AndroidSetupService service = new AndroidSetupService(paths, SetupPlatform.LINUX, + SetupArchitecture.X64, AndroidSetupRequest.defaults(), operations, false); + SetupPlan plan = AndroidSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED, AndroidSetupRequest.defaults()); + + SetupExecutionException failure = assertThrows(SetupExecutionException.class, () -> service.install(plan, + new SetupApproval(plan.digest(), Instant.EPOCH, + Set.of(AndroidSetupPlanner.ANDROID_SDK_LICENSE)))); + + assertEquals(SetupTarget.ANDROID_SDK, failure.failedAction().target()); + assertEquals(plan.actions().subList(0, 4), failure.partialReceipt().completedActions()); + assertFalse(Files.exists(paths.receipts().resolve("mobile-android.json"))); + } + + @Test + void readyFilesWithoutACompatibleReceiptAreDegraded(@TempDir Path temp) { + RecordingOperations operations = new RecordingOperations(); + SetupPlan plan = AndroidSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED, AndroidSetupRequest.defaults()); + plan.actions().forEach(action -> operations.readiness.put(action.target(), SetupReadiness.READY)); + AndroidSetupService service = new AndroidSetupService(paths(temp), SetupPlatform.LINUX, + SetupArchitecture.X64, AndroidSetupRequest.defaults(), operations, false); + + assertEquals(SetupReadiness.DEGRADED, service.status().readiness()); + } + + @Test + void partialOfflineInstallationStartsNoProbeOrMutation(@TempDir Path temp) throws Exception { + ShaftCachePaths paths = paths(temp); + Path node = paths.tools().resolve("node/24.19.0/windows-x64/node.exe"); + Files.createDirectories(node.getParent()); + Files.writeString(node, "partial node"); + java.util.concurrent.atomic.AtomicInteger processes = new java.util.concurrent.atomic.AtomicInteger(); + AndroidCommandRunner runner = (command, workingDirectory, environment, removed, input, log, timeout) -> { + processes.incrementAndGet(); + throw new AssertionError("Offline partial preflight must not execute tools."); + }; + DefaultAndroidToolchainOperations operations = new DefaultAndroidToolchainOperations(paths, + SetupPlatform.WINDOWS, SetupArchitecture.X64, AndroidSetupRequest.defaults(), + action -> { throw new AssertionError("Offline partial preflight must not fetch."); }, runner, true); + AndroidSetupService service = new AndroidSetupService(paths, SetupPlatform.WINDOWS, + SetupArchitecture.X64, AndroidSetupRequest.defaults(), operations, true); + SetupPlan plan = AndroidSetupPlanner.plan(SetupPlatform.WINDOWS, SetupArchitecture.X64, + SetupMode.MANAGED, AndroidSetupRequest.defaults()); + + IOException failure = assertThrows(IOException.class, () -> service.install(plan, + new SetupApproval(plan.digest(), Instant.EPOCH, + Set.of(AndroidSetupPlanner.ANDROID_SDK_LICENSE)))); + + assertTrue(failure.getMessage().contains("complete verified installation")); + assertEquals(0, processes.get()); + assertFalse(Files.exists(paths.state())); + assertFalse(Files.exists(paths.receipts())); + } + + @Test + void realOperationsInstallAndVerifyAppiumBuildToolsAndAvdWithoutGlobalTools(@TempDir Path temp) + throws Exception { + ShaftCachePaths paths = paths(temp); + Path nodeArchive = ReportingSetupServiceTest.createNodeZip(temp.resolve("node.zip")); + Path commandTools = createCommandToolsZip(temp.resolve("command-tools.zip")); + Path npmArchive = Files.writeString(temp.resolve("package.tgz"), "package"); + AndroidSetupRequest request = new AndroidSetupRequest(36, "pixel_8", "google_apis", "x86_64", + "integration_avd", 4096, 2, 4723); + FullInstallRunner runner = new FullInstallRunner(); + ReportingSetupService.ArtifactFetcher fetcher = fixtureFetcher(nodeArchive, commandTools, npmArchive); + DefaultAndroidToolchainOperations operations = new DefaultAndroidToolchainOperations(paths, + SetupPlatform.WINDOWS, SetupArchitecture.X64, request, fetcher, runner, false); + AndroidSetupService service = new AndroidSetupService(paths, SetupPlatform.WINDOWS, + SetupArchitecture.X64, request, operations, false); + SetupPlan plan = AndroidSetupPlanner.plan(SetupPlatform.WINDOWS, SetupArchitecture.X64, + SetupMode.MANAGED, request); + + SetupReceipt receipt = service.install(plan, new SetupApproval(plan.digest(), Instant.EPOCH, + Set.of(AndroidSetupPlanner.ANDROID_SDK_LICENSE))); + + assertEquals(plan.actions(), receipt.completedActions()); + assertEquals(SetupReadiness.READY, service.status().readiness()); + assertTrue(Files.isRegularFile(paths.tools().resolve("android-sdk/15859902-api36-x86_64/" + + "build-tools/36.0.0/aapt2.exe"))); + String extensionManifest = Files.readString(paths.tools().resolve( + "appium/3.6.0/node_modules/.cache/appium/extensions.yaml")); + assertFalse(extensionManifest.contains(".staging-")); + assertTrue(extensionManifest.contains(paths.tools().resolve("appium/3.6.0").toString())); + assertTrue(runner.commands.stream().noneMatch(command -> command.contains("--licenses"))); + assertEquals(List.of("y\n"), runner.sdkPackageInputs); + assertTrue(runner.commands.stream().anyMatch(command -> command.contains("build-tools;36.0.0"))); + assertTrue(runner.commands.stream().anyMatch(command -> command.contains("--list_installed"))); + assertTrue(runner.commands.stream().anyMatch(command -> command.stream() + .anyMatch(part -> part.endsWith("aapt2.exe")) + && command.contains("version"))); + assertTrue(runner.commands.stream().anyMatch(command -> command.containsAll( + List.of("driver", "list", "--installed", "--json")))); + assertTrue(runner.commands.stream().anyMatch(command -> command.containsAll( + List.of("plugin", "list", "--installed", "--json")))); + assertTrue(runner.commands.stream().noneMatch(command -> command.contains("--relaxed-security"))); + } + + private static ReportingSetupService.ArtifactFetcher fixtureFetcher(Path nodeArchive, Path commandTools, + Path npmArchive) { + return action -> switch (action.target()) { + case NODE -> nodeArchive; + case ANDROID_SDK -> commandTools; + default -> npmArchive; + }; + } + + private static ShaftCachePaths paths(Path temp) { + Path cache = temp.resolve("cache").toAbsolutePath(); + Path data = temp.resolve("data").toAbsolutePath(); + return new ShaftCachePaths(cache, data, cache.resolve("downloads"), data.resolve("tools"), + data.resolve("state"), data.resolve("receipts")); + } + + private static Path createCommandToolsZip(Path destination) throws IOException { + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(destination))) { + for (String name : List.of("cmdline-tools/bin/sdkmanager.bat", + "cmdline-tools/bin/avdmanager.bat", "cmdline-tools/lib/repository.jar")) { + output.putNextEntry(new ZipEntry(name)); + output.write("fixture".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + return destination; + } + + private static Path createLinuxCommandToolsZip(Path destination) throws IOException { + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(destination))) { + for (String name : List.of("cmdline-tools/bin/sdkmanager", + "cmdline-tools/bin/avdmanager", "cmdline-tools/lib/repository.jar")) { + output.putNextEntry(new ZipEntry(name)); + output.write("fixture".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + return destination; + } + + private static void createAppiumFixture(Path staging) throws IOException { + Files.createDirectories(staging.resolve("node_modules/appium")); + Files.createDirectories(staging.resolve("node_modules/appium-inspector-plugin")); + Files.createDirectories(staging.resolve("node_modules/appium-uiautomator2-driver")); + Files.writeString(staging.resolve("node_modules/appium/index.js"), "appium"); + Files.writeString(staging.resolve("node_modules/appium/package.json"), "{\"version\":\"3.6.0\"}"); + Files.writeString(staging.resolve("node_modules/appium-inspector-plugin/package.json"), + "{\"version\":\"2026.7.1\"}"); + Files.writeString(staging.resolve("node_modules/appium-uiautomator2-driver/package.json"), + "{\"version\":\"8.2.2\"}"); + } + + private static void createSdkFixture(Path root) throws IOException { + for (Path file : List.of(root.resolve("platform-tools/adb.exe"), root.resolve("emulator/emulator.exe"), + root.resolve("platforms/android-36/android.jar"), root.resolve("build-tools/36.0.0/aapt2.exe"), + root.resolve("system-images/android-36/google_apis/x86_64/package.xml"))) { + Files.createDirectories(file.getParent()); + Files.writeString(file, "fixture"); + } + } + + private static String requestMetadata(AndroidSetupRequest request) { + return String.join("\n", "api=" + request.apiLevel(), "device=" + request.deviceProfile(), + "tag=" + request.imageTag(), "abi=" + request.abi(), "avd=" + request.avdName(), + "ramMb=" + request.ramMb(), "cores=" + request.cores(), "port=" + request.appiumPort()) + "\n"; + } + + private static String exactAvdConfig(AndroidSetupRequest request) { + return "image.sysdir.1=system-images/android-" + request.apiLevel() + '/' + request.imageTag() + '/' + + request.abi() + "/\n"; + } + + private static void createLinuxSdkFixture(Path root) throws IOException { + for (Path file : List.of(root.resolve("platform-tools/adb"), root.resolve("emulator/emulator"), + root.resolve("platforms/android-36/android.jar"), root.resolve("build-tools/36.0.0/aapt2"), + root.resolve("system-images/android-36/google_apis/x86_64/package.xml"))) { + Files.createDirectories(file.getParent()); + Files.writeString(file, "fixture"); + assertTrue(file.toFile().setExecutable(true, false)); + } + } + + private static String exactInstalledPackages() { + return "Installed packages:\nPath | Version | Description\n" + + "platform-tools | 37.0.1 | fixture\n" + + "emulator | 37.1.11 | fixture\n" + + "platforms;android-36 | 2 | fixture\n" + + "build-tools;36.0.0 | 36.0.0 | fixture\n" + + "system-images;android-36;google_apis;x86_64 | 7 | fixture\n"; + } + + private static final class FullInstallRunner implements AndroidCommandRunner { + private final List> commands = new ArrayList<>(); + private final List sdkPackageInputs = new ArrayList<>(); + + @Override + public ReportingSetupService.ProcessResult run(List command, Path workingDirectory, + Map environment, Set removed, + String input, Path log, Duration timeout) throws IOException { + commands.add(List.copyOf(command)); + recordExtensionManifest(command, workingDirectory); + createInstalledFixtures(command, workingDirectory, input); + return new ReportingSetupService.ProcessResult(0, output(command)); + } + + private void recordExtensionManifest(List command, Path workingDirectory) throws IOException { + if (!command.contains("driver") && !command.contains("plugin")) return; + Path manifest = workingDirectory.resolve("node_modules/.cache/appium/extensions.yaml"); + if (Files.exists(manifest)) return; + Files.createDirectories(manifest.getParent()); + Files.writeString(manifest, "installPath: " + workingDirectory); + } + + private void createInstalledFixtures(List command, Path workingDirectory, String input) + throws IOException { + createAppiumInstall(command); + createSdkInstall(command, workingDirectory, input); + createAvdInstall(command); + } + + private static void createAppiumInstall(List command) throws IOException { + int prefix = command.indexOf("--prefix"); + if (command.contains("ci") && prefix >= 0) { + createAppiumFixture(Path.of(command.get(prefix + 1))); + } + } + + private void createSdkInstall(List command, Path workingDirectory, String input) throws IOException { + boolean sdkManager = command.stream().anyMatch(part -> part.contains("sdkmanager")); + if (!sdkManager || command.contains("--licenses")) return; + createSdkFixture(workingDirectory); + if (command.stream().anyMatch(part -> part.startsWith("system-images;"))) { + sdkPackageInputs.add(input); + } + } + + private static void createAvdInstall(List command) throws IOException { + int avdPath = command.indexOf("--path"); + boolean avdManager = command.stream().anyMatch(part -> part.contains("avdmanager")); + if (!avdManager || avdPath < 0) return; + Path root = Path.of(command.get(avdPath + 1)); + Files.createDirectories(root); + Files.writeString(root.resolve("config.ini"), + "image.sysdir.1=system-images/android-36/google_apis/x86_64\n"); + } + + private static String output(List command) { + if (command.contains("--list_installed")) return exactInstalledPackages(); + if (command.contains("driver")) return "dbug Appium refreshed extension cache\n" + + "{\"uiautomator2\":{\"pkgName\":\"appium-uiautomator2-driver\",\"version\":\"8.2.2\"}}"; + if (command.contains("plugin")) return "dbug Appium refreshed extension cache\n" + + "{\"inspector\":{\"pkgName\":\"appium-inspector-plugin\",\"version\":\"2026.7.1\"}}"; + return isAppium(command) ? "dbug Appium refreshed extension cache\n3.6.0" : "v24.19.0"; + } + + private static boolean isAppium(List command) { + return command.stream().anyMatch(part -> part.endsWith("appium/index.js") + || part.endsWith("appium\\index.js")); + } + } + + private static final class RecordingOperations implements AndroidToolchainOperations { + private final List installs = new ArrayList<>(); + private final EnumMap readiness = new EnumMap<>(SetupTarget.class); + private int preflights; + private SetupTarget failTarget; + + @Override + public void preflight(List actions, boolean offline) { + preflights++; + } + + @Override + public void install(SetupAction action) throws IOException { + if (action.target() == failTarget) throw new IOException("simulated " + failTarget + " failure"); + installs.add(action); + readiness.put(action.target(), SetupReadiness.READY); + } + + @Override + public SetupStatus status(SetupAction action) { + SetupReadiness state = readiness.getOrDefault(action.target(), SetupReadiness.MISSING); + return new SetupStatus(action.target(), state, state == SetupReadiness.READY ? action.version() : "", + state == SetupReadiness.READY ? "Verified test installation." : "Not installed."); + } + } + + private static final class ConvergingOperations implements AndroidToolchainOperations { + private final Set installed = java.util.concurrent.ConcurrentHashMap.newKeySet(); + private final java.util.concurrent.atomic.AtomicInteger preflights = new java.util.concurrent.atomic.AtomicInteger(); + private final java.util.concurrent.atomic.AtomicInteger activeMutations = new java.util.concurrent.atomic.AtomicInteger(); + private final java.util.concurrent.atomic.AtomicInteger maximumActiveMutations = + new java.util.concurrent.atomic.AtomicInteger(); + private final java.util.concurrent.atomic.AtomicInteger firstPublications = + new java.util.concurrent.atomic.AtomicInteger(); + + @Override + public void preflight(List actions, boolean offline) { + preflights.incrementAndGet(); + } + + @Override + public void install(SetupAction action) throws IOException { + int active = activeMutations.incrementAndGet(); + maximumActiveMutations.accumulateAndGet(active, Math::max); + try { + if (firstPublications.get() == 0) { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (preflights.get() < 4 && System.nanoTime() < deadline) Thread.onSpinWait(); + if (preflights.get() < 4) throw new IOException("Concurrent waiters did not reach preflight."); + } + if (installed.add(action.target())) firstPublications.incrementAndGet(); + } finally { + activeMutations.decrementAndGet(); + } + } + + @Override + public SetupStatus status(SetupAction action) { + return new SetupStatus(action.target(), installed.contains(action.target()) + ? SetupReadiness.READY : SetupReadiness.MISSING, action.version(), "fixture"); + } + } + + public static final class SeparateProcessInstaller { + public static void main(String[] args) throws Exception { + Path root = Path.of(args[0]).toAbsolutePath(); + Path gate = Path.of(args[1]); + long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (Files.notExists(gate) && System.nanoTime() < deadline) Thread.sleep(Duration.ofMillis(10)); + if (Files.notExists(gate)) throw new IOException("Timed out waiting for the installer gate."); + Path cache = root.resolve("cache"); + Path data = root.resolve("data"); + ShaftCachePaths paths = new ShaftCachePaths(cache, data, cache.resolve("downloads"), + data.resolve("tools"), data.resolve("state"), data.resolve("receipts")); + AndroidSetupRequest request = AndroidSetupRequest.defaults(); + SetupPlan plan = AndroidSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED, request); + SetupReceipt receipt = new AndroidSetupService(paths, SetupPlatform.LINUX, SetupArchitecture.X64, + request, new FileConvergingOperations(data.resolve("markers")), false).install(plan, + new SetupApproval(plan.digest(), Instant.EPOCH, + Set.of(AndroidSetupPlanner.ANDROID_SDK_LICENSE))); + Files.writeString(Path.of(args[2]), receipt.planDigest()); + } + } + + private record FileConvergingOperations(Path markers) implements AndroidToolchainOperations { + @Override public void preflight(List actions, boolean offline) { } + + @Override + public void install(SetupAction action) throws IOException { + Files.createDirectories(markers); + try { + Files.createFile(markers.resolve(action.target().name())); + } catch (java.nio.file.FileAlreadyExistsException alreadyPublished) { + // The other process published this exact immutable target under the shared setup lock. + } + } + + @Override + public SetupStatus status(SetupAction action) { + return new SetupStatus(action.target(), Files.isRegularFile(markers.resolve(action.target().name())) + ? SetupReadiness.READY : SetupReadiness.MISSING, action.version(), "fixture"); + } + } +} diff --git a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/InfrastructureSetupServiceTest.java b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/InfrastructureSetupServiceTest.java index ee31fb8d19a..ac372d9cb4a 100644 --- a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/InfrastructureSetupServiceTest.java +++ b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/InfrastructureSetupServiceTest.java @@ -95,6 +95,31 @@ void builtInCoordinatorProvidesReadOnlyLighthouseStatusAndManagedPlan(@TempDir P assertTrue(Files.notExists(paths.dataRoot())); } + @Test + void builtInCoordinatorProvidesCompleteAndroidPlanIncludingBuildToolsLicense(@TempDir Path temp) { + ShaftCachePaths paths = paths(temp); + InfrastructureSetupService service = InfrastructureSetupService.builtIn( + SetupPlatform.LINUX, SetupArchitecture.X64); + SetupOptions options = SetupOptions.defaults(SetupProfile.MOBILE_ANDROID, paths) + .withMode(SetupMode.MANAGED); + + assertTrue(service.supports(SetupProfile.MOBILE_ANDROID)); + SetupPlan plan = service.plan(options); + + assertEquals(SetupProfile.MOBILE_ANDROID, plan.profile()); + assertEquals(List.of(SetupTarget.NODE, SetupTarget.APPIUM_SERVER, + SetupTarget.APPIUM_INSPECTOR_PLUGIN, SetupTarget.APPIUM_UIAUTOMATOR2_DRIVER, + SetupTarget.ANDROID_SDK, SetupTarget.ANDROID_EMULATOR), plan.actions().stream() + .map(SetupAction::target).toList()); + assertTrue(plan.actions().stream().allMatch(action -> action.kind() == SetupActionKind.INSTALL)); + SetupAction androidSdk = plan.actions().get(4); + assertTrue(androidSdk.version().contains("build-tools;"), + "The reviewed Android package set must bind build-tools so aapt2 is present."); + assertTrue(androidSdk.requiredLicenses().contains("android-sdk-license")); + assertTrue(Files.notExists(paths.cacheRoot())); + assertTrue(Files.notExists(paths.dataRoot())); + } + @Test void bundledLighthouseManifestMatchesTheApprovedLock() throws Exception { byte[] packageJson; diff --git a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/ReportingSetupServiceTest.java b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/ReportingSetupServiceTest.java index cba91762461..d26c9b717db 100644 --- a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/ReportingSetupServiceTest.java +++ b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/ReportingSetupServiceTest.java @@ -44,6 +44,33 @@ void installsAndReceiptsWindowsReportingProfileWithoutNetwork(@TempDir Path temp exerciseInstall(temp, SetupPlatform.WINDOWS, createNodeZip(temp.resolve("node.zip"))); } + @Test + void windowsNodeZipRejectsCaseFoldCollisionBeforePublication(@TempDir Path temp) throws Exception { + Path archive = temp.resolve("node-collision.zip"); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(archive))) { + for (String entry : List.of("node-v24.19.0-win-x64/node.exe", + "node-v24.19.0-win-x64/NODE.EXE")) { + output.putNextEntry(new ZipEntry(entry)); + output.write("binary".getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + Path cache = temp.resolve("cache").toAbsolutePath(); + Path data = temp.resolve("data").toAbsolutePath(); + ShaftCachePaths paths = new ShaftCachePaths(cache, data, cache.resolve("downloads"), + data.resolve("tools"), data.resolve("state"), data.resolve("receipts")); + ReportingSetupService service = new ReportingSetupService(paths, SetupPlatform.WINDOWS, + SetupArchitecture.X64, action -> archive, + (command, log, timeout) -> new ReportingSetupService.ProcessResult(0, "v24.19.0"), false); + + IOException failure = assertThrows(IOException.class, () -> service.installNodeAction( + ReportingSetupPlanner.plan(SetupPlatform.WINDOWS, SetupArchitecture.X64, + SetupMode.MANAGED).actions().getFirst())); + + assertTrue(failure.getMessage().contains("duplicate path")); + assertTrue(Files.notExists(paths.tools().resolve("node/24.19.0/windows-x64"))); + } + @Test void installsLinuxTarArchiveAndRoundTripsPlan(@TempDir Path temp) throws Exception { SetupPlan plan = exerciseInstall(temp, SetupPlatform.LINUX, createNodeTar(temp.resolve("node.tar.gz"))); @@ -64,6 +91,40 @@ void installsLinuxTarArchiveAndRoundTripsPlan(@TempDir Path temp) throws Excepti () -> SetupPlanJson.read(SetupPlanJson.write(plan).replaceFirst("\\{", "{\"mode\":\"MANAGED\","))); } + @Test + void linuxNodeTarRejectsEscapingRelativeLink(@TempDir Path temp) throws Exception { + Path archive = temp.resolve("node-escape.tar.gz"); + try (TarArchiveOutputStream output = new TarArchiveOutputStream( + new GzipCompressorOutputStream(Files.newOutputStream(archive)))) { + byte[] content = "binary".getBytes(StandardCharsets.UTF_8); + TarArchiveEntry node = new TarArchiveEntry("node-v24.19.0-linux-x64/bin/node"); + node.setSize(content.length); + node.setMode(0755); + output.putArchiveEntry(node); + output.write(content); + output.closeArchiveEntry(); + TarArchiveEntry link = new TarArchiveEntry("node-v24.19.0-linux-x64/bin/npm", + TarArchiveEntry.LF_SYMLINK); + link.setLinkName("../../../outside"); + output.putArchiveEntry(link); + output.closeArchiveEntry(); + } + Path cache = temp.resolve("cache").toAbsolutePath(); + Path data = temp.resolve("data").toAbsolutePath(); + ShaftCachePaths paths = new ShaftCachePaths(cache, data, cache.resolve("downloads"), + data.resolve("tools"), data.resolve("state"), data.resolve("receipts")); + ReportingSetupService service = new ReportingSetupService(paths, SetupPlatform.LINUX, + SetupArchitecture.X64, action -> archive, + (command, log, timeout) -> new ReportingSetupService.ProcessResult(0, "v24.19.0"), false); + + IOException failure = assertThrows(IOException.class, () -> service.installNodeAction( + ReportingSetupPlanner.plan(SetupPlatform.LINUX, SetupArchitecture.X64, + SetupMode.MANAGED).actions().getFirst())); + + assertTrue(failure.getMessage().contains("escapes")); + assertTrue(Files.notExists(temp.resolve("outside"))); + } + @Test void verifiedArtifactStoreRejectsBadHashAndCachesGoodFile(@TempDir Path temp) throws Exception { Path source = temp.resolve("artifact.bin"); @@ -427,6 +488,11 @@ private static Path createNodeTar(Path destination) throws IOException { output.write(content); output.closeArchiveEntry(); } + TarArchiveEntry npmLink = new TarArchiveEntry("node-v24.19.0-linux-x64/bin/npm", + TarArchiveEntry.LF_SYMLINK); + npmLink.setLinkName("../lib/node_modules/npm/bin/npm-cli.js"); + output.putArchiveEntry(npmLink); + output.closeArchiveEntry(); } return destination; } diff --git a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/SafeZipExtractorTest.java b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/SafeZipExtractorTest.java new file mode 100644 index 00000000000..0a6f8290a69 --- /dev/null +++ b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/SafeZipExtractorTest.java @@ -0,0 +1,70 @@ +package com.shaft.infrastructure; + +import org.apache.commons.compress.archivers.zip.UnixStat; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SafeZipExtractorTest { + @Test + void extractsOrdinaryFilesInsideTheReviewedDestination(@TempDir Path temp) throws Exception { + Path archive = archive(temp.resolve("safe.zip"), new Entry("cmdline-tools/bin/sdkmanager", "tool", 0)); + Path destination = temp.resolve("destination"); + + SafeZipExtractor.extract(archive, destination); + + assertEquals("tool", Files.readString(destination.resolve("cmdline-tools/bin/sdkmanager"))); + } + + @Test + void rejectsTraversalAndCaseFoldedDuplicateBeforeExternalMutation(@TempDir Path temp) throws Exception { + Path outside = temp.resolve("outside.txt"); + Path traversal = archive(temp.resolve("traversal.zip"), new Entry("../outside.txt", "escape", 0)); + Path duplicate = archive(temp.resolve("duplicate.zip"), + new Entry("Tools/sdkmanager", "one", 0), new Entry("tools/SDKMANAGER", "two", 0)); + + assertThrows(IOException.class, () -> SafeZipExtractor.extract(traversal, temp.resolve("traversal-out"))); + assertThrows(IOException.class, () -> SafeZipExtractor.extract(duplicate, temp.resolve("duplicate-out"))); + assertFalse(Files.exists(outside)); + } + + @Test + void rejectsUnixSymlinkEntriesWithoutPublishingTheLink(@TempDir Path temp) throws Exception { + Path archive = archive(temp.resolve("symlink.zip"), + new Entry("cmdline-tools/latest", "../../outside", UnixStat.LINK_FLAG | 0777)); + Path destination = temp.resolve("destination"); + + IOException failure = assertThrows(IOException.class, () -> SafeZipExtractor.extract(archive, destination)); + + assertTrue(failure.getMessage().toLowerCase().contains("link")); + assertFalse(Files.exists(destination.resolve("cmdline-tools/latest"))); + } + + private static Path archive(Path path, Entry... entries) throws IOException { + try (ZipArchiveOutputStream output = new ZipArchiveOutputStream(path.toFile())) { + for (Entry item : entries) { + ZipArchiveEntry entry = new ZipArchiveEntry(item.name()); + if (item.unixMode() != 0) entry.setUnixMode(item.unixMode()); + byte[] content = item.content().getBytes(StandardCharsets.UTF_8); + entry.setSize(content.length); + output.putArchiveEntry(entry); + output.write(content); + output.closeArchiveEntry(); + } + } + return path; + } + + private record Entry(String name, String content, int unixMode) { } +} diff --git a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/SystemAndroidRuntimeControllerTest.java b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/SystemAndroidRuntimeControllerTest.java new file mode 100644 index 00000000000..6aed2da3ba6 --- /dev/null +++ b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/SystemAndroidRuntimeControllerTest.java @@ -0,0 +1,143 @@ +package com.shaft.infrastructure; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SystemAndroidRuntimeControllerTest { + @Test + @EnabledOnOs(OS.LINUX) + void execTransitionUsesPostLaunchCommandIdentityForLeaseReuse(@TempDir Path temp) throws Exception { + SystemAndroidRuntimeController controller = new SystemAndroidRuntimeController(); + AndroidOwnedProcess process = controller.start("exec-child", List.of("/bin/sh", "-c", "exec sleep 30"), + temp, Map.of(), Set.of(), temp.resolve("exec-child.log")); + try { + awaitCommandChange(process.pid(), "/bin/sh", Duration.ofSeconds(5)); + + assertTrue(process.commandIdentity().contains("30")); + assertTrue(controller.find(process.pid(), process.startInstant(), process.commandIdentity()).isPresent()); + } finally { + process.stop(Duration.ofSeconds(5)); + } + } + + @Test + void mismatchedStartInstantOrCommandIsNeverAdoptedOrKilled(@TempDir Path temp) throws Exception { + Path java = javaExecutable(); + Path log = temp.resolve("child.log"); + SystemAndroidRuntimeController controller = new SystemAndroidRuntimeController(); + AndroidOwnedProcess process = controller.start("child", List.of(java.toString(), "-Xmx32m", + "-XX:+UseSerialGC", "-cp", System.getProperty("java.class.path"), SleepingChild.class.getName()), + temp, Map.of(), Set.of(), log); + try { + assertTrue(process.commandIdentity().startsWith(java.toString())); + Instant wrongStart = process.startInstant().plusMillis(1); + + assertThrows(java.io.IOException.class, + () -> controller.find(process.pid(), wrongStart, process.commandIdentity())); + assertTrue(process.isAlive()); + assertThrows(java.io.IOException.class, + () -> controller.find(process.pid(), process.startInstant(), process.commandIdentity() + ".other")); + assertTrue(process.isAlive()); + } finally { + process.stop(Duration.ofSeconds(5)); + awaitDelete(log, Duration.ofSeconds(2)); + } + } + + @Test + void stopTerminatesRealDescendantBeforeParent(@TempDir Path temp) throws Exception { + Path java = javaExecutable(); + Path childPidFile = temp.resolve("child.pid"); + List command = List.of(java.toString(), "-Xmx32m", "-XX:+UseSerialGC", "-cp", + System.getProperty("java.class.path"), DescendantParent.class.getName(), childPidFile.toString()); + SystemAndroidRuntimeController controller = new SystemAndroidRuntimeController(); + AndroidOwnedProcess parent = controller.start("parent", command, temp, Map.of(), Set.of(), + temp.resolve("parent.log")); + long childPid = -1; + try { + childPid = awaitPid(childPidFile, Duration.ofSeconds(5)); + + parent.stop(Duration.ofSeconds(5)); + + assertTrue(ProcessHandle.of(childPid).map(handle -> !handle.isAlive()).orElse(true)); + assertTrue(ProcessHandle.of(parent.pid()).map(handle -> !handle.isAlive()).orElse(true)); + } finally { + if (childPid > 0) ProcessHandle.of(childPid).filter(ProcessHandle::isAlive) + .ifPresent(ProcessHandle::destroyForcibly); + ProcessHandle.of(parent.pid()).filter(ProcessHandle::isAlive).ifPresent(ProcessHandle::destroyForcibly); + } + } + + private static long awaitPid(Path pidFile, Duration timeout) throws Exception { + long deadline = System.nanoTime() + timeout.toNanos(); + while (System.nanoTime() < deadline) { + if (Files.isRegularFile(pidFile) && Files.size(pidFile) > 0) { + return Long.parseLong(Files.readString(pidFile)); + } + Thread.sleep(Duration.ofMillis(10)); + } + throw new AssertionError("Timed out waiting for descendant PID."); + } + + private static void awaitDelete(Path file, Duration timeout) throws Exception { + long deadline = System.nanoTime() + timeout.toNanos(); + Exception last = null; + while (System.nanoTime() < deadline) { + try { + Files.deleteIfExists(file); + return; + } catch (java.io.IOException locked) { + last = locked; + Thread.sleep(Duration.ofMillis(10)); + } + } + throw new java.io.IOException("Timed out waiting for child process log handle to close: " + file, last); + } + + private static void awaitCommandChange(long pid, String launchCommand, Duration timeout) throws Exception { + String normalizedLaunch = Path.of(launchCommand).toAbsolutePath().normalize().toString(); + long deadline = System.nanoTime() + timeout.toNanos(); + while (System.nanoTime() < deadline) { + String command = ProcessHandle.of(pid).flatMap(handle -> handle.info().command()) + .map(value -> Path.of(value).toAbsolutePath().normalize().toString()).orElse(""); + if (!command.isBlank() && !command.equals(normalizedLaunch)) return; + Thread.sleep(Duration.ofMillis(10)); + } + throw new AssertionError("Timed out waiting for the child process exec transition."); + } + + private static Path javaExecutable() { + return Path.of(System.getProperty("java.home"), "bin", SetupPlatform.current() == SetupPlatform.WINDOWS + ? "java.exe" : "java").toAbsolutePath(); + } + + public static final class DescendantParent { + public static void main(String[] args) throws Exception { + Path java = Path.of(System.getProperty("java.home"), "bin", SetupPlatform.current() == SetupPlatform.WINDOWS + ? "java.exe" : "java"); + Process child = new ProcessBuilder(java.toString(), "-Xmx32m", "-XX:+UseSerialGC", "-cp", + System.getProperty("java.class.path"), SleepingChild.class.getName()).start(); + Files.writeString(Path.of(args[0]), Long.toString(child.pid())); + Thread.sleep(Duration.ofSeconds(30)); + } + } + + public static final class SleepingChild { + public static void main(String[] args) throws Exception { + Thread.sleep(Duration.ofSeconds(30)); + } + } +} diff --git a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/SystemAndroidRuntimeHealthTest.java b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/SystemAndroidRuntimeHealthTest.java new file mode 100644 index 00000000000..8d2771d20b1 --- /dev/null +++ b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/SystemAndroidRuntimeHealthTest.java @@ -0,0 +1,66 @@ +package com.shaft.infrastructure; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.net.http.HttpClient; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SystemAndroidRuntimeHealthTest { + @Test + void emulatorConsoleAvdNameProvesExactOwnedIdentity(@TempDir Path temp) { + RecordingRunner runner = new RecordingRunner("shaft_android\nOK\n"); + SystemAndroidRuntimeHealth health = new SystemAndroidRuntimeHealth(runner, HttpClient.newHttpClient()); + + assertDoesNotThrow(() -> health.awaitEmulator("emulator-5554", layout(temp), Map.of(), + Duration.ofMillis(100))); + + assertTrue(runner.commands.stream().anyMatch(command -> command.containsAll( + List.of("-s", "emulator-5554", "emu", "avd", "name")))); + } + + @Test + void differentEmulatorConsoleAvdNameIsNeverAccepted(@TempDir Path temp) { + SystemAndroidRuntimeHealth health = new SystemAndroidRuntimeHealth( + new RecordingRunner("some_other_avd\nOK\n"), HttpClient.newHttpClient()); + + assertThrows(java.io.IOException.class, () -> health.awaitEmulator("emulator-5554", layout(temp), + Map.of(), Duration.ofMillis(10))); + } + + private static AndroidRuntimeLayout layout(Path root) { + return new AndroidRuntimeLayout(root.resolve("node"), root.resolve("appium/index.js"), + root.resolve("appium"), root.resolve("sdk"), root.resolve("sdk/adb"), + root.resolve("sdk/emulator"), root.resolve("avd"), root.resolve("avd/shaft_android.avd"), + root.resolve("logs/emulator.log"), root.resolve("logs/appium.log"), + "shaft_android", "emulator-5554"); + } + + private static final class RecordingRunner implements AndroidCommandRunner { + private final String avdNameOutput; + private final java.util.ArrayList> commands = new java.util.ArrayList<>(); + + private RecordingRunner(String avdNameOutput) { + this.avdNameOutput = avdNameOutput; + } + + @Override + public ReportingSetupService.ProcessResult run(List command, Path workingDirectory, + Map environment, java.util.Set removed, + String input, Path log, Duration timeout) { + commands.add(List.copyOf(command)); + String output = command.contains("get-state") ? "device" + : command.contains("sys.boot_completed") ? "1" + : command.contains("pm") ? "package:/system/framework/framework-res.apk" + : command.contains("emu") ? avdNameOutput : ""; + return new ReportingSetupService.ProcessResult(0, output); + } + } +} diff --git a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/VerifiedArtifactStoreTest.java b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/VerifiedArtifactStoreTest.java index d042843b5fd..1519da81a04 100644 --- a/shaft-infrastructure/src/test/java/com/shaft/infrastructure/VerifiedArtifactStoreTest.java +++ b/shaft-infrastructure/src/test/java/com/shaft/infrastructure/VerifiedArtifactStoreTest.java @@ -14,6 +14,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; class VerifiedArtifactStoreTest { + @Test + void androidCommandLineToolsHaveATargetSpecificBoundWithoutWeakeningOtherArtifacts() { + assertEquals(256L * 1024 * 1024, + VerifiedArtifactStore.maximumArtifactBytes(SetupTarget.ANDROID_SDK)); + assertEquals(128L * 1024 * 1024, + VerifiedArtifactStore.maximumArtifactBytes(SetupTarget.NODE)); + } @Test void oversizedArtifactIsRejectedAndTemporaryFileIsRemoved(@TempDir Path temp) throws Exception { Path source = temp.resolve("oversized.bin"); diff --git a/shaft-mcp/src/main/java/com/shaft/mcp/McpMobileInspectorRecordingService.java b/shaft-mcp/src/main/java/com/shaft/mcp/McpMobileInspectorRecordingService.java index 70e173a600c..c8675067ca7 100644 --- a/shaft-mcp/src/main/java/com/shaft/mcp/McpMobileInspectorRecordingService.java +++ b/shaft-mcp/src/main/java/com/shaft/mcp/McpMobileInspectorRecordingService.java @@ -1,5 +1,6 @@ package com.shaft.mcp; +import com.shaft.infrastructure.ManagedEnvironment; import tools.jackson.core.JacksonException; import tools.jackson.databind.ObjectMapper; @@ -103,6 +104,8 @@ synchronized McpMobileInspectorPlan prepare( && "device".equals(device.state())); if (!androidReadyDevice) { if (!selectedAvd.isBlank()) { + proposal = toolchain.defaultAndroidProposal(selectedAvd, androidApiLevel, androidDeviceProfile, + androidImageTag, androidAbi, androidRamMb, androidCores); nextSteps.add("Confirm this plan to start cached Android emulator `" + selectedAvd + "`."); } else if (!status.cachedAndroidEmulators().isEmpty()) { readyToStart = false; @@ -178,7 +181,7 @@ synchronized McpMobileInspectorRecordingStatus start(String confirmationToken, S } List warnings = new ArrayList<>(plan.warnings()); - Process emulatorProcess = null; + ManagedEnvironment managedEnvironment = null; boolean managedEmulator = false; String androidAvdName = text(selectedAndroidAvdName).isBlank() ? plan.selectedAndroidAvdName() @@ -187,36 +190,37 @@ synchronized McpMobileInspectorRecordingStatus start(String confirmationToken, S try { toolchain.ensureAppium(plan.platformName()); if ("Android".equals(plan.platformName()) && deviceId.isBlank()) { - if (plan.willProvisionAndroidEmulator()) { - toolchain.ensureAndroidEmulator(plan.androidEmulatorProposal()); + if (plan.androidEmulatorProposal() != null) { + managedEnvironment = toolchain.startAndroidRuntime(plan.androidEmulatorProposal()); androidAvdName = plan.androidEmulatorProposal().avdName(); + managedEmulator = true; + deviceId = managedEnvironment.connectionProperties().getOrDefault("ANDROID_SERIAL", ""); } else if (androidAvdName.isBlank()) { throw new IllegalArgumentException( "selectedAndroidAvdName is required when no real Android device is connected."); } - emulatorProcess = toolchain.startAndroidEmulator(androidAvdName, plan.androidEmulatorProposal()); - managedEmulator = true; - if (!toolchain.waitForAndroidDevice(Duration.ofMinutes(3))) { - throw new IllegalStateException("Android emulator did not become ready within 3 minutes."); - } - McpMobileToolchainStatus status = toolchain.status("Android"); - deviceId = status.androidDevices().stream() - .filter(device -> device.emulator() && "device".equals(device.state())) - .map(McpMobileDevice::id) - .findFirst() - .orElse(""); } - int appiumPort = freePort(); recorder.start(plan.outputPath(), "mobile-inspector-" + plan.platformName().toLowerCase(Locale.ROOT), plan.includeSensitiveValues()); - Process appiumProcess = toolchain.startAppiumServer(appiumPort); - URI backend = URI.create("http://127.0.0.1:" + appiumPort); - waitForAppium(backend, warnings); + Process appiumProcess = null; + URI backend; + if (managedEnvironment != null) { + backend = managedEnvironment.endpoint().orElseThrow(() -> + new IllegalStateException("Shared Android runtime did not publish its Appium endpoint.")); + } else if ("Android".equals(plan.platformName())) { + backend = URI.create(DEFAULT_APPIUM_SERVER); + waitForAppium(backend, warnings); + } else { + int appiumPort = freePort(); + appiumProcess = toolchain.startAppiumServer(appiumPort); + backend = URI.create("http://127.0.0.1:" + appiumPort); + waitForAppium(backend, warnings); + } Session session = new Session( plan, appiumProcess, - emulatorProcess, + managedEnvironment, managedEmulator, deviceId, androidAvdName, @@ -234,7 +238,10 @@ synchronized McpMobileInspectorRecordingStatus start(String confirmationToken, S + " if the embedded Inspector asks for a server endpoint."); return status(session, warnings, session.setupBlocks); } catch (RuntimeException exception) { - cleanupFailedStart(emulatorProcess, managedEmulator, deviceId); + if (managedEnvironment != null) { + managedEnvironment.close(); + } + cleanupFailedStart(); activeSession = null; throw exception; } @@ -305,10 +312,10 @@ private synchronized McpMobileInspectorRecordingStatus stop(boolean discard, boo warnings.add("Session-managed Appium and emulator processes were stopped. Update executionAddress " + "before replaying against a different Appium server."); } - destroy(session.appiumProcess); - if (session.managedEmulator) { - toolchain.stopAndroidEmulator(session.deviceId); - destroy(session.emulatorProcess); + if (session.managedEnvironment != null) { + session.managedEnvironment.close(); + } else { + destroy(session.appiumProcess); } if (closeProxy && session.proxy != null) { session.proxy.close(); @@ -437,11 +444,7 @@ private String platform(String platformName) { }; } - private void cleanupFailedStart(Process emulatorProcess, boolean managedEmulator, String deviceId) { - if (managedEmulator) { - toolchain.stopAndroidEmulator(deviceId); - destroy(emulatorProcess); - } + private void cleanupFailedStart() { try { if (recorder.status().active()) { recorder.stop(true); @@ -544,7 +547,7 @@ private static String text(String value) { private static final class Session { private final McpMobileInspectorPlan plan; private final Process appiumProcess; - private final Process emulatorProcess; + private final ManagedEnvironment managedEnvironment; private final boolean managedEmulator; private final String deviceId; private final String androidAvdName; @@ -557,7 +560,7 @@ private static final class Session { private Session( McpMobileInspectorPlan plan, Process appiumProcess, - Process emulatorProcess, + ManagedEnvironment managedEnvironment, boolean managedEmulator, String deviceId, String androidAvdName, @@ -565,7 +568,7 @@ private Session( List setupBlocks) { this.plan = plan; this.appiumProcess = appiumProcess; - this.emulatorProcess = emulatorProcess; + this.managedEnvironment = managedEnvironment; this.managedEmulator = managedEmulator; this.deviceId = text(deviceId); this.androidAvdName = text(androidAvdName); diff --git a/shaft-mcp/src/main/java/com/shaft/mcp/McpMobileToolchainService.java b/shaft-mcp/src/main/java/com/shaft/mcp/McpMobileToolchainService.java index 79062177ed9..e2c67eb4ca8 100644 --- a/shaft-mcp/src/main/java/com/shaft/mcp/McpMobileToolchainService.java +++ b/shaft-mcp/src/main/java/com/shaft/mcp/McpMobileToolchainService.java @@ -1,6 +1,16 @@ package com.shaft.mcp; import com.shaft.driver.SHAFT; +import com.shaft.infrastructure.AndroidSetupRequest; +import com.shaft.infrastructure.AndroidSetupPlanner; +import com.shaft.infrastructure.InfrastructureSetupService; +import com.shaft.infrastructure.ManagedEnvironment; +import com.shaft.infrastructure.SetupApproval; +import com.shaft.infrastructure.SetupMode; +import com.shaft.infrastructure.SetupOptions; +import com.shaft.infrastructure.SetupPlan; +import com.shaft.infrastructure.SetupProfile; +import com.shaft.infrastructure.ShaftCachePaths; import com.shaft.properties.internal.PropertiesHelper; import java.io.IOException; @@ -15,6 +25,7 @@ import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashSet; @@ -57,10 +68,12 @@ final class McpMobileToolchainService { private final String osName; private final String osArch; private final HttpClient httpClient; + private final AndroidSetupOwner androidSetupOwner; McpMobileToolchainService() { this(McpProcessRunner.system(), System.getenv(), McpRuntimePaths.applicationDataRoot().resolve("tools"), - System.getProperty("os.name", ""), System.getProperty("os.arch", "")); + System.getProperty("os.name", ""), System.getProperty("os.arch", ""), + new InfrastructureAndroidSetupOwner(ShaftCachePaths.current())); } McpMobileToolchainService( @@ -69,12 +82,24 @@ final class McpMobileToolchainService { Path toolRoot, String osName, String osArch) { + this(runner, environment, toolRoot, osName, osArch, + new InfrastructureAndroidSetupOwner(setupPaths(toolRoot))); + } + + McpMobileToolchainService( + McpProcessRunner runner, + Map environment, + Path toolRoot, + String osName, + String osArch, + AndroidSetupOwner androidSetupOwner) { ensurePropertiesInitialized(); this.runner = runner; this.environment = environment == null ? Map.of() : Map.copyOf(environment); this.toolRoot = toolRoot.toAbsolutePath().normalize(); this.osName = osName == null ? "" : osName; this.osArch = osArch == null ? "" : osArch; + this.androidSetupOwner = java.util.Objects.requireNonNull(androidSetupOwner, "androidSetupOwner"); this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(30)) .followRedirects(HttpClient.Redirect.NORMAL) @@ -83,31 +108,47 @@ final class McpMobileToolchainService { McpMobileToolchainStatus status(String platformName) { String platform = normalizePlatform(platformName); - Path androidSdkRoot = androidSdkRoot(); - Path androidAvdHome = androidAvdHome(); - Path appiumRoot = appiumRoot(); + boolean sharedAndroid = "Android".equals(platform); + Path androidSdkRoot = statusAndroidSdkRoot(sharedAndroid); + Path androidAvdHome = statusAndroidAvdHome(sharedAndroid); + Path managedAppiumRoot = toolRoot.resolve("appium").resolve(appiumServerVersion()); + Path legacyAppiumRoot = appiumRoot(); + Path appiumRoot = sharedAndroid ? managedAppiumRoot : legacyAppiumRoot; + List nodeBins = sharedAndroid ? List.of(managedNodeBin(), nodeBin()) : List.of(nodeBin()); List warnings = new ArrayList<>(); - Optional node = resolveExecutable("node", List.of(nodeBin())); - Optional npm = resolveExecutable("npm", List.of(nodeBin())); - Optional appium = resolveExecutable("appium", List.of(appiumBin())); + Optional node = resolveExecutable("node", nodeBins); + Optional npm = resolveExecutable("npm", nodeBins); + List appiumBins = sharedAndroid + ? List.of(managedAppiumRoot.resolve("node_modules/.bin"), appiumBin()) : List.of(appiumBin()); + Optional appium = resolveExecutable("appium", appiumBins); + if (sharedAndroid && appium.isPresent() + && !appium.orElseThrow().startsWith(managedAppiumRoot.toAbsolutePath().normalize()) + && appium.orElseThrow().startsWith(legacyAppiumRoot.toAbsolutePath().normalize())) { + appiumRoot = legacyAppiumRoot; + } + Path statusAppiumRoot = appiumRoot; Optional adb = resolveExecutable("adb", List.of(androidSdkRoot.resolve("platform-tools"))); Optional emulator = resolveExecutable("emulator", List.of(androidSdkRoot.resolve("emulator"))); Optional sdkManager = resolveExecutable("sdkmanager", List.of(androidSdkRoot.resolve("cmdline-tools") .resolve("latest").resolve("bin"))); Optional avdManager = resolveExecutable("avdmanager", List.of(androidSdkRoot.resolve("cmdline-tools") .resolve("latest").resolve("bin"))); - Path inspectorPluginPath = appiumRoot.resolve("node_modules").resolve("appium-inspector-plugin"); + Path inspectorPluginPath = statusAppiumRoot.resolve("node_modules").resolve("appium-inspector-plugin"); - List devices = adb.map(this::androidDevices).orElseGet(() -> { + List devices = adb.map(value -> androidDevices(value, androidSdkRoot, androidAvdHome)) + .orElseGet(() -> { warnings.add("adb was not found on PATH or in the SHAFT-managed Android SDK cache."); return List.of(); }); - List avds = cachedAndroidEmulators(emulator.orElse(null), androidAvdHome); - boolean inspector = appium.filter(this::hasInspectorPlugin).isPresent() + List avds = cachedAndroidEmulators(emulator.orElse(null), androidSdkRoot, androidAvdHome); + boolean inspector = appium.filter(value -> hasInspectorPlugin(value, statusAppiumRoot, + androidSdkRoot, androidAvdHome)).isPresent() || Files.isDirectory(inspectorPluginPath); - String detectedAppiumVersion = appiumVersion(appium.orElse(null)); - List diagnostics = diagnostics(platform, androidSdkRoot, appiumRoot, node, npm, + String detectedAppiumVersion = appiumVersion(appium.orElse(null), statusAppiumRoot, + androidSdkRoot, androidAvdHome); + List diagnostics = diagnostics(platform, androidSdkRoot, statusAppiumRoot, + node, npm, appium, inspector, inspectorPluginPath, adb, emulator, sdkManager, avdManager, detectedAppiumVersion); List missing = new ArrayList<>(); @@ -151,7 +192,7 @@ McpMobileToolchainStatus status(String platformName) { toolRoot, androidSdkRoot, androidAvdHome, - appiumRoot, + statusAppiumRoot, detectedAppiumVersion, appiumInspectorPluginVersion(), devices, @@ -265,13 +306,14 @@ McpAndroidEmulatorProposal defaultAndroidProposal( + resolvedAbi.replace('-', '_')); int resolvedRam = ramMb > 0 ? ramMb : androidEmulatorRamMb(); int resolvedCores = cores > 0 ? cores : androidEmulatorCores(); - Path sdkRoot = androidSdkRoot(); - Path avdHome = androidAvdHome(); + Path sdkRoot = androidSetupOwner.sdkRoot(resolvedApiLevel, resolvedAbi); + Path avdHome = androidSetupOwner.avdHome(); String imagePackage = "system-images;android-" + resolvedApiLevel + ";" + resolvedTag + ";" + resolvedAbi; List packages = List.of( "platform-tools", "emulator", "platforms;android-" + resolvedApiLevel, + "build-tools;" + AndroidSetupPlanner.BUILD_TOOLS_VERSION, imagePackage); List commands = List.of( commandLineToolsUrl(), @@ -298,6 +340,9 @@ McpAndroidEmulatorProposal defaultAndroidProposal( void ensureAppium(String platformName) { String platform = normalizePlatform(platformName); + if ("Android".equals(platform)) { + return; + } Path npm = ensureNpm(); try { Files.createDirectories(appiumRoot()); @@ -326,46 +371,85 @@ void ensureAndroidEmulator(McpAndroidEmulatorProposal proposal) { if (proposal == null) { throw new IllegalArgumentException("Android emulator proposal is required."); } - ensureAndroidCommandLineTools(); - Path sdkManager = sdkManagerCommand(); - Path avdManager = avdManagerCommand(); try { - Files.createDirectories(proposal.avdHome()); + androidSetupOwner.install(proposal); } catch (IOException exception) { - throw new IllegalStateException("Android AVD home could not be created.", exception); - } - runner.runWithInput(List.of(sdkManager.toString(), "--sdk_root=" + androidSdkRoot(), "--licenses"), - androidSdkRoot(), androidEnvironment(), INSTALL_TIMEOUT, "y\n".repeat(80)); - List sdkCommand = new ArrayList<>(List.of(sdkManager.toString(), "--sdk_root=" + androidSdkRoot())); - sdkCommand.addAll(proposal.sdkPackages()); - runChecked(sdkCommand, androidSdkRoot(), androidEnvironment(), INSTALL_TIMEOUT, - "Android SDK package installation failed."); - runChecked(List.of(avdManager.toString(), "create", "avd", "--force", - "--name", proposal.avdName(), - "--package", "system-images;android-" + proposal.apiLevel() + ";" + proposal.imageTag() + ";" - + proposal.abi(), - "--device", proposal.deviceProfile()), - androidSdkRoot(), androidEnvironment(), INSTALL_TIMEOUT, "Android AVD creation failed."); - } - - Process startAndroidEmulator(String avdName, McpAndroidEmulatorProposal proposal) { - String name = defaultText(avdName, proposal == null ? "" : proposal.avdName()); - if (name.isBlank()) { - throw new IllegalArgumentException("Android AVD name is required."); - } - Path emulator = emulatorCommand(); - List command = new ArrayList<>(List.of( - emulator.toString(), - "-avd", - name, - "-no-snapshot-save", - "-no-boot-anim")); - if (proposal != null) { - command.addAll(List.of( - "-memory", String.valueOf(proposal.ramMb()), - "-cores", String.valueOf(proposal.cores()))); - } - return runner.start(command, androidSdkRoot(), androidEnvironment()); + throw new IllegalStateException("Shared Android setup could not be installed.", exception); + } + } + + ManagedEnvironment startAndroidRuntime(McpAndroidEmulatorProposal proposal) { + if (proposal == null) { + throw new IllegalArgumentException("Android emulator proposal is required."); + } + try { + return androidSetupOwner.start(proposal); + } catch (IOException exception) { + throw new IllegalStateException("Shared Android runtime could not be started.", exception); + } + } + + interface AndroidSetupOwner { + Path sdkRoot(int apiLevel, String abi); + Path avdHome(); + void install(McpAndroidEmulatorProposal proposal) throws IOException; + ManagedEnvironment start(McpAndroidEmulatorProposal proposal) throws IOException; + } + + private static final class InfrastructureAndroidSetupOwner implements AndroidSetupOwner { + private final ShaftCachePaths paths; + private final InfrastructureSetupService coordinator = InfrastructureSetupService.builtIn(); + + private InfrastructureAndroidSetupOwner(ShaftCachePaths paths) { + this.paths = paths; + } + + @Override + public Path sdkRoot(int apiLevel, String abi) { + return paths.tools().resolve("android-sdk").resolve(AndroidSetupPlanner.COMMAND_LINE_TOOLS_VERSION + + "-api" + apiLevel + '-' + abi); + } + + @Override + public Path avdHome() { + return paths.tools().resolve("android-avd"); + } + + @Override + public void install(McpAndroidEmulatorProposal proposal) throws IOException { + PreparedAndroidSetup setup = prepare(proposal); + coordinator.install(setup.plan(), setup.approval(), setup.options(), setup.request()); + } + + @Override + public ManagedEnvironment start(McpAndroidEmulatorProposal proposal) throws IOException { + PreparedAndroidSetup setup = prepare(proposal); + coordinator.install(setup.plan(), setup.approval(), setup.options(), setup.request()); + return coordinator.start(setup.plan(), setup.approval(), setup.options(), setup.request()); + } + + private PreparedAndroidSetup prepare(McpAndroidEmulatorProposal proposal) { + AndroidSetupRequest request = new AndroidSetupRequest(proposal.apiLevel(), proposal.deviceProfile(), + proposal.imageTag(), proposal.abi(), proposal.avdName(), proposal.ramMb(), proposal.cores(), + AndroidSetupPlanner.APPIUM_PORT); + SetupOptions options = SetupOptions.defaults(SetupProfile.MOBILE_ANDROID, paths) + .withMode(SetupMode.MANAGED); + SetupPlan plan = coordinator.plan(options, request); + SetupApproval approval = new SetupApproval(plan.digest(), Instant.now(), + Set.of(AndroidSetupPlanner.ANDROID_SDK_LICENSE)); + return new PreparedAndroidSetup(request, options, plan, approval); + } + + private record PreparedAndroidSetup(AndroidSetupRequest request, SetupOptions options, SetupPlan plan, + SetupApproval approval) { } + } + + private static ShaftCachePaths setupPaths(Path toolRoot) { + Path tools = toolRoot.toAbsolutePath().normalize(); + Path data = java.util.Objects.requireNonNullElse(tools.getParent(), tools); + Path cache = data.resolve("cache"); + return new ShaftCachePaths(cache, data, cache.resolve("downloads"), tools, + data.resolve("state"), data.resolve("receipts")); } Process startAppiumServer(int port) { @@ -374,35 +458,10 @@ Process startAppiumServer(int port) { appium.toString(), "--address", "127.0.0.1", "--port", String.valueOf(port), - "--use-plugins=inspector", - "--relaxed-security"), + "--use-plugins=inspector"), appiumRoot(), appiumEnvironment()); } - boolean waitForAndroidDevice(Duration timeout) { - Optional adb = resolveExecutable("adb", List.of(androidSdkRoot().resolve("platform-tools"))); - if (adb.isEmpty()) { - return false; - } - long deadline = System.nanoTime() + timeout.toNanos(); - while (System.nanoTime() < deadline) { - if (androidDevices(adb.get()).stream().anyMatch(device -> "device".equals(device.state()))) { - return true; - } - sleep(Duration.ofSeconds(2)); - } - return false; - } - - void stopAndroidEmulator(String deviceId) { - Optional adb = resolveExecutable("adb", List.of(androidSdkRoot().resolve("platform-tools"))); - if (adb.isEmpty() || text(deviceId).isBlank()) { - return; - } - runner.run(List.of(adb.get().toString(), "-s", deviceId, "emu", "kill"), - androidSdkRoot(), androidEnvironment(), Duration.ofSeconds(8)); - } - String newConfirmationToken() { return UUID.randomUUID().toString(); } @@ -422,29 +481,6 @@ private Path ensureNpm() { .orElseThrow(() -> new IllegalStateException("npm was not found after portable Node.js setup.")); } - private void ensureAndroidCommandLineTools() { - if (Files.isRegularFile(sdkManagerCommand()) || Files.isRegularFile(sdkManagerCommand().resolveSibling( - sdkManagerCommand().getFileName().toString() + ".bat"))) { - return; - } - Path zip = toolRoot.resolve("downloads").resolve("android-commandlinetools.zip"); - Path temp = toolRoot.resolve("downloads").resolve("android-commandlinetools-" + System.nanoTime()); - try { - Files.createDirectories(zip.getParent()); - download(commandLineToolsUrl(), zip); - unzip(zip, temp); - Path extracted = temp.resolve("cmdline-tools"); - Path target = androidSdkRoot().resolve("cmdline-tools").resolve("latest"); - deleteIfExists(target); - Files.createDirectories(target.getParent()); - Files.move(extracted, target, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException exception) { - throw new IllegalStateException("Android command-line tools could not be prepared.", exception); - } finally { - deleteIfExists(temp); - } - } - private void downloadPortableNode() { String archiveName = nodeArchiveName(); String extension = isWindows() ? ".zip" : isMac() ? ".tar.gz" : ".tar.xz"; @@ -525,9 +561,9 @@ private void runChecked( } } - private List androidDevices(Path adb) { + private List androidDevices(Path adb, Path sdkRoot, Path avdHome) { McpProcessRunner.ProcessResult result = runner.run(List.of(adb.toString(), "devices", "-l"), - androidSdkRoot(), androidEnvironment(), QUICK_TIMEOUT); + sdkRoot, androidEnvironment(sdkRoot, avdHome), QUICK_TIMEOUT); if (result.exitCode() != 0 || result.timedOut()) { return List.of(new McpMobileDevice("", "", "Android", "unavailable", false, List.of("adb devices failed: " + safeOutput(result.stdout(), result.stderr())))); @@ -557,11 +593,11 @@ private List androidDevices(Path adb) { return List.copyOf(devices); } - private List cachedAndroidEmulators(Path emulator, Path androidAvdHome) { + private List cachedAndroidEmulators(Path emulator, Path sdkRoot, Path androidAvdHome) { Set avds = new LinkedHashSet<>(); if (emulator != null) { McpProcessRunner.ProcessResult result = runner.run(List.of(emulator.toString(), "-list-avds"), - androidSdkRoot(), androidEnvironment(), QUICK_TIMEOUT); + sdkRoot, androidEnvironment(sdkRoot, androidAvdHome), QUICK_TIMEOUT); if (result.exitCode() == 0 && !result.timedOut()) { Arrays.stream(result.stdout().split("\\R")) .map(String::trim) @@ -587,18 +623,18 @@ private void scanAvdHome(Path avdHome, Set avds) { } } - private boolean hasInspectorPlugin(Path appium) { + private boolean hasInspectorPlugin(Path appium, Path root, Path sdkRoot, Path avdHome) { McpProcessRunner.ProcessResult result = runner.run(List.of(appium.toString(), "plugin", "list", "--installed"), - appiumRoot(), appiumEnvironment(), QUICK_TIMEOUT); + root, appiumEnvironment(root, sdkRoot, avdHome), QUICK_TIMEOUT); return result.exitCode() == 0 && result.stdout().toLowerCase(Locale.ROOT).contains("inspector"); } - private String appiumVersion(Path appium) { + private String appiumVersion(Path appium, Path root, Path sdkRoot, Path avdHome) { if (appium == null) { return ""; } McpProcessRunner.ProcessResult result = runner.run(List.of(appium.toString(), "--version"), - appiumRoot(), appiumEnvironment(), QUICK_TIMEOUT); + root, appiumEnvironment(root, sdkRoot, avdHome), QUICK_TIMEOUT); return result.exitCode() == 0 ? result.stdout().trim() : ""; } @@ -634,18 +670,21 @@ private List executableCandidates(Path directory, String name) { } private Map androidEnvironment() { - return mergedEnvironment(Map.of( - "ANDROID_HOME", androidSdkRoot().toString(), - "ANDROID_SDK_ROOT", androidSdkRoot().toString(), - "ANDROID_AVD_HOME", androidAvdHome().toString())); + return androidEnvironment(androidSdkRoot(), androidAvdHome()); + } + + private Map androidEnvironment(Path sdkRoot, Path avdHome) { + return mergedEnvironment(Map.of("ANDROID_HOME", sdkRoot.toString(), + "ANDROID_SDK_ROOT", sdkRoot.toString(), "ANDROID_AVD_HOME", avdHome.toString())); } private Map appiumEnvironment() { - return mergedEnvironment(Map.of( - "APPIUM_HOME", appiumRoot().resolve("home").toString(), - "ANDROID_HOME", androidSdkRoot().toString(), - "ANDROID_SDK_ROOT", androidSdkRoot().toString(), - "ANDROID_AVD_HOME", androidAvdHome().toString())); + return appiumEnvironment(appiumRoot().resolve("home"), androidSdkRoot(), androidAvdHome()); + } + + private Map appiumEnvironment(Path root, Path sdkRoot, Path avdHome) { + return mergedEnvironment(Map.of("APPIUM_HOME", root.toString(), "ANDROID_HOME", sdkRoot.toString(), + "ANDROID_SDK_ROOT", sdkRoot.toString(), "ANDROID_AVD_HOME", avdHome.toString())); } private Map mergedEnvironment(Map additions) { @@ -667,11 +706,24 @@ private Path androidSdkRoot() { return configured == null ? toolRoot.resolve("android-sdk") : Path.of(configured); } + private Path statusAndroidSdkRoot(boolean sharedAndroid) { + String configured = firstNonBlank(environment.get("ANDROID_SDK_ROOT"), environment.get("ANDROID_HOME")); + if (configured != null) return Path.of(configured); + return sharedAndroid ? androidSetupOwner.sdkRoot(androidEmulatorApiLevel(), hostAndroidAbi()) + : toolRoot.resolve("android-sdk"); + } + private Path androidAvdHome() { String configured = environment.get("ANDROID_AVD_HOME"); return configured == null || configured.isBlank() ? toolRoot.resolve("android-avd") : Path.of(configured); } + private Path statusAndroidAvdHome(boolean sharedAndroid) { + String configured = environment.get("ANDROID_AVD_HOME"); + if (configured != null && !configured.isBlank()) return Path.of(configured); + return sharedAndroid ? androidSetupOwner.avdHome() : toolRoot.resolve("android-avd"); + } + private Path defaultUserAvdHome() { return Path.of(System.getProperty("user.home", ".")).resolve(".android").resolve("avd"); } @@ -688,6 +740,14 @@ private Path nodeBin() { return toolRoot.resolve("node").resolve(nodeArchiveName()).resolve(isWindows() ? "" : "bin"); } + private Path managedNodeBin() { + String platform = isWindows() ? "windows" : isMac() ? "macos" : "linux"; + String architecture = osArch.toLowerCase(Locale.ROOT).contains("aarch64") + || osArch.toLowerCase(Locale.ROOT).contains("arm64") ? "arm64" : "x64"; + return toolRoot.resolve("node").resolve(nodeLtsVersion()).resolve(platform + '-' + architecture) + .resolve(isWindows() ? "" : "bin"); + } + private Path sdkManagerCommand() { Path bin = androidSdkRoot().resolve("cmdline-tools").resolve("latest").resolve("bin"); return bin.resolve(isWindows() ? "sdkmanager.bat" : "sdkmanager"); diff --git a/shaft-mcp/src/test/java/com/shaft/mcp/InfrastructureMcpServiceTest.java b/shaft-mcp/src/test/java/com/shaft/mcp/InfrastructureMcpServiceTest.java index 24ed12e7b32..e9a58ecc603 100644 --- a/shaft-mcp/src/test/java/com/shaft/mcp/InfrastructureMcpServiceTest.java +++ b/shaft-mcp/src/test/java/com/shaft/mcp/InfrastructureMcpServiceTest.java @@ -43,7 +43,7 @@ class InfrastructureMcpServiceTest { void planReturnsTheExactCoordinatorPlanAndPolicy() { InfrastructureSetupService coordinator = mock(InfrastructureSetupService.class); SetupPlan plan = plan(SetupProfile.OCR); - when(coordinator.plan(any(), any())).thenReturn(plan); + when(coordinator.plan(any(SetupOptions.class), any(SetupSelection.class))).thenReturn(plan); InfrastructureMcpService service = new InfrastructureMcpService(coordinator); McpSetupPlanResult result = service.setupPlan(request("OCR", "MANAGED", List.of("fra", "deu"))); @@ -63,7 +63,8 @@ void installRecoversOcrSelectionFromTheReviewedPlan() throws Exception { InfrastructureSetupService coordinator = mock(InfrastructureSetupService.class); SetupPlan plan = plan(SetupProfile.OCR); SetupReceipt receipt = new SetupReceipt(plan.digest(), Instant.EPOCH, plan.actions()); - when(coordinator.install(any(), any(), any(), any())).thenReturn(receipt); + when(coordinator.install(any(SetupPlan.class), any(SetupApproval.class), any(SetupOptions.class), + any(SetupSelection.class))).thenReturn(receipt); InfrastructureMcpService service = new InfrastructureMcpService(coordinator); SetupReceipt result = service.setupInstall( @@ -86,7 +87,7 @@ void componentSelectionForAnUnrelatedProfileFailsBeforeCoordinatorCallback() { assertThrows(IllegalArgumentException.class, () -> service.setupPlan(request("REPORTING", "MANAGED", List.of("fra")))); - verify(coordinator, never()).plan(any(), any()); + verify(coordinator, never()).plan(any(SetupOptions.class), any(SetupSelection.class)); } @Test @@ -129,7 +130,8 @@ void malformedPlanAndSelectionDriftFailBeforeInstallCallback() throws Exception com.shaft.infrastructure.SetupPlanJson.write(plan), plan.digest(), List.of(), request("OCR", "MANAGED", List.of("deu")))); - verify(coordinator, never()).install(any(), any(), any(), any()); + verify(coordinator, never()).install(any(SetupPlan.class), any(SetupApproval.class), any(SetupOptions.class), + any(SetupSelection.class)); } @Test @@ -144,7 +146,7 @@ void invalidRootPairFailsBeforePlanningCallback() { () -> service.setupPlan(request)); assertTrue(failure.getMessage().contains("supplied together")); - verify(coordinator, never()).plan(any(), any()); + verify(coordinator, never()).plan(any(SetupOptions.class), any(SetupSelection.class)); } @Test diff --git a/shaft-mcp/src/test/java/com/shaft/mcp/McpMobileToolchainServiceTest.java b/shaft-mcp/src/test/java/com/shaft/mcp/McpMobileToolchainServiceTest.java index 4f70a198f77..63722fd5d67 100644 --- a/shaft-mcp/src/test/java/com/shaft/mcp/McpMobileToolchainServiceTest.java +++ b/shaft-mcp/src/test/java/com/shaft/mcp/McpMobileToolchainServiceTest.java @@ -1,6 +1,9 @@ package com.shaft.mcp; import com.shaft.properties.internal.Internal; +import com.shaft.infrastructure.ManagedEnvironment; +import com.shaft.infrastructure.SetupProfile; +import com.shaft.infrastructure.SetupReceipt; import org.aeonbits.owner.ConfigFactory; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -8,12 +11,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; class McpMobileToolchainServiceTest { @@ -69,6 +75,52 @@ void statusDiscoversAndroidDevicesCachedAvdsAndInspector() throws Exception { assertEquals("", diagnostic(status, "appium-inspector-plugin").detectedVersion()); } + @Test + void androidStatusDiscoversTheExactSharedInfrastructureLayout(@TempDir Path root) throws Exception { + Path toolRoot = Files.createDirectories(root.resolve("tools")); + Path sdk = Files.createDirectories(toolRoot.resolve("android-sdk/15859902-api36-x86_64")); + Path avdHome = Files.createDirectories(toolRoot.resolve("android-avd")); + create(sdk.resolve("platform-tools"), "adb.exe"); + create(sdk.resolve("emulator"), "emulator.exe"); + create(sdk.resolve("cmdline-tools/latest/bin"), "sdkmanager.bat"); + create(sdk.resolve("cmdline-tools/latest/bin"), "avdmanager.bat"); + Path node = toolRoot.resolve("node").resolve(INTERNAL.nodeLtsVersion()).resolve("windows-x64"); + create(node, "node.exe"); + create(node, "npm.cmd"); + Path appium = toolRoot.resolve("appium").resolve(INTERNAL.appiumServerVersion()); + create(appium.resolve("node_modules/.bin"), "appium.cmd"); + Files.createDirectories(appium.resolve("node_modules/appium-inspector-plugin")); + Files.createDirectories(avdHome.resolve("Shared_Pixel.avd")); + FakeRunner runner = new FakeRunner(); + runner.appiumPluginOutput = "inspector\n"; + runner.appiumVersionOutput = INTERNAL.appiumServerVersion() + "\n"; + McpMobileToolchainService.AndroidSetupOwner owner = new McpMobileToolchainService.AndroidSetupOwner() { + @Override public Path sdkRoot(int apiLevel, String abi) { return sdk; } + @Override public Path avdHome() { return avdHome; } + @Override public void install(McpAndroidEmulatorProposal proposal) { } + @Override public ManagedEnvironment start(McpAndroidEmulatorProposal proposal) { + throw new UnsupportedOperationException(); + } + }; + McpMobileToolchainService service = new McpMobileToolchainService(runner, Map.of("PATH", ""), + toolRoot, "Windows 11", "amd64", owner); + + McpMobileToolchainStatus status = service.status("Android"); + + assertEquals(sdk, status.androidSdkRoot()); + assertEquals(avdHome, status.androidAvdHome()); + assertEquals(appium, status.appiumRoot()); + assertTrue(status.nodeAvailable()); + assertTrue(status.npmAvailable()); + assertTrue(status.appiumAvailable()); + assertTrue(status.appiumInspectorAvailable()); + assertTrue(status.adbAvailable()); + assertTrue(status.emulatorAvailable()); + assertTrue(status.sdkManagerAvailable()); + assertTrue(status.avdManagerAvailable()); + assertTrue(status.cachedAndroidEmulators().contains("Shared_Pixel")); + } + @Test void statusReportsRepairableDiagnosticsForMissingAndroidToolchain() { Path toolRoot = temp.resolve("tools"); @@ -127,6 +179,7 @@ void defaultAndroidProposalUsesPortableCacheAndHostAbi() { assertEquals(4096, proposal.ramMb()); assertTrue(proposal.sdkRoot().toString().contains("android-sdk")); assertTrue(proposal.sdkPackages().contains("platforms;android-36")); + assertTrue(proposal.sdkPackages().contains("build-tools;36.0.0")); assertTrue(proposal.commands().stream().anyMatch(command -> command.contains("sdkmanager"))); } @@ -170,7 +223,7 @@ void toolchainStatusReturnsNormalResultWhenPropertiesInitialized() throws Except } @Test - void ensureAppiumInstallsPinnedPackagesWithCurrentCliSyntax() throws Exception { + void androidEnsureAppiumDoesNotOwnMutableNpmOrExtensionInstallation() throws Exception { Path toolRoot = Files.createDirectories(temp.resolve("tools")); create(toolRoot.resolve(windowsNodeArchive()), "npm.cmd"); create(toolRoot.resolve("appium/node_modules/.bin"), "appium.cmd"); @@ -180,20 +233,54 @@ void ensureAppiumInstallsPinnedPackagesWithCurrentCliSyntax() throws Exception { service.ensureAppium("Android"); - assertTrue(runner.commands.stream().anyMatch(command -> - command.contains("appium@" + INTERNAL.appiumServerVersion()))); - assertTrue(runner.commands.stream().anyMatch(command -> command.equals(List.of( - toolRoot.resolve("appium/node_modules/.bin/appium.cmd").toString(), - "driver", - "install", - "--source=npm", - "appium-uiautomator2-driver@" + INTERNAL.appiumUiAutomator2DriverVersion())))); - assertTrue(runner.commands.stream().anyMatch(command -> command.equals(List.of( - toolRoot.resolve("appium/node_modules/.bin/appium.cmd").toString(), - "plugin", - "install", - "--source=npm", - "appium-inspector-plugin@" + INTERNAL.appiumInspectorPluginVersion())))); + assertTrue(runner.commands.isEmpty()); + } + + @Test + void confirmedAndroidProposalDoesNotRunLegacySdkmanagerOrLicenseCommands() throws Exception { + Path toolRoot = Files.createDirectories(temp.resolve("tools")); + create(toolRoot.resolve("android-sdk/cmdline-tools/latest/bin"), "sdkmanager.bat"); + create(toolRoot.resolve("android-sdk/cmdline-tools/latest/bin"), "avdmanager.bat"); + FakeRunner runner = new FakeRunner(); + java.util.concurrent.atomic.AtomicInteger sharedInstalls = new java.util.concurrent.atomic.AtomicInteger(); + McpMobileToolchainService service = new McpMobileToolchainService(runner, + Map.of("PATH", ""), toolRoot, "Windows 11", "amd64", new McpMobileToolchainService.AndroidSetupOwner() { + @Override public Path sdkRoot(int apiLevel, String abi) { return toolRoot.resolve("shared-sdk"); } + @Override public Path avdHome() { return toolRoot.resolve("shared-avd"); } + @Override public void install(McpAndroidEmulatorProposal proposal) { sharedInstalls.incrementAndGet(); } + @Override public ManagedEnvironment start(McpAndroidEmulatorProposal proposal) { + throw new UnsupportedOperationException(); + } + }); + McpAndroidEmulatorProposal proposal = service.defaultAndroidProposal("", 0, "", "", "", 0, 0); + + service.ensureAndroidEmulator(proposal); + + assertTrue(runner.commands.isEmpty()); + assertEquals(1, sharedInstalls.get()); + } + + @Test + void androidRuntimeStartIsOwnedBySharedInfrastructureWithoutLegacyProcessLaunch() throws Exception { + Path toolRoot = Files.createDirectories(temp.resolve("tools")); + FakeRunner runner = new FakeRunner(); + ManagedEnvironment expected = new ManagedEnvironment(SetupProfile.MOBILE_ANDROID, + new SetupReceipt("digest", Instant.EPOCH, List.of()), + Optional.of(java.net.URI.create("http://127.0.0.1:4723/")), + Map.of("ANDROID_SERIAL", "emulator-5554"), () -> { }); + McpMobileToolchainService service = new McpMobileToolchainService(runner, + Map.of("PATH", ""), toolRoot, "Windows 11", "amd64", new McpMobileToolchainService.AndroidSetupOwner() { + @Override public Path sdkRoot(int apiLevel, String abi) { return toolRoot.resolve("shared-sdk"); } + @Override public Path avdHome() { return toolRoot.resolve("shared-avd"); } + @Override public void install(McpAndroidEmulatorProposal proposal) { } + @Override public ManagedEnvironment start(McpAndroidEmulatorProposal proposal) { return expected; } + }); + McpAndroidEmulatorProposal proposal = service.defaultAndroidProposal("", 0, "", "", "", 0, 0); + + ManagedEnvironment actual = service.startAndroidRuntime(proposal); + + assertSame(expected, actual); + assertTrue(runner.commands.isEmpty()); } private static String windowsNodeArchive() { diff --git a/tests/scripts/test_android_infrastructure_boundary.py b/tests/scripts/test_android_infrastructure_boundary.py new file mode 100644 index 00000000000..bd21c0b8a49 --- /dev/null +++ b/tests/scripts/test_android_infrastructure_boundary.py @@ -0,0 +1,49 @@ +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +class AndroidInfrastructureBoundaryTest(unittest.TestCase): + def test_shared_infrastructure_is_the_only_android_mutation_and_lifecycle_owner(self): + mcp = (ROOT / "shaft-mcp/src/main/java/com/shaft/mcp/McpMobileToolchainService.java").read_text(encoding="utf-8") + inspector = (ROOT / "shaft-mcp/src/main/java/com/shaft/mcp/McpMobileInspectorRecordingService.java").read_text(encoding="utf-8") + + self.assertIn("InfrastructureSetupService", mcp) + self.assertIn("ManagedEnvironment", inspector) + self.assertNotIn("ensureAndroidCommandLineTools", mcp) + self.assertNotIn("startAndroidEmulator", mcp) + self.assertNotIn("--relaxed-security", mcp) + + def test_pr_gate_cannot_omit_android_or_packaged_cli_changes(self): + workflow = (ROOT / ".github/workflows/pr-gate.yml").read_text(encoding="utf-8") + + self.assertGreaterEqual(workflow.count("- 'shaft-infrastructure/**'"), 2) + self.assertIn("tests.scripts.test_android_infrastructure_boundary", workflow) + self.assertIn("setup plan --profile MOBILE_ANDROID", workflow) + self.assertIn("android-sdk-license", workflow) + self.assertIn('android_plan="$RUNNER_TEMP/shaft-android-plan.json"', workflow) + self.assertIn("open(sys.argv[1])", workflow) + self.assertNotIn('android_plan="$(mktemp', workflow) + + def test_real_acceptance_is_gated_and_proves_uiautomator2_and_aapt2(self): + workflow = (ROOT / ".github/workflows/e2eLocalTests.yml").read_text(encoding="utf-8") + acceptance = (ROOT / "shaft-engine/src/test/java/testPackage/ManagedAndroidE2ETest.java").read_text(encoding="utf-8") + + self.assertIn("Ubuntu_Managed_Android", workflow) + self.assertIn("runManagedAndroidE2E=true", workflow) + self.assertIn("setup install", workflow) + self.assertIn("setup start --plan", workflow) + self.assertIn("setup stop --profile MOBILE_ANDROID", workflow) + self.assertGreaterEqual(workflow.count("mobile-android-runtime.json"), 3) + self.assertIn("--accept-license android-sdk-license", workflow) + self.assertIn("mobile-android-install.log", workflow) + self.assertIn("sudo apt-get install --yes libpulse0", workflow) + self.assertIn("UiAutomator2Options", acceptance) + self.assertIn("aapt2", acceptance) + self.assertIn("getPageSource", acceptance) + + +if __name__ == "__main__": + unittest.main()