Integrate Playwright for Configuration Analyzer HTML report testing

- Add playwright as a test dependency.
- Configure headless Chrome dependency.
- Create PlaywrightTestBase and BlastRadiusInspector to hide browser setup.
- Add BlastRadiusPlaywrightTest with comprehensive E2E tests for report rendering and UI features (search, tabs, details drilldown).
- Update .gitignore to exclude downloaded chrome binaries but allow sha1 files.

Change-Id: I4220aa636f6f23d47cf741a5ee03e5f3bbe46e73
diff --git a/.gitignore b/.gitignore
index 9e585c1..195e571 100644
--- a/.gitignore
+++ b/.gitignore
@@ -83,6 +83,8 @@
 third_party/cf_segments.tar.gz
 third_party/chrome/*
 third_party/chrome/*.tar.gz
+third_party/chrome_headless/*/
+third_party/chrome_headless/*.tar.gz
 third_party/classlib
 third_party/classlib.tar.gz
 third_party/core-lambda-stubs
diff --git a/d8_r8/commonBuildSrc/src/main/kotlin/DependenciesPlugin.kt b/d8_r8/commonBuildSrc/src/main/kotlin/DependenciesPlugin.kt
index c507534..c7857c4 100644
--- a/d8_r8/commonBuildSrc/src/main/kotlin/DependenciesPlugin.kt
+++ b/d8_r8/commonBuildSrc/src/main/kotlin/DependenciesPlugin.kt
@@ -369,7 +369,7 @@
   public const val ddmLibVersion = "32.2.0"
   public const val errorproneVersion = "2.18.0"
   public const val fastUtilVersion = "7.2.1"
-  public const val gsonVersion = "2.10.1"
+  public const val gsonVersion = "2.14.0"
   public const val guavaVersion = "32.1.2-jre"
   public const val javassist = "3.29.2-GA"
   public const val junitJupiterVersion = "5.14.3"
@@ -377,9 +377,10 @@
   public const val kotlinVersion = "1.9.20"
   public const val kotlinMetadataVersion = "2.3.10"
   public const val mockito = "2.10.0"
-  public const val testRetry = "1.6.4"
-  public const val smaliVersion = "3.0.3"
+  public const val playwrightVersion = "1.60.0"
   public const val protobufVersion = "4.33.5"
+  public const val smaliVersion = "3.0.3"
+  public const val testRetry = "1.6.4"
   public const val zipflingerVersion = "9.0.0"
 }
 
@@ -424,15 +425,18 @@
     "org.jetbrains.kotlin:kotlin-reflect:${Versions.kotlinVersion}"
   }
   public val mockito: String by lazy { "org.mockito:mockito-core:${Versions.mockito}" }
-  public val testretry: String by lazy {
-    "org.gradle.test-retry-gradle-plugin:${Versions.testRetry}"
+  public val playwright: String by lazy {
+    "com.microsoft.playwright:playwright:${Versions.playwrightVersion}"
+  }
+  public val protobuf: String by lazy {
+    "com.google.protobuf:protobuf-java:${Versions.protobufVersion}"
   }
   public val smali: String by lazy { "com.android.tools.smali:smali:${Versions.smaliVersion}" }
   public val smaliUtil: String by lazy {
     "com.android.tools.smali:smali-util:${Versions.smaliVersion}"
   }
-  public val protobuf: String by lazy {
-    "com.google.protobuf:protobuf-java:${Versions.protobufVersion}"
+  public val testretry: String by lazy {
+    "org.gradle.test-retry-gradle-plugin:${Versions.testRetry}"
   }
   public val zipflinger: String by lazy { "com.android:zipflinger:${Versions.zipflingerVersion}" }
 
@@ -513,6 +517,7 @@
       Paths.get("third_party", "opensource-apps", "chrome").toFile(),
       Paths.get("third_party", "opensource-apps", "chrome.tar.gz.sha1").toFile(),
     )
+  public val chromeHeadless: ThirdPartyDependency = getThirdPartyChromeHeadless()
   public val compilerApi: ThirdPartyDependency =
     ThirdPartyDependency(
       "compiler-api",
@@ -1093,6 +1098,25 @@
   )
 }
 
+private fun getThirdPartyChromeHeadless(): ThirdPartyDependency {
+  val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
+  val subFolder =
+    when {
+      os.isLinux -> "linux"
+      os.isMacOsX -> "mac"
+      else -> {
+        assert(os.isWindows)
+        "windows"
+      }
+    }
+  return ThirdPartyDependency(
+    "chrome_headless",
+    Paths.get("third_party", "chrome_headless", subFolder).toFile(),
+    Paths.get("third_party", "chrome_headless", "$subFolder.tar.gz.sha1").toFile(),
+    testOnly = true,
+  )
+}
+
 private fun getJdks(): List<ThirdPartyDependency> {
   val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
   if (os.isLinux || os.isMacOsX) {
diff --git a/d8_r8/test_modules/testbase/build.gradle.kts b/d8_r8/test_modules/testbase/build.gradle.kts
index c85d365..650aad8 100644
--- a/d8_r8/test_modules/testbase/build.gradle.kts
+++ b/d8_r8/test_modules/testbase/build.gradle.kts
@@ -62,9 +62,10 @@
   implementation(Deps.javassist)
   implementation(Deps.junitJupiter)
   implementation(Deps.junitVintageEngine)
-  implementation(Deps.kotlinStdLib)
-  implementation(Deps.kotlinReflect)
   implementation(Deps.kotlinMetadata)
+  implementation(Deps.kotlinReflect)
+  implementation(Deps.kotlinStdLib)
+  implementation(Deps.playwright)
   implementation(Deps.ddmLib)
   implementation(resolve(ThirdPartyDeps.jasmin, "jasmin-2.4.jar"))
   implementation(resolve(ThirdPartyDeps.jdwpTests, "apache-harmony-jdwp-tests-host.jar"))
diff --git a/d8_r8/test_modules/tests_java_8/build.gradle.kts b/d8_r8/test_modules/tests_java_8/build.gradle.kts
index 3040da9..42c44a3 100644
--- a/d8_r8/test_modules/tests_java_8/build.gradle.kts
+++ b/d8_r8/test_modules/tests_java_8/build.gradle.kts
@@ -86,11 +86,11 @@
   implementation(project(":resourceshrinker", "resourceshrinkerDepsJar"))
   implementation(project(":testbase"))
   implementation(project(":testbase", "depsJar"))
-
   // For each child project, add its test classes to the test class configuration.
   childProjects.values.forEach { childProject ->
     partialTestClassesScope(project(childProject.path, "partialTestClasses"))
   }
+  implementation(Deps.playwright)
 }
 
 fun testDependencies(): FileCollection {
diff --git a/src/main/java/com/android/tools/r8/assistant/postprocessing/ReflectiveOperationJsonParser.java b/src/main/java/com/android/tools/r8/assistant/postprocessing/ReflectiveOperationJsonParser.java
index 4888a0d..093a59c 100644
--- a/src/main/java/com/android/tools/r8/assistant/postprocessing/ReflectiveOperationJsonParser.java
+++ b/src/main/java/com/android/tools/r8/assistant/postprocessing/ReflectiveOperationJsonParser.java
@@ -32,7 +32,7 @@
       if (line.trim().isEmpty()) {
         continue;
       }
-      JsonObject event = new JsonParser().parse(line).getAsJsonObject();
+      JsonObject event = JsonParser.parseString(line).getAsJsonObject();
       if (event.isEmpty()) {
         continue;
       }
diff --git a/src/main/java/com/android/tools/r8/dex/ClassesChecksum.java b/src/main/java/com/android/tools/r8/dex/ClassesChecksum.java
index a69e843..d394fa0 100644
--- a/src/main/java/com/android/tools/r8/dex/ClassesChecksum.java
+++ b/src/main/java/com/android/tools/r8/dex/ClassesChecksum.java
@@ -61,7 +61,7 @@
         && dexString.content[2] == PREFIX_CHAR2) {
       String str = dexString.toString().substring(3);
       try {
-        JsonElement result = new JsonParser().parse(str);
+        JsonElement result = JsonParser.parseString(str);
         if (result.isJsonObject()) {
           append(result.getAsJsonObject());
         }
diff --git a/src/main/java/com/android/tools/r8/dex/Marker.java b/src/main/java/com/android/tools/r8/dex/Marker.java
index 40aa9a7..1297330 100644
--- a/src/main/java/com/android/tools/r8/dex/Marker.java
+++ b/src/main/java/com/android/tools/r8/dex/Marker.java
@@ -319,7 +319,7 @@
 
   private static Marker internalParse(Tool tool, String str) {
     try {
-      JsonElement result = new JsonParser().parse(str);
+      JsonElement result = JsonParser.parseString(str);
       if (result.isJsonObject()) {
         return new Marker(tool, result.getAsJsonObject());
       }
diff --git a/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/DesugaredLibrarySpecificationParser.java b/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/DesugaredLibrarySpecificationParser.java
index e62fade..9e5cd84 100644
--- a/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/DesugaredLibrarySpecificationParser.java
+++ b/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/DesugaredLibrarySpecificationParser.java
@@ -46,8 +46,7 @@
     JsonObject jsonConfig;
     try {
       jsonConfigString = stringResource.getString();
-      JsonParser parser = new JsonParser();
-      jsonConfig = parser.parse(jsonConfigString).getAsJsonObject();
+      jsonConfig = JsonParser.parseString(jsonConfigString).getAsJsonObject();
     } catch (Exception e) {
       throw reporter.fatalError(new ExceptionDiagnostic(e, origin));
     }
diff --git a/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/humanspecification/HumanDesugaredLibrarySpecificationParser.java b/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/humanspecification/HumanDesugaredLibrarySpecificationParser.java
index d993797..b558940 100644
--- a/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/humanspecification/HumanDesugaredLibrarySpecificationParser.java
+++ b/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/humanspecification/HumanDesugaredLibrarySpecificationParser.java
@@ -160,8 +160,7 @@
     String jsonConfigString;
     try {
       jsonConfigString = stringResource.getString();
-      JsonParser parser = new JsonParser();
-      jsonConfig = parser.parse(jsonConfigString).getAsJsonObject();
+      jsonConfig = JsonParser.parseString(jsonConfigString).getAsJsonObject();
     } catch (Exception e) {
       throw reporter.fatalError(new ExceptionDiagnostic(e, origin));
     }
diff --git a/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/legacyspecification/LegacyDesugaredLibrarySpecificationParser.java b/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/legacyspecification/LegacyDesugaredLibrarySpecificationParser.java
index 8c8e2a8..f9b1acc 100644
--- a/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/legacyspecification/LegacyDesugaredLibrarySpecificationParser.java
+++ b/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/legacyspecification/LegacyDesugaredLibrarySpecificationParser.java
@@ -15,8 +15,8 @@
 import com.android.tools.r8.utils.AndroidApiLevel;
 import com.android.tools.r8.utils.ExceptionDiagnostic;
 import com.android.tools.r8.utils.Reporter;
-import com.android.tools.r8.utils.internal.SemanticVersion;
 import com.android.tools.r8.utils.StringDiagnostic;
+import com.android.tools.r8.utils.internal.SemanticVersion;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
@@ -137,8 +137,7 @@
     String jsonConfigString;
     try {
       jsonConfigString = stringResource.getString();
-      JsonParser parser = new JsonParser();
-      jsonConfig = parser.parse(jsonConfigString).getAsJsonObject();
+      jsonConfig = JsonParser.parseString(jsonConfigString).getAsJsonObject();
     } catch (Exception e) {
       throw reporter.fatalError(new ExceptionDiagnostic(e, origin));
     }
diff --git a/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/machinespecification/MachineDesugaredLibrarySpecificationParser.java b/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/machinespecification/MachineDesugaredLibrarySpecificationParser.java
index a7b9165..fcb9916 100644
--- a/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/machinespecification/MachineDesugaredLibrarySpecificationParser.java
+++ b/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/machinespecification/MachineDesugaredLibrarySpecificationParser.java
@@ -156,8 +156,7 @@
     String jsonConfigString;
     try {
       jsonConfigString = stringResource.getString();
-      JsonParser parser = new JsonParser();
-      jsonConfig = parser.parse(jsonConfigString).getAsJsonObject();
+      jsonConfig = JsonParser.parseString(jsonConfigString).getAsJsonObject();
     } catch (Exception e) {
       throw reporter.fatalError(new ExceptionDiagnostic(e, origin));
     }
diff --git a/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/specificationconversion/DesugaredLibraryConverter.java b/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/specificationconversion/DesugaredLibraryConverter.java
index d416e45..77b02ca 100644
--- a/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/specificationconversion/DesugaredLibraryConverter.java
+++ b/src/main/java/com/android/tools/r8/ir/desugar/desugaredlibrary/specificationconversion/DesugaredLibraryConverter.java
@@ -18,10 +18,10 @@
 import com.android.tools.r8.ir.desugar.desugaredlibrary.machinespecification.MultiAPILevelMachineDesugaredLibrarySpecification;
 import com.android.tools.r8.ir.desugar.desugaredlibrary.machinespecification.MultiAPILevelMachineDesugaredLibrarySpecificationJsonExporter;
 import com.android.tools.r8.utils.AndroidApp;
-import com.android.tools.r8.utils.internal.Box;
 import com.android.tools.r8.utils.ExceptionDiagnostic;
 import com.android.tools.r8.utils.InternalOptions;
 import com.android.tools.r8.utils.ThreadUtils;
+import com.android.tools.r8.utils.internal.Box;
 import com.android.tools.r8.utils.timing.Timing;
 import com.google.common.collect.ImmutableSet;
 import com.google.gson.JsonObject;
@@ -95,8 +95,7 @@
     JsonObject jsonConfig;
     try {
       String jsonConfigString = jsonResource.getString();
-      JsonParser parser = new JsonParser();
-      jsonConfig = parser.parse(jsonConfigString).getAsJsonObject();
+      jsonConfig = JsonParser.parseString(jsonConfigString).getAsJsonObject();
     } catch (Exception e) {
       throw options.reporter.fatalError(new ExceptionDiagnostic(e, jsonResource.getOrigin()));
     }
diff --git a/src/main/java/com/android/tools/r8/naming/ProguardMapReader.java b/src/main/java/com/android/tools/r8/naming/ProguardMapReader.java
index 1d42a9f..4d287be 100644
--- a/src/main/java/com/android/tools/r8/naming/ProguardMapReader.java
+++ b/src/main/java/com/android/tools/r8/naming/ProguardMapReader.java
@@ -72,7 +72,6 @@
 public class ProguardMapReader implements AutoCloseable {
 
   private final LineReader reader;
-  private final JsonParser jsonParser = new JsonParser();
   private final DiagnosticsHandler diagnosticsHandler;
   private final boolean allowEmptyMappedRanges;
   private final boolean allowExperimentalMapping;
@@ -890,7 +889,7 @@
       while (line.charAt(firstIndex) != '{') {
         firstIndex++;
       }
-      return jsonParser.parse(line.substring(firstIndex)).getAsJsonObject();
+      return JsonParser.parseString(line.substring(firstIndex)).getAsJsonObject();
     } catch (com.google.gson.JsonSyntaxException ex) {
       // An info message is reported in MappingInformation.
       return null;
diff --git a/src/test/java/com/android/tools/r8/MarkersTest.java b/src/test/java/com/android/tools/r8/MarkersTest.java
index 88eff76..a667860 100644
--- a/src/test/java/com/android/tools/r8/MarkersTest.java
+++ b/src/test/java/com/android/tools/r8/MarkersTest.java
@@ -93,8 +93,7 @@
     L8.run(builder.build());
     Collection<Marker> markers = ExtractMarkerUtils.extractMarkersFromFile(output);
     JsonObject jsonObject =
-        new JsonParser()
-            .parse(
+        JsonParser.parseString(
                 FileUtils.readTextFile(
                     libraryDesugaringSpecification.getSpecification(), Charsets.UTF_8))
             .getAsJsonObject();
diff --git a/src/test/java/com/android/tools/r8/keepradius/ui/KeepRadiusPlaywrightTest.java b/src/test/java/com/android/tools/r8/keepradius/ui/KeepRadiusPlaywrightTest.java
new file mode 100644
index 0000000..e19a2e5
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/keepradius/ui/KeepRadiusPlaywrightTest.java
@@ -0,0 +1,73 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.keepradius.ui;
+
+import com.android.tools.r8.PlaywrightTestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class KeepRadiusPlaywrightTest extends PlaywrightTestBase {
+
+  @Parameter(0)
+  public TestParameters parameters;
+
+  @Parameters(name = "{0}")
+  public static TestParametersCollection data() {
+    return getTestParameters().withNoneRuntime().build();
+  }
+
+  @Test
+  public void testHtmlReportRendering() throws Exception {
+    testForR8(Backend.DEX)
+        .addProgramClasses(Main.class)
+        .addKeepAllClassesRule()
+        .enableConfigurationAnalysisReport()
+        .compile()
+        .inspectKeepRadiusHtmlReport(
+            page,
+            inspector -> {
+              inspector
+                  .assertTitle("R8 Configuration Analyzer")
+                  .assertTotalObfuscationNotEmpty()
+                  // 2. Assert table contains the keep rule
+                  .assertTableContains("-keep class ** { *; }")
+                  // 3. Search for "keep"
+                  .search("keep")
+                  .assertTableContains("-keep class ** { *; }")
+                  // Search for "nonexistent"
+                  .search("nonexistent")
+                  .assertTableContains("No results found.")
+                  // Clear search
+                  .search("")
+                  // 4. Click the "Unused" tab (should be empty for our keep rule)
+                  .clickLensTab("Unused")
+                  .assertTableContains("No results found.")
+                  // Go back to "All"
+                  .clickLensTab("All")
+                  // 5. Click the rule row to drill down to details
+                  .clickRowWithText("-keep class ** { *; }")
+                  // Assert details view is visible
+                  .assertVisible("#details-view")
+                  .assertHidden("#report-view")
+                  // Assert Main class is kept in details
+                  .assertDetailsClassesContains(Main.class.getTypeName())
+                  // Go back
+                  .clickDetailsBackToSummary()
+                  .assertVisible("#report-view")
+                  .assertHidden("#details-view");
+            });
+  }
+
+  static class Main {
+    public static void main(String[] args) {
+      System.out.println("Hello");
+    }
+  }
+}
diff --git a/src/test/testbase/java/com/android/tools/r8/PlaywrightTestBase.java b/src/test/testbase/java/com/android/tools/r8/PlaywrightTestBase.java
new file mode 100644
index 0000000..faea90d
--- /dev/null
+++ b/src/test/testbase/java/com/android/tools/r8/PlaywrightTestBase.java
@@ -0,0 +1,139 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8;
+
+import com.microsoft.playwright.Browser;
+import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.Playwright;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.rules.TemporaryFolder;
+
+public class PlaywrightTestBase extends TestBase {
+
+  @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+  private static Playwright playwright;
+  private static Browser browser;
+  protected Page page;
+
+  @BeforeClass
+  public static void setUpBrowser() {
+    Path chromeExecutable = resolveChromeExecutable();
+
+    Map<String, String> env = new HashMap<>();
+    env.put("PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD", "1");
+
+    Playwright.CreateOptions createOptions = new Playwright.CreateOptions().setEnv(env);
+    playwright = Playwright.create(createOptions);
+
+    BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions();
+    if (chromeExecutable == null) {
+      throw new RuntimeException("chrome-headless-shell binary is missing");
+    }
+    launchOptions.setExecutablePath(chromeExecutable);
+    launchOptions.setHeadless(true);
+
+    browser = playwright.chromium().launch(launchOptions);
+  }
+
+  @Before
+  public void setUpPage() {
+    page = browser.newPage();
+  }
+
+  @After
+  public void tearDownPage() {
+    if (page != null) {
+      page.close();
+    }
+  }
+
+  @AfterClass
+  public static void tearDownBrowser() {
+    if (browser != null) {
+      browser.close();
+    }
+    if (playwright != null) {
+      playwright.close();
+    }
+  }
+
+  protected Page loadHtml(String html) throws IOException {
+    Path tempFile = tempFolder.newFile("temp_report.html").toPath();
+    Files.write(tempFile, html.getBytes(StandardCharsets.UTF_8));
+    page.navigate(tempFile.toUri().toString());
+    return page;
+  }
+
+  private static Path resolveChromeExecutable() {
+    Path chromeDir = Paths.get(ToolHelper.THIRD_PARTY_DIR, "chrome_headless");
+    if (!Files.exists(chromeDir)) {
+      return null;
+    }
+
+    boolean isMac = ToolHelper.isMac();
+    boolean isWindows = ToolHelper.isWindows();
+    boolean isLinux = ToolHelper.isLinux();
+
+    if (isMac) {
+      chromeDir = chromeDir.resolve("mac");
+    } else if (isLinux) {
+      chromeDir = chromeDir.resolve("linux");
+    } else if (isWindows) {
+      chromeDir = chromeDir.resolve("windows");
+    }
+
+    if (!Files.exists(chromeDir)) {
+      return null;
+    }
+
+    String[] executables = {
+      "chrome-headless-shell", "chrome", "chromium", "chrome-headless-shell.exe", "chrome.exe"
+    };
+
+    String macArch = "";
+    if (isMac) {
+      String arch = System.getProperty("os.arch").toLowerCase();
+      if (arch.contains("aarch64") || arch.contains("arm64")) {
+        macArch = "mac-arm64";
+      } else {
+        macArch = "mac-x64";
+      }
+    }
+    final String targetMacArch = macArch;
+
+    try {
+      return Files.walk(chromeDir, 3)
+          .filter(
+              p -> {
+                String name = p.getFileName().toString();
+                if (isMac && !p.toString().contains(targetMacArch)) {
+                  return false;
+                }
+                for (String exec : executables) {
+                  if (name.equalsIgnoreCase(exec) && Files.isExecutable(p)) {
+                    return true;
+                  }
+                }
+                return false;
+              })
+          .findFirst()
+          .orElse(null);
+    } catch (IOException e) {
+      return null;
+    }
+  }
+}
diff --git a/src/test/testbase/java/com/android/tools/r8/R8CompatTestBuilder.java b/src/test/testbase/java/com/android/tools/r8/R8CompatTestBuilder.java
index b541d2b..98bff3e 100644
--- a/src/test/testbase/java/com/android/tools/r8/R8CompatTestBuilder.java
+++ b/src/test/testbase/java/com/android/tools/r8/R8CompatTestBuilder.java
@@ -8,8 +8,8 @@
 import com.android.tools.r8.benchmarks.BenchmarkResults;
 import com.android.tools.r8.shaking.ProguardConfigurationRule;
 import com.android.tools.r8.utils.AndroidApp;
-import com.android.tools.r8.utils.internal.Box;
 import com.android.tools.r8.utils.InternalOptions;
+import com.android.tools.r8.utils.internal.Box;
 import java.util.List;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
@@ -63,6 +63,7 @@
         resourceShrinkerOutput,
         resourceShrinkerOutputForFeatures,
         resourceShrinkerLogConsumer,
-        buildMetadata != null ? buildMetadata.get() : null);
+        buildMetadata != null ? buildMetadata.get() : null,
+        configurationAnalysisHtmlReport);
   }
 }
diff --git a/src/test/testbase/java/com/android/tools/r8/R8FullTestBuilder.java b/src/test/testbase/java/com/android/tools/r8/R8FullTestBuilder.java
index 48b5bdd..5063a38 100644
--- a/src/test/testbase/java/com/android/tools/r8/R8FullTestBuilder.java
+++ b/src/test/testbase/java/com/android/tools/r8/R8FullTestBuilder.java
@@ -8,8 +8,8 @@
 import com.android.tools.r8.benchmarks.BenchmarkResults;
 import com.android.tools.r8.shaking.ProguardConfigurationRule;
 import com.android.tools.r8.utils.AndroidApp;
-import com.android.tools.r8.utils.internal.Box;
 import com.android.tools.r8.utils.InternalOptions;
+import com.android.tools.r8.utils.internal.Box;
 import java.util.List;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
@@ -61,6 +61,7 @@
         resourceShrinkerOutput,
         resourceShrinkerOutputForFeatures,
         resourceShrinkerLogConsumer,
-        buildMetadata != null ? buildMetadata.get() : null);
+        buildMetadata != null ? buildMetadata.get() : null,
+        configurationAnalysisHtmlReport);
   }
 }
diff --git a/src/test/testbase/java/com/android/tools/r8/R8PartialTestBuilder.java b/src/test/testbase/java/com/android/tools/r8/R8PartialTestBuilder.java
index aa3686a..b2ab55e 100644
--- a/src/test/testbase/java/com/android/tools/r8/R8PartialTestBuilder.java
+++ b/src/test/testbase/java/com/android/tools/r8/R8PartialTestBuilder.java
@@ -10,12 +10,12 @@
 import com.android.tools.r8.partial.R8PartialCompilationConfiguration;
 import com.android.tools.r8.shaking.ProguardConfigurationRule;
 import com.android.tools.r8.utils.AndroidApp;
-import com.android.tools.r8.utils.internal.Box;
 import com.android.tools.r8.utils.InternalOptions;
 import com.android.tools.r8.utils.codeinspector.EnumUnboxingInspector;
 import com.android.tools.r8.utils.codeinspector.HorizontallyMergedClassesInspector;
 import com.android.tools.r8.utils.codeinspector.RepackagingInspector;
 import com.android.tools.r8.utils.codeinspector.VerticallyMergedClassesInspector;
+import com.android.tools.r8.utils.internal.Box;
 import com.android.tools.r8.utils.internal.exceptions.Unreachable;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -157,7 +157,8 @@
         resourceShrinkerOutput,
         resourceShrinkerOutputForFeatures,
         resourceShrinkerLogConsumer,
-        buildMetadata != null ? buildMetadata.get() : null);
+        buildMetadata != null ? buildMetadata.get() : null,
+        configurationAnalysisHtmlReport);
   }
 
   @Override
diff --git a/src/test/testbase/java/com/android/tools/r8/R8PartialTestCompileResult.java b/src/test/testbase/java/com/android/tools/r8/R8PartialTestCompileResult.java
index 7478fc4..eb6b071 100644
--- a/src/test/testbase/java/com/android/tools/r8/R8PartialTestCompileResult.java
+++ b/src/test/testbase/java/com/android/tools/r8/R8PartialTestCompileResult.java
@@ -9,6 +9,7 @@
 import com.android.tools.r8.shaking.CollectingGraphConsumer;
 import com.android.tools.r8.shaking.ProguardConfigurationRule;
 import com.android.tools.r8.utils.AndroidApp;
+import com.android.tools.r8.utils.internal.Box;
 import java.nio.file.Path;
 import java.util.HashMap;
 import java.util.List;
@@ -31,7 +32,8 @@
       Path resourceShrinkerOutput,
       HashMap<String, Path> resourceShrinkerOutputForFeatures,
       TestDebugConsumer resourceShrinkerLogConsumer,
-      R8BuildMetadata buildMetadata) {
+      R8BuildMetadata buildMetadata,
+      Box<String> configurationAnalysisHtmlReport) {
     super(
         state,
         outputMode,
@@ -47,7 +49,8 @@
         resourceShrinkerOutput,
         resourceShrinkerOutputForFeatures,
         resourceShrinkerLogConsumer,
-        buildMetadata);
+        buildMetadata,
+        configurationAnalysisHtmlReport);
   }
 
   @Override
diff --git a/src/test/testbase/java/com/android/tools/r8/R8TestBuilder.java b/src/test/testbase/java/com/android/tools/r8/R8TestBuilder.java
index 3aeed0b..d3b4b5b 100644
--- a/src/test/testbase/java/com/android/tools/r8/R8TestBuilder.java
+++ b/src/test/testbase/java/com/android/tools/r8/R8TestBuilder.java
@@ -4,6 +4,7 @@
 package com.android.tools.r8;
 
 import static com.android.tools.r8.R8TestBuilder.KeepAnnotationLibrary.ANDROIDX;
+import static org.junit.Assert.assertNull;
 
 import com.android.tools.r8.DexIndexedConsumer.ArchiveConsumer;
 import com.android.tools.r8.R8Command.Builder;
@@ -114,6 +115,7 @@
   Box<R8BuildMetadata> buildMetadata;
   private boolean androidPlatformBuild = false;
   private Box<StringConsumer> proguardMapConsumer = null;
+  Box<String> configurationAnalysisHtmlReport = null;
 
   @Override
   public boolean isR8TestBuilder() {
@@ -185,6 +187,22 @@
     if (buildMetadata != null) {
       builder.setBuildMetadataConsumer(buildMetadata::set);
     }
+    if (configurationAnalysisHtmlReport != null) {
+      builder.setConfigurationAnalysisHtmlReportConsumer(
+          new StringConsumer() {
+            private final StringBuilder sb = new StringBuilder();
+
+            @Override
+            public void accept(String string, DiagnosticsHandler handler) {
+              sb.append(string);
+            }
+
+            @Override
+            public void finished(DiagnosticsHandler handler) {
+              configurationAnalysisHtmlReport.set(sb.toString());
+            }
+          });
+    }
     StringBuilder pgConfOutput = wrapProguardConfigConsumer(builder);
     CR compileResult =
         internalCompileR8(
@@ -853,6 +871,12 @@
     return self();
   }
 
+  public T enableConfigurationAnalysisReport() {
+    assertNull(this.configurationAnalysisHtmlReport);
+    this.configurationAnalysisHtmlReport = new Box<>();
+    return self();
+  }
+
   public T setKeptGraphConsumer(GraphConsumer graphConsumer) {
     assert this.graphConsumer == null;
     builder.setKeptGraphConsumer(graphConsumer);
diff --git a/src/test/testbase/java/com/android/tools/r8/R8TestCompileResult.java b/src/test/testbase/java/com/android/tools/r8/R8TestCompileResult.java
index 8bd3dc3..75dd1ec 100644
--- a/src/test/testbase/java/com/android/tools/r8/R8TestCompileResult.java
+++ b/src/test/testbase/java/com/android/tools/r8/R8TestCompileResult.java
@@ -9,6 +9,7 @@
 import com.android.tools.r8.shaking.CollectingGraphConsumer;
 import com.android.tools.r8.shaking.ProguardConfigurationRule;
 import com.android.tools.r8.utils.AndroidApp;
+import com.android.tools.r8.utils.internal.Box;
 import java.nio.file.Path;
 import java.util.HashMap;
 import java.util.List;
@@ -30,7 +31,8 @@
       Path resourceShrinkerOutput,
       HashMap<String, Path> resourceShrinkerOutputForFeatures,
       TestDebugConsumer resourceShrinkerLogConsumer,
-      R8BuildMetadata buildMetadata) {
+      R8BuildMetadata buildMetadata,
+      Box<String> configurationAnalysisHtmlReport) {
     super(
         state,
         outputMode,
@@ -46,7 +48,8 @@
         resourceShrinkerOutput,
         resourceShrinkerOutputForFeatures,
         resourceShrinkerLogConsumer,
-        buildMetadata);
+        buildMetadata,
+        configurationAnalysisHtmlReport);
   }
 
   @Override
diff --git a/src/test/testbase/java/com/android/tools/r8/R8TestCompileResultBase.java b/src/test/testbase/java/com/android/tools/r8/R8TestCompileResultBase.java
index 673eb16..6075512 100644
--- a/src/test/testbase/java/com/android/tools/r8/R8TestCompileResultBase.java
+++ b/src/test/testbase/java/com/android/tools/r8/R8TestCompileResultBase.java
@@ -7,6 +7,7 @@
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
 
 import com.android.tools.r8.DexSegments.SegmentInfo;
 import com.android.tools.r8.TestRuntime.DexRuntime;
@@ -22,6 +23,7 @@
 import com.android.tools.r8.graph.DexEncodedMethod;
 import com.android.tools.r8.graph.DexProgramClass;
 import com.android.tools.r8.graph.DexType;
+import com.android.tools.r8.keepradius.ui.KeepRadiusInspector;
 import com.android.tools.r8.metadata.R8BuildMetadata;
 import com.android.tools.r8.profile.art.model.ExternalArtProfile;
 import com.android.tools.r8.profile.art.utils.ArtProfileInspector;
@@ -35,9 +37,11 @@
 import com.android.tools.r8.utils.codeinspector.CodeInspector;
 import com.android.tools.r8.utils.codeinspector.FoundClassSubject;
 import com.android.tools.r8.utils.graphinspector.GraphInspector;
+import com.android.tools.r8.utils.internal.Box;
 import com.android.tools.r8.utils.internal.FileUtils;
 import com.android.tools.r8.utils.internal.ThrowingBiConsumer;
 import com.android.tools.r8.utils.internal.ThrowingConsumer;
+import com.microsoft.playwright.Page;
 import it.unimi.dsi.fastutil.ints.Int2ReferenceLinkedOpenHashMap;
 import it.unimi.dsi.fastutil.ints.Int2ReferenceMap;
 import java.io.IOException;
@@ -63,6 +67,7 @@
   private final Map<String, Path> resourceShrinkerOutputForFeatures;
   private final TestDebugConsumer resourceShrinkerLogConsumer;
   private final R8BuildMetadata buildMetadata;
+  private final Box<String> configurationAnalysisHtmlReport;
 
   R8TestCompileResultBase(
       TestState state,
@@ -79,7 +84,8 @@
       Path resourceShrinkerOutput,
       HashMap<String, Path> resourceShrinkerOutputForFeatures,
       TestDebugConsumer resourceShrinkerLogConsumer,
-      R8BuildMetadata buildMetadata) {
+      R8BuildMetadata buildMetadata,
+      Box<String> configurationAnalysisHtmlReport) {
     super(state, app, minApiLevel, outputMode, libraryDesugaringTestConfiguration);
     this.proguardConfiguration = proguardConfiguration;
     this.syntheticProguardRules = syntheticProguardRules;
@@ -91,6 +97,7 @@
     this.resourceShrinkerOutputForFeatures = resourceShrinkerOutputForFeatures;
     this.resourceShrinkerLogConsumer = resourceShrinkerLogConsumer;
     this.buildMetadata = buildMetadata;
+    this.configurationAnalysisHtmlReport = configurationAnalysisHtmlReport;
   }
 
   public CR benchmarkResourceSize(BenchmarkResults results) throws IOException {
@@ -252,6 +259,21 @@
     return new GraphInspector(graphConsumer, inspector());
   }
 
+  public KeepRadiusInspector keepRadiusInspector(Page page) throws IOException {
+    assertNotNull(configurationAnalysisHtmlReport);
+    assertTrue(configurationAnalysisHtmlReport.isSet());
+    Path tempFile = state.getNewTempFolder().resolve("report.html");
+    FileUtils.writeTextFile(tempFile, configurationAnalysisHtmlReport.get());
+    page.navigate(tempFile.toUri().toString());
+    return new KeepRadiusInspector(page);
+  }
+
+  public <E extends Throwable> CR inspectKeepRadiusHtmlReport(
+      Page page, ThrowingConsumer<KeepRadiusInspector, E> consumer) throws E, IOException {
+    consumer.accept(keepRadiusInspector(page));
+    return self();
+  }
+
   public String getProguardConfiguration() {
     return proguardConfiguration;
   }
diff --git a/src/test/testbase/java/com/android/tools/r8/keepradius/ui/KeepRadiusInspector.java b/src/test/testbase/java/com/android/tools/r8/keepradius/ui/KeepRadiusInspector.java
new file mode 100644
index 0000000..98275ff
--- /dev/null
+++ b/src/test/testbase/java/com/android/tools/r8/keepradius/ui/KeepRadiusInspector.java
@@ -0,0 +1,86 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.keepradius.ui;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+
+import com.microsoft.playwright.Locator;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.options.BoundingBox;
+
+public class KeepRadiusInspector {
+
+  private final Page page;
+
+  public KeepRadiusInspector(Page page) {
+    this.page = page;
+  }
+
+  public KeepRadiusInspector assertTitle(String expected) {
+    assertThat(page).hasTitle(expected);
+    return this;
+  }
+
+  public KeepRadiusInspector assertTotalObfuscationNotEmpty() {
+    assertThat(page.locator("#total-obfuscation")).not().isEmpty();
+    return this;
+  }
+
+  public KeepRadiusInspector clickLensTab(String lens) {
+    page.click("#lens-tabs button[data-lens=\"" + lens + "\"]");
+    return this;
+  }
+
+  public KeepRadiusInspector search(String term) {
+    BoundingBox box = page.locator("#search-input").boundingBox();
+    if (box == null || box.width == 0) {
+      page.click("#search-icon-btn");
+    }
+    page.fill("#search-input", term);
+    return this;
+  }
+
+  public KeepRadiusInspector clickRowWithText(String text) {
+    page.locator("#table-data td")
+        .filter(new Locator.FilterOptions().setHasText(text))
+        .first()
+        .click();
+    return this;
+  }
+
+  public KeepRadiusInspector assertTableContains(String text) {
+    assertThat(page.locator("#table-data")).containsText(text);
+    return this;
+  }
+
+  public KeepRadiusInspector assertTableNotContains(String text) {
+    assertThat(page.locator("#table-data")).not().containsText(text);
+    return this;
+  }
+
+  public KeepRadiusInspector assertVisible(String selector) {
+    assertThat(page.locator(selector)).isVisible();
+    return this;
+  }
+
+  public KeepRadiusInspector assertHidden(String selector) {
+    assertThat(page.locator(selector)).isHidden();
+    return this;
+  }
+
+  public KeepRadiusInspector assertTableRowsCount(int expectedCount) {
+    assertThat(page.locator("#table-data tr")).hasCount(expectedCount);
+    return this;
+  }
+
+  public KeepRadiusInspector assertDetailsClassesContains(String className) {
+    assertThat(page.locator("#details-classes-content")).containsText(className);
+    return this;
+  }
+
+  public KeepRadiusInspector clickDetailsBackToSummary() {
+    page.click("#details-back-to-summary");
+    return this;
+  }
+}
diff --git a/third_party/chrome_headless/linux.tar.gz.sha1 b/third_party/chrome_headless/linux.tar.gz.sha1
new file mode 100644
index 0000000..dc59451
--- /dev/null
+++ b/third_party/chrome_headless/linux.tar.gz.sha1
@@ -0,0 +1 @@
+fd81a2a204f7528ca52a9cfb0a802027fa0ef9b0
\ No newline at end of file
diff --git a/third_party/chrome_headless/mac.tar.gz.sha1 b/third_party/chrome_headless/mac.tar.gz.sha1
new file mode 100644
index 0000000..32237b5
--- /dev/null
+++ b/third_party/chrome_headless/mac.tar.gz.sha1
@@ -0,0 +1 @@
+6a1bda8761ca6cfb377bde6c8eef8947150840c8
\ No newline at end of file
diff --git a/third_party/chrome_headless/windows.tar.gz.sha1 b/third_party/chrome_headless/windows.tar.gz.sha1
new file mode 100644
index 0000000..42f5f4e
--- /dev/null
+++ b/third_party/chrome_headless/windows.tar.gz.sha1
@@ -0,0 +1 @@
+616ed97744c5950fd6fec7a2c3df8211392958b1
\ No newline at end of file
diff --git a/third_party/dependencies.tar.gz.sha1 b/third_party/dependencies.tar.gz.sha1
index 62aca42..7ba547d 100644
--- a/third_party/dependencies.tar.gz.sha1
+++ b/third_party/dependencies.tar.gz.sha1
@@ -1 +1 @@
-ea3befd18630378e7d73b067a49c0623892675b7
\ No newline at end of file
+cdd0a821609f82f6101d4c3b6ccd42ee9ab78228
\ No newline at end of file
diff --git a/tools/create_local_maven_with_dependencies.py b/tools/create_local_maven_with_dependencies.py
index 307848a..e61b569 100755
--- a/tools/create_local_maven_with_dependencies.py
+++ b/tools/create_local_maven_with_dependencies.py
@@ -30,7 +30,8 @@
 # the dependencies untangled.
 KOTLIN_VERSION = '2.2.21'
 GUAVA_VERSION = '32.1.2-jre'
-GSON_VERSION = '2.10.1'
+GSON_VERSION = '2.14.0'
+PLAYWRIGHT_VERSION = '1.60.0'
 JAVASSIST_VERSION = '3.29.2-GA'
 MOCKITO_VERSION = '2.10.0'
 TEST_RETRY_VERSION = '1.6.4'
@@ -120,6 +121,8 @@
         version=JUNIT_JUPITER_VERSION),
     'org.junit.platform:junit-platform-launcher:{version}'.format(
         version=JUNIT_PLATFORM_VERSION),
+    'com.microsoft.playwright:playwright:{version}'.format(
+        version=PLAYWRIGHT_VERSION),
 ]
 
 PLUGIN_DEPENDENCIES = [
diff --git a/tools/update_chrome_headless.py b/tools/update_chrome_headless.py
new file mode 100755
index 0000000..1b6bb7e
--- /dev/null
+++ b/tools/update_chrome_headless.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+# Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+# for details. All rights reserved. Use of this source code is governed by a
+# BSD-style license that can be found in the LICENSE file.
+
+import argparse
+import json
+import os
+import shutil
+import stat
+import sys
+import tempfile
+import urllib.request
+import zipfile
+
+import utils
+
+VERSION = "148.0.7778.96"  # Default version, but can be overridden
+API_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"
+
+
+def get_latest_stable_version():
+    print("Fetching latest stable Chrome version from googlechromelabs...")
+    try:
+        req = urllib.request.Request(API_URL,
+                                     headers={'User-Agent': 'Mozilla/5.0'})
+        with urllib.request.urlopen(req) as response:
+            data = json.loads(response.read().decode('utf-8'))
+        channel_data = data.get("channels", {}).get("Stable")
+        if channel_data:
+            return channel_data.get("version")
+    except Exception as e:
+        print(f"Failed to fetch latest version: {e}")
+    return None
+
+
+def parse_options():
+    parser = argparse.ArgumentParser(
+        description=
+        'Update chrome-headless-shell binaries for Playwright in third_party.')
+    parser.add_argument(
+        '--version',
+        help=
+        f'Chrome version to download. If not specified and --latest is not used, defaults to {VERSION}.'
+    )
+    parser.add_argument(
+        '--latest',
+        action='store_true',
+        help='Download the latest stable version of chrome-headless-shell.')
+    return parser.parse_args()
+
+
+def download_and_extract(name, url, extract_to):
+    with tempfile.TemporaryDirectory() as tmpdir:
+        zip_path = os.path.join(tmpdir, f"{name}.zip")
+        print(f"Downloading {url} to {zip_path}...")
+        try:
+            urllib.request.urlretrieve(url, zip_path)
+        except Exception as e:
+            print(f"Failed to download {url}: {e}")
+            return False
+
+        print(f"Extracting {zip_path} to {tmpdir}...")
+        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
+            zip_ref.extractall(tmpdir)
+
+        # Chrome zip usually contains a single top-level directory
+        zip_dirs = [
+            d for d in os.listdir(tmpdir)
+            if os.path.isdir(os.path.join(tmpdir, d))
+        ]
+        if not zip_dirs:
+            print(f"No directory found in zip for {name}")
+            return False
+
+        src_dir = os.path.join(tmpdir, zip_dirs[0])
+        print(f"Moving contents of {src_dir} to {extract_to}...")
+        os.makedirs(extract_to, exist_ok=True)
+        for item in os.listdir(src_dir):
+            s = os.path.join(src_dir, item)
+            d = os.path.join(extract_to, item)
+            if os.path.exists(d):
+                if os.path.isdir(d):
+                    shutil.rmtree(d)
+                else:
+                    os.remove(d)
+            shutil.move(s, d)
+    return True
+
+
+def set_executable(file_path):
+    if os.path.exists(file_path):
+        print(f"Making {file_path} executable...")
+        st = os.stat(file_path)
+        os.chmod(file_path,
+                 st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+    else:
+        print(f"Warning: File {file_path} not found to set executable.")
+
+
+def main():
+    args = parse_options()
+    version = args.version
+    if args.latest:
+        version = get_latest_stable_version()
+        if not version:
+            print("Error: Could not retrieve latest stable version.")
+            sys.exit(1)
+    elif not version:
+        version = VERSION
+
+    print(f"Using Chrome version: {version}")
+    base_url = f"https://storage.googleapis.com/chrome-for-testing-public/{version}"
+
+    urls = {
+        "linux":
+            f"{base_url}/linux64/chrome-headless-shell-linux64.zip",
+        "mac-arm64":
+            f"{base_url}/mac-arm64/chrome-headless-shell-mac-arm64.zip",
+        "mac-x64":
+            f"{base_url}/mac-x64/chrome-headless-shell-mac-x64.zip",
+        "windows":
+            f"{base_url}/win64/chrome-headless-shell-win64.zip"
+    }
+
+    chrome_headless_dir = os.path.join(utils.THIRD_PARTY, 'chrome_headless')
+
+    linux_dir = os.path.join(chrome_headless_dir, 'linux')
+    mac_dir = os.path.join(chrome_headless_dir, 'mac')
+    mac_arm_dir = os.path.join(mac_dir, 'mac-arm64')
+    mac_x64_dir = os.path.join(mac_dir, 'mac-x64')
+    windows_dir = os.path.join(chrome_headless_dir, 'windows')
+
+    # Clean up existing directories if they exist
+    for d in [linux_dir, mac_dir, windows_dir]:
+        if os.path.exists(d):
+            print(f"Removing existing directory {d}...")
+            shutil.rmtree(d)
+
+    # 1. Linux
+    if download_and_extract("linux", urls["linux"], linux_dir):
+        set_executable(os.path.join(linux_dir, "chrome-headless-shell"))
+
+    # 2. Mac
+    download_arm = download_and_extract("mac-arm64", urls["mac-arm64"],
+                                        mac_arm_dir)
+    download_x64 = download_and_extract("mac-x64", urls["mac-x64"], mac_x64_dir)
+    if download_arm and download_x64:
+        set_executable(os.path.join(mac_arm_dir, "chrome-headless-shell"))
+        set_executable(os.path.join(mac_x64_dir, "chrome-headless-shell"))
+
+    # 3. Windows
+    download_and_extract("windows", urls["windows"], windows_dir)
+
+    print("\nLocal setup complete.")
+    print("If you want to upload these to Google Storage, run:")
+    print(
+        f"  (cd {chrome_headless_dir}; upload_to_google_storage.py -a --bucket r8-deps linux)"
+    )
+    print(
+        f"  (cd {chrome_headless_dir}; upload_to_google_storage.py -a --bucket r8-deps mac)"
+    )
+    print(
+        f"  (cd {chrome_headless_dir}; upload_to_google_storage.py -a --bucket r8-deps windows)"
+    )
+
+
+if __name__ == '__main__':
+    sys.exit(main())