Install chrome for browser testing

The unzip util is not part of the recipe standard library (called
"recipe_engine"), so I copied it from chromium's util folder (called
"recipe_modules") to our util folder.

The env stuff is done a bit differently than the original code from
gerrit_plugins.py because we are not running karma. Possibly this will
take some adjusting, but according to
https://github.com/GoogleChrome/chrome-launcher#launch-options it should
detect chrome on the PATH without needing to set CHROME_PATH ourselves.

Change-Id: I9a881b0189aab5982f92606971457c48a353219b
diff --git a/recipes/README.recipes.md b/recipes/README.recipes.md
index 762c20f..46529da 100644
--- a/recipes/README.recipes.md
+++ b/recipes/README.recipes.md
@@ -2,9 +2,97 @@
 # Repo documentation for [gerrit](https://gerrit.googlesource.com/luci-config.git)
 ## Table of Contents
 
+**[Recipe Modules](#Recipe-Modules)**
+  * [zip](#recipe_modules-zip) (Python3 ✅)
+
 **[Recipes](#Recipes)**
   * [hello_world](#recipes-hello_world)
   * [luci-test](#recipes-luci-test) (Python3 ✅)
+  * [zip:examples/full](#recipes-zip_examples_full) (Python3 ✅)
+## Recipe Modules
+
+### *recipe_modules* / [zip](/recipes/recipe_modules/zip)
+
+[DEPS](/recipes/recipe_modules/zip/__init__.py#7): [recipe\_engine/json][recipe_engine/recipe_modules/json], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/platform][recipe_engine/recipe_modules/platform], [recipe\_engine/raw\_io][recipe_engine/recipe_modules/raw_io], [recipe\_engine/step][recipe_engine/recipe_modules/step]
+
+PYTHON_VERSION_COMPATIBILITY: PY2+3
+
+#### **class [ZipApi](/recipes/recipe_modules/zip/api.py#8)([RecipeApi][recipe_engine/wkt/RecipeApi]):**
+
+Provides steps to zip and unzip files.
+
+— **def [directory](/recipes/recipe_modules/zip/api.py#50)(self, step_name, directory, output, comment=None):**
+
+Step to compress a single directory.
+
+Args:
+  step_name: display name of the step.
+  directory: path to a directory to compress, it would become the root of
+      an archive, i.e. |directory|/file.txt would be named 'file.txt' in
+      the archive.
+  output: path to a zip file to create.
+  comment: the archive comment to set on the created ZIP file.
+
+— **def [get\_comment](/recipes/recipe_modules/zip/api.py#93)(self, step_name, zip_file):**
+
+Returns the archive comment from |zip_file|.
+
+Args:
+  step_name: display name of a step.
+  zip_file: path to a zip file to read, should exist.
+
+— **def [make\_package](/recipes/recipe_modules/zip/api.py#11)(self, root, output):**
+
+Returns ZipPackage object that can be used to compress a set of files.
+
+Usage:
+  pkg = api.zip.make_package(root, output)
+  pkg.add_file(root.join('file'))
+  pkg.add_directory(root.join('directory'))
+  yield pkg.zip('zipping step')
+
+Args:
+  root: a directory that would become root of a package, all files added to
+      an archive will have archive paths relative to this directory.
+  output: path to a zip file to create.
+
+Returns:
+  ZipPackage object.
+
+— **def [unzip](/recipes/recipe_modules/zip/api.py#67)(self, step_name, zip_file, output, quiet=False):**
+
+Step to uncompress |zip_file| into |output| directory.
+
+Zip package will be unpacked to |output| so that root of an archive is in
+|output|, i.e. archive.zip/file.txt will become |output|/file.txt.
+
+Step will FAIL if |output| already exists.
+
+Args:
+  step_name: display name of a step.
+  zip_file: path to a zip file to uncompress, should exist.
+  output: path to a directory to unpack to, it should NOT exist.
+  quiet (bool): If True, print terse output instead of the name
+      of each unzipped file.
+
+— **def [update\_package](/recipes/recipe_modules/zip/api.py#30)(self, root, output):**
+
+Returns ZipPackage object that can be used to update an existing package.
+
+Usage:
+  pkg = api.zip.update_package(root, output)
+  pkg.add_file(root.join('file'))
+  pkg.add_directory(root.join('directory'))
+  yield pkg.zip('updating zip step')
+
+Args:
+  root: the root directory for adding new files/dirs to the package; all
+      files/dirs added to an archive will have archive paths relative to
+      this directory.
+  output: path to a zip file to update.
+
+Returns:
+  ZipPackage object.
 ## Recipes
 
 ### *recipes* / [hello\_world](/recipes/recipes/hello_world.py)
@@ -16,16 +104,29 @@
 — **def [RunSteps](/recipes/recipes/hello_world.py#11)(api):**
 ### *recipes* / [luci-test](/recipes/recipes/luci-test.py)
 
-[DEPS](/recipes/recipes/luci-test.py#7): [depot\_tools/bot\_update][depot_tools/recipe_modules/bot_update], [depot\_tools/gclient][depot_tools/recipe_modules/gclient], [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/nodejs][recipe_engine/recipe_modules/nodejs], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/step][recipe_engine/recipe_modules/step]
+[DEPS](/recipes/recipes/luci-test.py#7): [depot\_tools/bot\_update][depot_tools/recipe_modules/bot_update], [depot\_tools/gclient][depot_tools/recipe_modules/gclient], [depot\_tools/gsutil][depot_tools/recipe_modules/gsutil], [zip](#recipe_modules-zip), [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/file][recipe_engine/recipe_modules/file], [recipe\_engine/nodejs][recipe_engine/recipe_modules/nodejs], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/platform][recipe_engine/recipe_modules/platform], [recipe\_engine/step][recipe_engine/recipe_modules/step]
 
 PYTHON_VERSION_COMPATIBILITY: PY3
 
-— **def [RunSteps](/recipes/recipes/luci-test.py#19)(api):**
+— **def [RunSteps](/recipes/recipes/luci-test.py#23)(api):**
+### *recipes* / [zip:examples/full](/recipes/recipe_modules/zip/examples/full.py)
+
+[DEPS](/recipes/recipe_modules/zip/examples/full.py#7): [zip](#recipe_modules-zip), [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/file][recipe_engine/recipe_modules/file], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/platform][recipe_engine/recipe_modules/platform], [recipe\_engine/step][recipe_engine/recipe_modules/step]
+
+PYTHON_VERSION_COMPATIBILITY: PY2+3
+
+— **def [RunSteps](/recipes/recipe_modules/zip/examples/full.py#17)(api):**
 
 [depot_tools/recipe_modules/bot_update]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/e1c8efebe0a3cce42ca46d6057b6d4bd909ad203/recipes/README.recipes.md#recipe_modules-bot_update
 [depot_tools/recipe_modules/gclient]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/e1c8efebe0a3cce42ca46d6057b6d4bd909ad203/recipes/README.recipes.md#recipe_modules-gclient
+[depot_tools/recipe_modules/gsutil]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/e1c8efebe0a3cce42ca46d6057b6d4bd909ad203/recipes/README.recipes.md#recipe_modules-gsutil
 [recipe_engine/recipe_modules/buildbucket]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/a4e5d51c4351ab0674e264a8a360572286b04a6f/README.recipes.md#recipe_modules-buildbucket
 [recipe_engine/recipe_modules/context]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/a4e5d51c4351ab0674e264a8a360572286b04a6f/README.recipes.md#recipe_modules-context
+[recipe_engine/recipe_modules/file]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/a4e5d51c4351ab0674e264a8a360572286b04a6f/README.recipes.md#recipe_modules-file
+[recipe_engine/recipe_modules/json]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/a4e5d51c4351ab0674e264a8a360572286b04a6f/README.recipes.md#recipe_modules-json
 [recipe_engine/recipe_modules/nodejs]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/a4e5d51c4351ab0674e264a8a360572286b04a6f/README.recipes.md#recipe_modules-nodejs
 [recipe_engine/recipe_modules/path]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/a4e5d51c4351ab0674e264a8a360572286b04a6f/README.recipes.md#recipe_modules-path
+[recipe_engine/recipe_modules/platform]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/a4e5d51c4351ab0674e264a8a360572286b04a6f/README.recipes.md#recipe_modules-platform
+[recipe_engine/recipe_modules/raw_io]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/a4e5d51c4351ab0674e264a8a360572286b04a6f/README.recipes.md#recipe_modules-raw_io
 [recipe_engine/recipe_modules/step]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/a4e5d51c4351ab0674e264a8a360572286b04a6f/README.recipes.md#recipe_modules-step
+[recipe_engine/wkt/RecipeApi]: https://chromium.googlesource.com/infra/luci/recipes-py.git/+/a4e5d51c4351ab0674e264a8a360572286b04a6f/recipe_engine/recipe_api.py#886
diff --git a/recipes/recipe_modules/zip/README.md b/recipes/recipe_modules/zip/README.md
new file mode 100644
index 0000000..7af526d
--- /dev/null
+++ b/recipes/recipe_modules/zip/README.md
@@ -0,0 +1 @@
+This module is copied from https://chromium.googlesource.com/infra/infra/+/refs/heads/main/recipes/recipe_modules/zip/
\ No newline at end of file
diff --git a/recipes/recipe_modules/zip/__init__.py b/recipes/recipe_modules/zip/__init__.py
new file mode 100644
index 0000000..26a3c00
--- /dev/null
+++ b/recipes/recipe_modules/zip/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2014 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+PYTHON_VERSION_COMPATIBILITY = "PY2+3"
+
+DEPS = [
+    'recipe_engine/path',
+    'recipe_engine/platform',
+    'recipe_engine/json',
+    'recipe_engine/step',
+    'recipe_engine/raw_io',
+]
diff --git a/recipes/recipe_modules/zip/api.py b/recipes/recipe_modules/zip/api.py
new file mode 100644
index 0000000..2f6db5a
--- /dev/null
+++ b/recipes/recipe_modules/zip/api.py
@@ -0,0 +1,173 @@
+# Copyright 2014 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from recipe_engine import recipe_api
+
+
+class ZipApi(recipe_api.RecipeApi):
+  """Provides steps to zip and unzip files."""
+
+  def make_package(self, root, output):
+    """Returns ZipPackage object that can be used to compress a set of files.
+
+    Usage:
+      pkg = api.zip.make_package(root, output)
+      pkg.add_file(root.join('file'))
+      pkg.add_directory(root.join('directory'))
+      yield pkg.zip('zipping step')
+
+    Args:
+      root: a directory that would become root of a package, all files added to
+          an archive will have archive paths relative to this directory.
+      output: path to a zip file to create.
+
+    Returns:
+      ZipPackage object.
+    """
+    return ZipPackage(self, root, output)
+
+  def update_package(self, root, output):
+    """Returns ZipPackage object that can be used to update an existing package.
+
+    Usage:
+      pkg = api.zip.update_package(root, output)
+      pkg.add_file(root.join('file'))
+      pkg.add_directory(root.join('directory'))
+      yield pkg.zip('updating zip step')
+
+    Args:
+      root: the root directory for adding new files/dirs to the package; all
+          files/dirs added to an archive will have archive paths relative to
+          this directory.
+      output: path to a zip file to update.
+
+    Returns:
+      ZipPackage object.
+    """
+    return ZipPackage(self, root, output, mode='a')
+
+  def directory(self, step_name, directory, output, comment=None):
+    """Step to compress a single directory.
+
+    Args:
+      step_name: display name of the step.
+      directory: path to a directory to compress, it would become the root of
+          an archive, i.e. |directory|/file.txt would be named 'file.txt' in
+          the archive.
+      output: path to a zip file to create.
+      comment: the archive comment to set on the created ZIP file.
+    """
+    pkg = self.make_package(directory, output)
+    pkg.add_directory(directory)
+    if comment:
+      pkg.set_comment(comment)
+    pkg.zip(step_name)
+
+  def unzip(self, step_name, zip_file, output, quiet=False):
+    """Step to uncompress |zip_file| into |output| directory.
+
+    Zip package will be unpacked to |output| so that root of an archive is in
+    |output|, i.e. archive.zip/file.txt will become |output|/file.txt.
+
+    Step will FAIL if |output| already exists.
+
+    Args:
+      step_name: display name of a step.
+      zip_file: path to a zip file to uncompress, should exist.
+      output: path to a directory to unpack to, it should NOT exist.
+      quiet (bool): If True, print terse output instead of the name
+          of each unzipped file.
+    """
+    # TODO(vadimsh): Use 7zip on Windows if available?
+    script_input = {
+        'output': str(output),
+        'zip_file': str(zip_file),
+        'quiet': quiet,
+    }
+    self.m.step(
+        name=step_name,
+        cmd=['python3', self.resource('unzip.py')],
+        stdin=self.m.json.input(script_input))
+
+  def get_comment(self, step_name, zip_file):
+    """Returns the archive comment from |zip_file|.
+
+    Args:
+      step_name: display name of a step.
+      zip_file: path to a zip file to read, should exist.
+    """
+    script_input = {
+        'zip_file': str(zip_file),
+    }
+    step_result = self.m.step(
+        step_name, ['python3', self.resource('zipcomment.py')],
+        stdout=self.m.raw_io.output_text(),
+        stdin=self.m.json.input(script_input))
+    return step_result.stdout
+
+
+class ZipPackage(object):
+  """Used to gather a list of files to zip."""
+
+  def __init__(self, module, root, output, mode='w'):
+    self._module = module
+    self._root = root
+    self._output = output
+    self._mode = mode
+    self._entries = []
+    self._comment = ''
+
+  @property
+  def root(self):
+    return self._root
+
+  @property
+  def output(self):
+    return self._output
+
+  def set_comment(self, comment):
+    self._comment = comment
+
+  def add_file(self, path, archive_name=None):
+    """Stages single file to be added to the package.
+
+    Args:
+      path: absolute path to a file, should be in |root| subdirectory.
+      archive_name: name of the file in the archive, if non-None
+    """
+    assert self._root.is_parent_of(path), path
+    self._entries.append({
+        'type': 'file',
+        'path': str(path),
+        'archive_name': archive_name
+    })
+
+  def add_directory(self, path):
+    """Stages a directory with all its content to be added to the package.
+
+    Args:
+      path: absolute path to a directory, should be in |root| subdirectory.
+    """
+    # TODO(vadimsh): Implement 'exclude' filter.
+    assert self._root.is_parent_of(path) or path == self._root, path
+    self._entries.append({
+        'type': 'dir',
+        'path': str(path),
+    })
+
+  def zip(self, step_name):
+    """Step to zip all staged files."""
+    script_input = {
+        'entries': self._entries,
+        'comment': self._comment,
+        'output': str(self._output),
+        'root': str(self._root),
+        'mode': str(self._mode),
+    }
+    step_result = self._module.m.step(
+        name=step_name,
+        cmd=['python3', self._module.resource('zip.py')],
+        stdin=self._module.m.json.input(script_input))
+    self._module.m.path.mock_add_paths(self._output)
+    return step_result
diff --git a/recipes/recipe_modules/zip/examples/full.expected/linux.json b/recipes/recipe_modules/zip/examples/full.expected/linux.json
new file mode 100644
index 0000000..33bca49
--- /dev/null
+++ b/recipes/recipe_modules/zip/examples/full.expected/linux.json
@@ -0,0 +1,115 @@
+[
+  {
+    "cmd": [
+      "touch",
+      "[CLEANUP]/zip-example_tmp_1/a"
+    ],
+    "name": "touch a"
+  },
+  {
+    "cmd": [
+      "touch",
+      "[CLEANUP]/zip-example_tmp_1/b"
+    ],
+    "name": "touch b"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[CLEANUP]/zip-example_tmp_1/sub/dir"
+    ],
+    "infra_step": true,
+    "name": "mkdirs"
+  },
+  {
+    "cmd": [
+      "touch",
+      "[CLEANUP]/zip-example_tmp_1/sub/dir/c"
+    ],
+    "name": "touch c"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]/resources/zip.py"
+    ],
+    "name": "zipping",
+    "stdin": "{\"comment\": \"hello\", \"entries\": [{\"path\": \"[CLEANUP]/zip-example_tmp_1\", \"type\": \"dir\"}], \"mode\": \"w\", \"output\": \"[CLEANUP]/zip-example_tmp_1/output.zip\", \"root\": \"[CLEANUP]/zip-example_tmp_1\"}"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]/resources/zip.py"
+    ],
+    "name": "zipping more",
+    "stdin": "{\"comment\": \"\", \"entries\": [{\"archive_name\": null, \"path\": \"[CLEANUP]/zip-example_tmp_1/a\", \"type\": \"file\"}, {\"archive_name\": null, \"path\": \"[CLEANUP]/zip-example_tmp_1/b\", \"type\": \"file\"}, {\"path\": \"[CLEANUP]/zip-example_tmp_1/sub\", \"type\": \"dir\"}], \"mode\": \"w\", \"output\": \"[CLEANUP]/zip-example_tmp_1/more.zip\", \"root\": \"[CLEANUP]/zip-example_tmp_1\"}"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]/resources/zip.py"
+    ],
+    "name": "zipping more updates",
+    "stdin": "{\"comment\": \"hello again\", \"entries\": [{\"archive_name\": \"renamed_a\", \"path\": \"[CLEANUP]/zip-example_tmp_1/update_a\", \"type\": \"file\"}, {\"archive_name\": \"renamed_b\", \"path\": \"[CLEANUP]/zip-example_tmp_1/update_b\", \"type\": \"file\"}], \"mode\": \"a\", \"output\": \"[CLEANUP]/zip-example_tmp_1/more.zip\", \"root\": \"[CLEANUP]/zip-example_tmp_1\"}"
+  },
+  {
+    "cmd": [
+      "echo",
+      "[CLEANUP]/zip-example_tmp_1/more.zip"
+    ],
+    "name": "report"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]/resources/unzip.py"
+    ],
+    "name": "unzipping",
+    "stdin": "{\"output\": \"[CLEANUP]/zip-example_tmp_1/output\", \"quiet\": true, \"zip_file\": \"[CLEANUP]/zip-example_tmp_1/output.zip\"}"
+  },
+  {
+    "cmd": [
+      "find"
+    ],
+    "cwd": "[CLEANUP]/zip-example_tmp_1/output",
+    "name": "listing"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "rmtree",
+      "[CLEANUP]/zip-example_tmp_1"
+    ],
+    "infra_step": true,
+    "name": "cleanup"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]/resources/zipcomment.py"
+    ],
+    "name": "get comment",
+    "stdin": "{\"zip_file\": \"[CLEANUP]/zip-example_tmp_1/output.zip\"}"
+  },
+  {
+    "cmd": [
+      "echo",
+      ""
+    ],
+    "name": "report comment"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/recipe_modules/zip/examples/full.expected/mac.json b/recipes/recipe_modules/zip/examples/full.expected/mac.json
new file mode 100644
index 0000000..33bca49
--- /dev/null
+++ b/recipes/recipe_modules/zip/examples/full.expected/mac.json
@@ -0,0 +1,115 @@
+[
+  {
+    "cmd": [
+      "touch",
+      "[CLEANUP]/zip-example_tmp_1/a"
+    ],
+    "name": "touch a"
+  },
+  {
+    "cmd": [
+      "touch",
+      "[CLEANUP]/zip-example_tmp_1/b"
+    ],
+    "name": "touch b"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[CLEANUP]/zip-example_tmp_1/sub/dir"
+    ],
+    "infra_step": true,
+    "name": "mkdirs"
+  },
+  {
+    "cmd": [
+      "touch",
+      "[CLEANUP]/zip-example_tmp_1/sub/dir/c"
+    ],
+    "name": "touch c"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]/resources/zip.py"
+    ],
+    "name": "zipping",
+    "stdin": "{\"comment\": \"hello\", \"entries\": [{\"path\": \"[CLEANUP]/zip-example_tmp_1\", \"type\": \"dir\"}], \"mode\": \"w\", \"output\": \"[CLEANUP]/zip-example_tmp_1/output.zip\", \"root\": \"[CLEANUP]/zip-example_tmp_1\"}"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]/resources/zip.py"
+    ],
+    "name": "zipping more",
+    "stdin": "{\"comment\": \"\", \"entries\": [{\"archive_name\": null, \"path\": \"[CLEANUP]/zip-example_tmp_1/a\", \"type\": \"file\"}, {\"archive_name\": null, \"path\": \"[CLEANUP]/zip-example_tmp_1/b\", \"type\": \"file\"}, {\"path\": \"[CLEANUP]/zip-example_tmp_1/sub\", \"type\": \"dir\"}], \"mode\": \"w\", \"output\": \"[CLEANUP]/zip-example_tmp_1/more.zip\", \"root\": \"[CLEANUP]/zip-example_tmp_1\"}"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]/resources/zip.py"
+    ],
+    "name": "zipping more updates",
+    "stdin": "{\"comment\": \"hello again\", \"entries\": [{\"archive_name\": \"renamed_a\", \"path\": \"[CLEANUP]/zip-example_tmp_1/update_a\", \"type\": \"file\"}, {\"archive_name\": \"renamed_b\", \"path\": \"[CLEANUP]/zip-example_tmp_1/update_b\", \"type\": \"file\"}], \"mode\": \"a\", \"output\": \"[CLEANUP]/zip-example_tmp_1/more.zip\", \"root\": \"[CLEANUP]/zip-example_tmp_1\"}"
+  },
+  {
+    "cmd": [
+      "echo",
+      "[CLEANUP]/zip-example_tmp_1/more.zip"
+    ],
+    "name": "report"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]/resources/unzip.py"
+    ],
+    "name": "unzipping",
+    "stdin": "{\"output\": \"[CLEANUP]/zip-example_tmp_1/output\", \"quiet\": true, \"zip_file\": \"[CLEANUP]/zip-example_tmp_1/output.zip\"}"
+  },
+  {
+    "cmd": [
+      "find"
+    ],
+    "cwd": "[CLEANUP]/zip-example_tmp_1/output",
+    "name": "listing"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "rmtree",
+      "[CLEANUP]/zip-example_tmp_1"
+    ],
+    "infra_step": true,
+    "name": "cleanup"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]/resources/zipcomment.py"
+    ],
+    "name": "get comment",
+    "stdin": "{\"zip_file\": \"[CLEANUP]/zip-example_tmp_1/output.zip\"}"
+  },
+  {
+    "cmd": [
+      "echo",
+      ""
+    ],
+    "name": "report comment"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/recipe_modules/zip/examples/full.expected/win.json b/recipes/recipe_modules/zip/examples/full.expected/win.json
new file mode 100644
index 0000000..2b10e40
--- /dev/null
+++ b/recipes/recipe_modules/zip/examples/full.expected/win.json
@@ -0,0 +1,115 @@
+[
+  {
+    "cmd": [
+      "touch",
+      "[CLEANUP]\\zip-example_tmp_1\\a"
+    ],
+    "name": "touch a"
+  },
+  {
+    "cmd": [
+      "touch",
+      "[CLEANUP]\\zip-example_tmp_1\\b"
+    ],
+    "name": "touch b"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]\\resources\\fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[CLEANUP]\\zip-example_tmp_1\\sub\\dir"
+    ],
+    "infra_step": true,
+    "name": "mkdirs"
+  },
+  {
+    "cmd": [
+      "touch",
+      "[CLEANUP]\\zip-example_tmp_1\\sub\\dir\\c"
+    ],
+    "name": "touch c"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]\\resources\\zip.py"
+    ],
+    "name": "zipping",
+    "stdin": "{\"comment\": \"hello\", \"entries\": [{\"path\": \"[CLEANUP]\\\\zip-example_tmp_1\", \"type\": \"dir\"}], \"mode\": \"w\", \"output\": \"[CLEANUP]\\\\zip-example_tmp_1\\\\output.zip\", \"root\": \"[CLEANUP]\\\\zip-example_tmp_1\"}"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]\\resources\\zip.py"
+    ],
+    "name": "zipping more",
+    "stdin": "{\"comment\": \"\", \"entries\": [{\"archive_name\": null, \"path\": \"[CLEANUP]\\\\zip-example_tmp_1\\\\a\", \"type\": \"file\"}, {\"archive_name\": null, \"path\": \"[CLEANUP]\\\\zip-example_tmp_1\\\\b\", \"type\": \"file\"}, {\"path\": \"[CLEANUP]\\\\zip-example_tmp_1\\\\sub\", \"type\": \"dir\"}], \"mode\": \"w\", \"output\": \"[CLEANUP]\\\\zip-example_tmp_1\\\\more.zip\", \"root\": \"[CLEANUP]\\\\zip-example_tmp_1\"}"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]\\resources\\zip.py"
+    ],
+    "name": "zipping more updates",
+    "stdin": "{\"comment\": \"hello again\", \"entries\": [{\"archive_name\": \"renamed_a\", \"path\": \"[CLEANUP]\\\\zip-example_tmp_1\\\\update_a\", \"type\": \"file\"}, {\"archive_name\": \"renamed_b\", \"path\": \"[CLEANUP]\\\\zip-example_tmp_1\\\\update_b\", \"type\": \"file\"}], \"mode\": \"a\", \"output\": \"[CLEANUP]\\\\zip-example_tmp_1\\\\more.zip\", \"root\": \"[CLEANUP]\\\\zip-example_tmp_1\"}"
+  },
+  {
+    "cmd": [
+      "echo",
+      "[CLEANUP]\\zip-example_tmp_1\\more.zip"
+    ],
+    "name": "report"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]\\resources\\unzip.py"
+    ],
+    "name": "unzipping",
+    "stdin": "{\"output\": \"[CLEANUP]\\\\zip-example_tmp_1\\\\output\", \"quiet\": true, \"zip_file\": \"[CLEANUP]\\\\zip-example_tmp_1\\\\output.zip\"}"
+  },
+  {
+    "cmd": [
+      "find"
+    ],
+    "cwd": "[CLEANUP]\\zip-example_tmp_1\\output",
+    "name": "listing"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]\\resources\\fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "rmtree",
+      "[CLEANUP]\\zip-example_tmp_1"
+    ],
+    "infra_step": true,
+    "name": "cleanup"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]\\resources\\zipcomment.py"
+    ],
+    "name": "get comment",
+    "stdin": "{\"zip_file\": \"[CLEANUP]\\\\zip-example_tmp_1\\\\output.zip\"}"
+  },
+  {
+    "cmd": [
+      "echo",
+      ""
+    ],
+    "name": "report comment"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/recipe_modules/zip/examples/full.py b/recipes/recipe_modules/zip/examples/full.py
new file mode 100644
index 0000000..a6b180b
--- /dev/null
+++ b/recipes/recipe_modules/zip/examples/full.py
@@ -0,0 +1,64 @@
+# Copyright 2014 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+PYTHON_VERSION_COMPATIBILITY = "PY2+3"
+
+DEPS = [
+    'recipe_engine/context',
+    'recipe_engine/file',
+    'recipe_engine/path',
+    'recipe_engine/platform',
+    'recipe_engine/step',
+    'zip',
+]
+
+
+def RunSteps(api):
+  # Prepare files.
+  temp = api.path.mkdtemp('zip-example')
+  api.step('touch a', ['touch', temp.join('a')])
+  api.step('touch b', ['touch', temp.join('b')])
+  api.file.ensure_directory('mkdirs', temp.join('sub', 'dir'))
+  api.step('touch c', ['touch', temp.join('sub', 'dir', 'c')])
+
+  # Build zip using 'zip.directory'.
+  api.zip.directory('zipping', temp, temp.join('output.zip'), comment='hello')
+
+  # Build a zip using ZipPackage api.
+  package = api.zip.make_package(temp, temp.join('more.zip'))
+  package.add_file(package.root.join('a'))
+  package.add_file(package.root.join('b'))
+  package.add_directory(package.root.join('sub'))
+  package.zip('zipping more')
+
+  # Update a zip using ZipPackage api.
+  package = api.zip.update_package(temp, temp.join('more.zip'))
+  package.add_file(temp.join('update_a'), 'renamed_a')
+  package.add_file(temp.join('update_b'), 'renamed_b')
+  package.set_comment('hello again')
+  package.zip('zipping more updates')
+
+  # Coverage for 'output' property.
+  api.step('report', ['echo', package.output])
+
+  # Unzip the package.
+  api.zip.unzip(
+      'unzipping', temp.join('output.zip'), temp.join('output'), quiet=True)
+  # List unzipped content.
+  with api.context(cwd=temp.join('output')):
+    api.step('listing', ['find'])
+  # Clean up.
+  api.file.rmtree('cleanup', temp)
+
+  # Retrieve archive comment.
+  comment = api.zip.get_comment('get comment', temp.join('output.zip'))
+  api.step('report comment', ['echo', comment])
+
+
+def GenTests(api):
+  for platform in ('linux', 'win', 'mac'):
+    yield api.test(
+        platform,
+        api.platform.name(platform),
+    )
diff --git a/recipes/recipe_modules/zip/resources/unzip.py b/recipes/recipe_modules/zip/resources/unzip.py
new file mode 100644
index 0000000..5b14082
--- /dev/null
+++ b/recipes/recipe_modules/zip/resources/unzip.py
@@ -0,0 +1,93 @@
+# Copyright 2014 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Standalone python script to unzip an archive. Intended to be used by 'zip'
+recipe module internally. Should not be used elsewhere.
+"""
+
+from __future__ import print_function
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import zipfile
+
+
+def unzip_with_subprocess(zip_file, output, quiet):
+  """Unzips an archive using 'zip' utility.
+
+  Works only on Linux and Mac, uses system 'zip' program.
+
+  Args:
+    zip_file: absolute path to an archive to unzip.
+    output: existing directory to unzip to.
+    quiet (bool): If True, instruct the subprocess to unzip with
+        minimal output.
+
+  Returns:
+    Exit code (0 on success).
+  """
+  args = ['unzip']
+  if quiet:
+    args += ['-q']
+  args += [zip_file]
+
+  return subprocess.call(args=args, cwd=output)
+
+
+def unzip_with_python(zip_file, output):
+  """Unzips an archive using 'zipfile' python module.
+
+  Works everywhere where python works (Windows and Posix).
+
+  Args:
+    zip_file: absolute path to an archive to unzip.
+    output: existing directory to unzip to.
+
+  Returns:
+    Exit code (0 on success).
+  """
+  with zipfile.ZipFile(zip_file) as zip_file_obj:
+    for name in zip_file_obj.namelist():
+      print('Extracting %s' % name)
+      zip_file_obj.extract(name, output)
+  return 0
+
+
+def main():
+  # See zip/api.py, def unzip(...) for format of |data|.
+  data = json.load(sys.stdin)
+  output = data['output']
+  zip_file = data['zip_file']
+  quiet = data['quiet']
+
+  # Archive path should exist and be an absolute path to a file.
+  assert os.path.exists(zip_file), zip_file
+  assert os.path.isfile(zip_file), zip_file
+
+  # Output path should be an absolute path, and should NOT exist.
+  assert os.path.isabs(output), output
+  assert not os.path.exists(output), output
+
+  print('Unzipping %s...' % zip_file)
+  exit_code = -1
+  try:
+    os.makedirs(output)
+    if sys.platform == 'win32':
+      # Used on Windows, since there's no builtin 'unzip' utility there.
+      exit_code = unzip_with_python(zip_file, output)
+    else:
+      # On mac and linux 'unzip' utility handles symlink and file modes.
+      exit_code = unzip_with_subprocess(zip_file, output, quiet)
+  finally:
+    # On non-zero exit code or on unexpected exception, clean up.
+    if exit_code:
+      shutil.rmtree(output, ignore_errors=True)
+  return exit_code
+
+
+if __name__ == '__main__':
+  sys.exit(main())
diff --git a/recipes/recipe_modules/zip/resources/zip.py b/recipes/recipe_modules/zip/resources/zip.py
new file mode 100644
index 0000000..c70db4e
--- /dev/null
+++ b/recipes/recipe_modules/zip/resources/zip.py
@@ -0,0 +1,169 @@
+# Copyright 2014 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Standalone python script to zip a set of files. Intended to be used by 'zip'
+recipe module internally. Should not be used elsewhere.
+"""
+
+from __future__ import print_function
+
+import json
+import os
+import subprocess
+import sys
+import zipfile
+
+
+def zip_with_subprocess(root, output, entries, comment, mode):
+  """Zips set of files and directories using 'zip' utility.
+
+  Works only on Linux and Mac, uses system 'zip' program.
+
+  Args:
+    root: absolute path to a directory that will become a root of the archive.
+    output: absolute path to a destination archive.
+    entries: list of dicts, describing what to zip, see zip/api.py.
+    comment: archive comment to store in the archive.
+    mode: 'w' to create/overwrite output file, or 'a' to append to output file.
+        Note, if output file doesn't exist, this always creates a new file.
+
+  Returns:
+    Exit code (0 on success).
+  """
+  # Collect paths relative to |root| of all items we'd like to zip.
+  items_to_zip = []
+  for entry in entries:
+    tp = entry['type']
+    path = entry['path']
+    if tp == 'file':
+      # File must exist and be inside |root|.
+      assert os.path.isfile(path), path
+      assert path.startswith(root), path
+      items_to_zip.append(path[len(root):])
+    elif entry['type'] == 'dir':
+      # Append trailing '/'.
+      path = path.rstrip(os.path.sep) + os.path.sep
+      # Directory must exist and be inside |root| or be |root| itself.
+      assert os.path.isdir(path), path
+      assert path.startswith(root), path
+      items_to_zip.append(path[len(root):] or '.')
+    else:
+      raise AssertionError('Invalid entry type: %s' % (tp,))
+
+  # zip defaults to adding/updating files, so explicitly remove any existing
+  # file in 'write' mode.
+  if mode == 'w' and os.path.exists(output):
+    os.unlink(output)
+  # Invoke 'zip' in |root| directory, passing all relative paths via stdin.
+  proc = subprocess.Popen(
+      args=['zip', '-1', '--recurse-paths', '--symlinks', '-@', output],
+      stdin=subprocess.PIPE,
+      universal_newlines=True,
+      cwd=root)
+  proc.communicate('\n'.join(items_to_zip))
+  if proc.returncode == 0 and comment:
+    proc = subprocess.Popen(
+        args=['zip', '--archive-comment', output],
+        stdin=subprocess.PIPE,
+        universal_newlines=True,
+        cwd=root)
+    proc.communicate(comment)
+  return proc.returncode
+
+
+def zip_with_python(root, output, entries, comment, mode):
+  """Zips set of files and directories using 'zipfile' python module.
+
+  Works everywhere where python works (Windows and Posix).
+
+  Args:
+    root: absolute path to a directory that will become a root of the archive.
+    output: absolute path to a destination archive.
+    entries: list of dicts, describing what to zip, see zip/api.py.
+    comment: archive comment to store in the archive.
+    mode: 'w' to create/overwrite output file, or 'a' to append to output file.
+        Note, if output file doesn't exist, this always creates a new file.
+
+  Returns:
+    Exit code (0 on success).
+  """
+  with zipfile.ZipFile(
+      output, mode, zipfile.ZIP_DEFLATED, allowZip64=True) as zip_file:
+
+    def add(path, archive_name):
+      assert path.startswith(root), path
+      # Do not add itself to archive.
+      if path == output:
+        return
+      if archive_name is None:
+        archive_name = path[len(root):]
+      print('Adding %s' % archive_name)
+      zip_file.write(path, archive_name)
+
+    for entry in entries:
+      tp = entry['type']
+      path = entry['path']
+      if tp == 'file':
+        add(path, entry.get('archive_name'))
+      elif tp == 'dir':
+        for cur, _, files in os.walk(path):
+          for name in files:
+            add(os.path.join(cur, name), None)
+      else:
+        raise AssertionError('Invalid entry type: %s' % (tp,))
+    if comment:
+      zip_file.comment = comment
+  return 0
+
+
+def use_python_zip(entries):
+  if sys.platform == 'win32':
+    return True
+  for entry in entries:
+    if entry.get('archive_name') is not None:
+      return True
+  return False
+
+
+def main():
+  # See zip/api.py, def zip(...) for format of |data|.
+  data = json.load(sys.stdin)
+  entries = data['entries']
+  output = data['output']
+  root = data['root'].rstrip(os.path.sep) + os.path.sep
+  mode = data['mode']
+  comment = data['comment']
+
+  # Archive root directory should exist and be an absolute path.
+  assert os.path.exists(root), root
+  assert os.path.isabs(root), root
+
+  # Output zip path should be an absolute path.
+  assert os.path.isabs(output), output
+
+  print('Zipping %s...' % output)
+  exit_code = -1
+  try:
+    if use_python_zip(entries):
+      # Used on Windows, since there's no builtin 'zip' utility there, and when
+      # an explicit archive_name is set, since there's no way to do that with
+      # the native zip utility without filesystem shenanigans
+      exit_code = zip_with_python(root, output, entries, comment, mode)
+    else:
+      # On mac and linux 'zip' utility handles symlink and file modes.
+      exit_code = zip_with_subprocess(root, output, entries, comment, mode)
+  finally:
+    # On non-zero exit code or on unexpected exception, clean up.
+    if exit_code:
+      try:
+        os.remove(output)
+      except:  # pylint: disable=bare-except
+        pass
+  if not exit_code:
+    print('Archive size: %.1f KB' % (os.stat(output).st_size / 1024.0,))
+  return exit_code
+
+
+if __name__ == '__main__':
+  sys.exit(main())
diff --git a/recipes/recipe_modules/zip/resources/zipcomment.py b/recipes/recipe_modules/zip/resources/zipcomment.py
new file mode 100644
index 0000000..b2fc344
--- /dev/null
+++ b/recipes/recipe_modules/zip/resources/zipcomment.py
@@ -0,0 +1,31 @@
+# Copyright 2022 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Standalone python script to return the comment from a ZIP archive. Intended
+to be used by 'zip' recipe module internally. Should not be used elsewhere.
+"""
+
+from __future__ import print_function
+
+import json
+import os
+import sys
+import zipfile
+
+
+def main():
+  # See zip/api.py, def unzip(...) for format of |data|.
+  data = json.load(sys.stdin)
+  zip_file = data['zip_file']
+
+  # Archive path should exist and be an absolute path to a file.
+  assert os.path.exists(zip_file), zip_file
+  assert os.path.isfile(zip_file), zip_file
+
+  with zipfile.ZipFile(zip_file) as zip_file_obj:
+    sys.stdout.buffer.write(zip_file_obj.comment)
+  return 0
+
+
+if __name__ == '__main__':
+  sys.exit(main())
diff --git a/recipes/recipes/luci-test.expected/basic.json b/recipes/recipes/luci-test.expected/basic.json
index 58c2766..b32453c 100644
--- a/recipes/recipes/luci-test.expected/basic.json
+++ b/recipes/recipes/luci-test.expected/basic.json
@@ -145,6 +145,123 @@
     ]
   },
   {
+    "cmd": [],
+    "name": "get chrome"
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "cp",
+      "gs://chromium-browser-snapshots/Linux_x64/LAST_CHANGE",
+      "[CLEANUP]/chrome_tmp_1"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "gerrit:try"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "get chrome.gsutil download",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/chrome_tmp_1/LAST_CHANGE",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "gerrit:try"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "get chrome.read latest chrome version",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_END@LAST_CHANGE@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "cp",
+      "gs://chromium-browser-snapshots/Linux_x64//chrome-linux.zip",
+      "[CLEANUP]/chrome_tmp_1"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "gerrit:try"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "get chrome.gsutil download (2)",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_MODULE[gerrit::zip]/resources/unzip.py"
+    ],
+    "luci_context": {
+      "realm": {
+        "name": "gerrit:try"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "get chrome.unzip chrome",
+    "stdin": "{\"output\": \"[CLEANUP]/chrome_tmp_1/zip\", \"quiet\": false, \"zip_file\": \"[CLEANUP]/chrome_tmp_1/chrome-linux.zip\"}",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
     "cmd": [
       "cipd",
       "ensure",
@@ -158,6 +275,9 @@
       "/path/to/tmp/json"
     ],
     "cwd": "[CACHE]/builder/src",
+    "env": {
+      "PATH": "[CLEANUP]/chrome_tmp_1/zip/chrome-linux:<PATH>"
+    },
     "infra_step": true,
     "luci_context": {
       "realm": {
@@ -193,6 +313,7 @@
     ],
     "cwd": "[CACHE]/builder/src",
     "env": {
+      "PATH": "[CLEANUP]/chrome_tmp_1/zip/chrome-linux:<PATH>",
       "npm_config_cache": "[CACHE]/npmcache/npm",
       "npm_config_prefix": "[CACHE]/npmcache/pfx"
     },
@@ -224,6 +345,7 @@
     ],
     "cwd": "[CACHE]/builder/src",
     "env": {
+      "PATH": "[CLEANUP]/chrome_tmp_1/zip/chrome-linux:<PATH>",
       "npm_config_cache": "[CACHE]/npmcache/npm",
       "npm_config_prefix": "[CACHE]/npmcache/pfx"
     },
diff --git a/recipes/recipes/luci-test.py b/recipes/recipes/luci-test.py
index b298cae..cc17a0f 100644
--- a/recipes/recipes/luci-test.py
+++ b/recipes/recipes/luci-test.py
@@ -7,11 +7,15 @@
 DEPS = [
   'depot_tools/bot_update',
   'depot_tools/gclient',
+  'depot_tools/gsutil',
   'recipe_engine/buildbucket',
   'recipe_engine/context',
+  'recipe_engine/file',
+  'recipe_engine/nodejs',
   'recipe_engine/path',
   'recipe_engine/step',
-  'recipe_engine/nodejs',
+  'recipe_engine/platform',
+  'zip',
 ]
 
 # Check out the change and run the tests to verify the change as part of Change
@@ -40,8 +44,15 @@
         gclient_config=gclient_config)
     api.step.raise_on_failure(update_result)
 
+  # Download Chrome and add to the PATH so that the test runner can launch it
+  # See https://github.com/GoogleChrome/chrome-launcher#launch-options
+  chrome_path = _getChrome(api)
+  env = {
+    'PATH': api.path.pathsep.join([str(chrome_path), '%(PATH)s']),
+  }
+
   # Now in the checked out code directory run our verification.
-  with api.context(cwd=api.path['cache'].join('builder').join(s.name)):
+  with api.context(env=env, cwd=api.path['cache'].join('builder').join(s.name)):
     # Current LTS for Node.js
     with api.nodejs(version='18.11.0'):
       # Named steps to test the change
@@ -59,3 +70,20 @@
       git_repo="https://gerrit.googlesource.com/luci-test",
     ),
   )
+
+# Download and unzip Chrome from a cloud storage bucket.
+# Copied from https://chromium.googlesource.com/infra/infra/+/refs/heads/main/recipes/recipes/gerrit_plugins.py
+def _getChrome(api):
+  with api.step.nest('get chrome'):
+    chrome = api.path.mkdtemp(prefix='chrome')
+    gs_bucket = 'chromium-browser-snapshots'
+    gs_path = 'Linux_x64'
+    version_file = 'LAST_CHANGE'
+    chrome_zip = 'chrome-linux.zip'
+    api.gsutil.download(gs_bucket, '%s/%s' % (gs_path, version_file), chrome)
+    version = api.file.read_text('read latest chrome version',
+                                 chrome.join(version_file))
+    api.gsutil.download(gs_bucket, '%s/%s/%s' % (gs_path, version, chrome_zip),
+                        chrome)
+    api.zip.unzip('unzip chrome', chrome.join(chrome_zip), chrome.join('zip'))
+    return chrome.join('zip', 'chrome-linux')