blob: 278db82d1a781f4cb89a67397b33b284fd3e303e [file] [log] [blame]
This module defines rules for running JS tests in a browser.
load("@build_bazel_rules_nodejs//:providers.bzl", "ExternalNpmPackageInfo", "node_modules_aspect")
load("@io_bazel_rules_webtesting//web:web.bzl", "web_test")
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
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.
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/<WORKSPACE_NAME>/<path-to-file>` or
- `/static/<WORKSPACE_NAME>/<path-to-rule>/<file>`
- `/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"
name = karma_test_name,
srcs = srcs,
deps = [
config_file = config_file,
static_files = static_files,
visibility = ["//visibility:private"],
tags = ["manual", "no-remote"],
# See the following link for the options.
# TODO(kjlubick) consider using web_test_suite to test on Firefox as well.
if not env:
name = name,
launcher = ":" + karma_test_name,
browser = "@io_bazel_rules_webtesting//browsers:chromium-local",
test = karma_test_name,
tags = [
# native is required to be set by web_test for reasons that are not
# abundantly clear.
web_test_name = name + "_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"],
tags = [
# native is required to be set by web_test for reasons that are not
# abundantly clear.
name = name,
env = env,
test = ":" + web_test_name,
test_on_env_binary = "@org_skia_go_infra//bazel/test_on_env:test_on_env",
tags = ["no-remote"],
# 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
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
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.
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
// When running on the CI, we saw errors like "No usable sandbox! Update your kernel or ..
// --no-sandbox". concatjs's version
// detects if sandboxing is supported, but to avoid that complexity, we just always disable
// the sandbox.
cfg.browsers = ['ChromeHeadlessNoSandbox'];
cfg.customLaunchers = {
'ChromeHeadlessNoSandbox': {
'base': 'ChromeHeadless',
'flags': [
// may help tests be less flaky
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) {
// 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.
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");
// 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.
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.
set -uo pipefail; f=build_bazel_rules_nodejs/third_party/
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}}"
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
def _create_bash_script_to_invoke_karma(ctx):
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):
# 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 = [
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.
node_modules_depsets = []
for dep in ctx.attr.deps:
if ExternalNpmPackageInfo in dep:
fail("Not an external npm file: " + dep)
node_modules = depset(transitive = node_modules_depsets)
return [DefaultInfo(
runfiles = ctx.runfiles(
files = runfiles,
transitive_files = node_modules,
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",