"""This module defines rules for running JS tests in a browser.""" load("@build_bazel_rules_nodejs//:providers.bzl", "ExternalNpmPackageInfo", "node_modules_aspect") # https://github.com/bazelbuild/rules_webtesting/blob/master/web/web.bzl load("@io_bazel_rules_webtesting//web:web.bzl", "web_test") # https://github.com/google/skia-buildbot/blob/main/bazel/test_on_env/test_on_env.bzl load("@org_skia_go_infra//bazel/test_on_env:test_on_env.bzl", "test_on_env") def karma_test(name, config_file, srcs, static_files = None, env = None, **kwargs): """Tests the given JS files using Karma and a browser provided by Bazel (Chromium) This rule injects some JS code into the karma config file and produces both that modified configuration file and a bash script which invokes Karma. That script is then invoked in an environment that has the Bazel-downloaded browser available and the tests run using it. When invoked via `bazel test`, the test runs in headless mode. When invoked via `bazel run`, a visible web browser appears for the user to inspect and debug. This draws inspiration from the karma_web_test implementation in concatjs https://github.com/bazelbuild/rules_nodejs/blob/700b7a3c5f97f2877320e6e699892ee706f85269/packages/concatjs/web_test/karma_web_test.bzl but we were unable to use it because they prevented us from defining some proxies ourselves, which we need in order to communicate our test gms (PNG files) to a server that runs alongside the test. This implementation is simpler than concatjs's and does not try to work for all situations nor bundle everything together. Args: name: The name of the rule which actually runs the tests. generated dependent rules will use this name plus an applicable suffix. config_file: A karma config file. The user is to expect a function called BAZEL_APPLY_SETTINGS is defined and should call it with the configuration object before passing it to config.set. srcs: A list of JavaScript test files or helpers. static_files: Arbitrary files which are available to be loaded. Files are served at: - `/static//` or - `/static///` Examples: - `/static/skia/modules/canvaskit/tests/assets/color_wheel.gif` - `/static/skia/modules/canvaskit/canvaskit_wasm/canvaskit.wasm` env: An optional label to a binary. If set, the test will be wrapped in a test_on_env rule, and this binary will be used as the "env" part of test_on_env. It will be started before the tests run and be running in parallel to them. See the test_on_env.bzl in the Skia Infra repo for more. **kwargs: Additional arguments are passed to @io_bazel_rules_webtesting/web_test. """ if len(srcs) == 0: fail("Must pass at least one file into srcs or there will be no tests to run") if not static_files: static_files = [] karma_test_name = name + "_karma_test" _karma_test( name = karma_test_name, srcs = srcs, deps = [ "@npm//karma-chrome-launcher", "@npm//karma-firefox-launcher", "@npm//karma-jasmine", "@npm//jasmine-core", ], config_file = config_file, static_files = static_files, visibility = ["//visibility:private"], tags = ["manual"], ) # See the following link for the options. # https://github.com/bazelbuild/rules_webtesting/blob/e9cf17123068b1123c68219edf9b274bf057b9cc/web/internal/web_test.bzl#L164 # TODO(kjlubick) consider using web_test_suite to test on Firefox as well. if not env: web_test( name = name, launcher = ":" + karma_test_name, browser = "@io_bazel_rules_webtesting//browsers:chromium-local", test = karma_test_name, **kwargs ) else: web_test_name = name + "_web_test" web_test( name = web_test_name, launcher = ":" + karma_test_name, browser = "@io_bazel_rules_webtesting//browsers:chromium-local", test = karma_test_name, visibility = ["//visibility:private"], **kwargs ) test_on_env( name = name, env = env, test = ":" + web_test_name, test_on_env_binary = "@org_skia_go_infra//bazel/test_on_env:test_on_env", ) # This JS code is injected into the the provided karma configuration file. It contains # Bazel-specific logic that could be re-used across different configuration files. # Concretely, it sets up the browser configuration and whether we want to just run the tests # and exit (e.g. the user ran `bazel test foo`) or if we want to have an interactive session # (e.g. the user ran `bazel run foo`). _apply_bazel_settings_js_code = """ (function(cfg) { // This is is a JS function provided via environment variables to let us resolve files // https://bazelbuild.github.io/rules_nodejs/Built-ins.html#nodejs_binary-templated_args const runfiles = require(process.env['BAZEL_NODE_RUNFILES_HELPER']); // Apply the paths to any files that are coming from other Bazel rules (e.g. compiled JS). function addFilePaths(cfg) { if (!cfg.files) { cfg.files = []; } cfg.files = cfg.files.concat([_BAZEL_SRCS]); cfg.basePath = "_BAZEL_BASE_PATH"; if (!cfg.proxies) { cfg.proxies = {}; } // The following is based off of the concatjs version // https://github.com/bazelbuild/rules_nodejs/blob/700b7a3c5f97f2877320e6e699892ee706f85269/packages/concatjs/web_test/karma.conf.js#L276 const staticFiles = [_BAZEL_STATIC_FILES]; for (const file of staticFiles) { // We need to find the actual path (symlinks can apparently cause issues on Windows). const resolvedFile = runfiles.resolve(file); cfg.files.push({pattern: resolvedFile, included: false}); // We want the file to be available on a path according to its location in the workspace // (and not the path on disk), so we use a proxy to redirect. // Prefixing the proxy path with '/absolute' allows karma to load files that are not // underneath the basePath. This doesn't see to be an official API. // https://github.com/karma-runner/karma/issues/2703 cfg.proxies['/static/' + file] = '/absolute' + resolvedFile; } } // Returns true if invoked with bazel run, i.e. the user wants to see the results on a real // browser. function isBazelRun() { // This env var seems to be a good indicator on Linux, at least. return !!process.env['DISPLAY']; } // Configures the settings to run chrome. function applyChromiumSettings(cfg, chromiumPath) { if (isBazelRun()) { cfg.browsers = ['Chrome']; cfg.singleRun = false; } else { // Invoked via bazel test, so run the tests once in a headless browser and be done cfg.browsers = ['ChromeHeadless']; cfg.singleRun = true; } try { // Setting the CHROME_BIN environment variable tells Karma which chrome to use. // We want it to use the Chrome brought via Bazel. process.env.CHROME_BIN = runfiles.resolve(chromiumPath); } catch { throw new Error(`Failed to resolve Chromium binary '${chromiumPath}' in runfiles`); } } function applyBazelSettings(cfg) { addFilePaths(cfg) // This is a JSON file that contains this metadata, mixed in with some other data, e.g. // the link to the correct executable for the given platform. // https://github.com/bazelbuild/rules_webtesting/blob/e9cf17123068b1123c68219edf9b274bf057b9cc/browsers/chromium-local.json const webTestMetadata = require(runfiles.resolve(process.env['WEB_TEST_METADATA'])); const webTestFiles = webTestMetadata['webTestFiles'][0]; const path = webTestFiles['namedFiles']['CHROMIUM']; if (path) { applyChromiumSettings(cfg, path); } else { throw new Error("not supported yet"); } } applyBazelSettings(cfg) // The user is expected to treat the BAZEL_APPLY_SETTINGS as a function name and pass in // the configuration as a parameter. Thus, we need to end such that our IIFE will be followed // by the parameter in parentheses and get passed in as cfg. })""" def _expand_templates_in_karma_config(ctx): # Wrap the absolute paths of our files in quotes and make them comma seperated so they # can go in the Karma files list. srcs = ['"{}"'.format(_absolute_path(ctx, f)) for f in ctx.files.srcs] src_list = ", ".join(srcs) # Set our base path to that which contains the karma configuration file. # This requires going up a few directory segments. This allows our absolute paths to # all be compatible with each other. config_segments = len(ctx.outputs.configuration.short_path.split("/")) base_path = "/".join([".."] * config_segments) static_files = ['"{}"'.format(_absolute_path(ctx, f)) for f in ctx.files.static_files] static_list = ", ".join(static_files) # Replace the placeholders in the embedded JS with those files. We cannot use .format() because # the curly braces from the JS code throw it off. apply_bazel_settings = _apply_bazel_settings_js_code.replace("_BAZEL_SRCS", src_list) apply_bazel_settings = apply_bazel_settings.replace("_BAZEL_BASE_PATH", base_path) apply_bazel_settings = apply_bazel_settings.replace("_BAZEL_STATIC_FILES", static_list) # Add in the JS fragment that applies the Bazel-specific settings to the provided config. # https://docs.bazel.build/versions/main/skylark/lib/actions.html#expand_template ctx.actions.expand_template( output = ctx.outputs.configuration, template = ctx.file.config_file, substitutions = { "BAZEL_APPLY_SETTINGS": apply_bazel_settings, }, ) def _absolute_path(ctx, file): # Referencing things in @npm yields a short_path that starts with ../ # For those cases, we can just remove the ../ if file.short_path.startswith("../"): return file.short_path[3:] # Otherwise, we have a local file, so we need to include the workspace path to make it # an absolute path return ctx.workspace_name + "/" + file.short_path _invoke_karma_bash_script = """#!/usr/bin/env bash # --- begin runfiles.bash initialization v2 --- # Copy-pasted from the Bazel Bash runfiles library v2. # https://github.com/bazelbuild/bazel/blob/master/tools/bash/runfiles/runfiles.bash set -uo pipefail; f=build_bazel_rules_nodejs/third_party/github.com/bazelbuild/bazel/tools/bash/runfiles/runfiles.bash source "${{RUNFILES_DIR:-/dev/null}}/$f" 2>/dev/null || \ source "$(grep -sm1 "^$f " "${{RUNFILES_MANIFEST_FILE:-/dev/null}}" | cut -f2- -d' ')" 2>/dev/null || \ source "$0.runfiles/$f" 2>/dev/null || \ source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \ source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \ {{ echo>&2 "ERROR: cannot find $f"; exit 1; }}; f=; set -e # --- end runfiles.bash initialization v2 --- readonly KARMA=$(rlocation "{_KARMA_EXECUTABLE_SCRIPT}") readonly CONF=$(rlocation "{_KARMA_CONFIGURATION_FILE}") # set a temporary directory as the home directory, because otherwise Chrome fails to # start up, complaining about a read-only file system. This does not get cleaned up automatically # by Bazel, so we do so after Karma finishes. export HOME=$(mktemp -d) readonly COMMAND="${{KARMA}} "start" ${{CONF}}" ${{COMMAND}} KARMA_EXIT_CODE=$? echo "Karma returned ${{KARMA_EXIT_CODE}}" # Attempt to clean up the temporary home directory. If this fails, that's not a big deal because # the contents are small and will be cleaned up by the OS on reboot. rm -rf $HOME || true exit $KARMA_EXIT_CODE """ def _create_bash_script_to_invoke_karma(ctx): ctx.actions.write( output = ctx.outputs.executable, is_executable = True, content = _invoke_karma_bash_script.format( _KARMA_EXECUTABLE_SCRIPT = _absolute_path(ctx, ctx.executable.karma), _KARMA_CONFIGURATION_FILE = _absolute_path(ctx, ctx.outputs.configuration), ), ) def _karma_test_impl(ctx): _expand_templates_in_karma_config(ctx) _create_bash_script_to_invoke_karma(ctx) # The files that need to be included when we run the bash script that invokes Karma are: # - The templated configuration file # - Any JS test files the user provided # - Any static files the user specified # - The other dependencies from npm (e.g. jasmine-core) runfiles = [ ctx.outputs.configuration, ] runfiles += ctx.files.srcs runfiles += ctx.files.static_files runfiles += ctx.files.deps # We need to add the sources for our Karma dependencies as transitive dependencies, otherwise # things like the karma-chrome-launcher will not be available for Karma to load. # https://docs.bazel.build/versions/main/skylark/lib/depset.html node_modules_depsets = [] for dep in ctx.attr.deps: if ExternalNpmPackageInfo in dep: node_modules_depsets.append(dep[ExternalNpmPackageInfo].sources) else: fail("Not an external npm file: " + dep) node_modules = depset(transitive = node_modules_depsets) # https://docs.bazel.build/versions/main/skylark/lib/DefaultInfo.html return [DefaultInfo( runfiles = ctx.runfiles( files = runfiles, transitive_files = node_modules, ).merge(ctx.attr.karma[DefaultInfo].data_runfiles), executable = ctx.outputs.executable, )] _karma_test = rule( implementation = _karma_test_impl, test = True, executable = True, attrs = { "config_file": attr.label( doc = "The karma config file", mandatory = True, allow_single_file = [".js"], ), "srcs": attr.label_list( doc = "A list of JavaScript test files", allow_files = [".js"], mandatory = True, ), "deps": attr.label_list( doc = """Any karma plugins (aka peer deps) required. These are generally listed in the provided config_file""", allow_files = True, aspects = [node_modules_aspect], mandatory = True, ), "karma": attr.label( doc = "karma binary label", # By default, we use the karma pulled in via Bazel running npm install default = "@npm//karma/bin:karma", executable = True, cfg = "exec", allow_files = True, ), "static_files": attr.label_list( doc = "Additional files which are available to be loaded", allow_files = True, ), }, outputs = { "configuration": "%{name}.conf.js", }, )