1""" 2THIS IS THE EXTERNAL-ONLY VERSION OF THIS FILE. G3 DOES NOT HAVE ONE AT ALL. 3 4This module defines rules for running JS tests in a browser. 5 6""" 7 8load("@build_bazel_rules_nodejs//:providers.bzl", "ExternalNpmPackageInfo", "node_modules_aspect") 9 10# https://github.com/bazelbuild/rules_webtesting/blob/master/web/web.bzl 11load("@io_bazel_rules_webtesting//web:web.bzl", "web_test") 12 13# https://github.com/google/skia-buildbot/blob/main/bazel/test_on_env/test_on_env.bzl 14load("@org_skia_go_infra//bazel/test_on_env:test_on_env.bzl", "test_on_env") 15 16def karma_test(name, config_file, srcs, static_files = None, env = None, **kwargs): 17 """Tests the given JS files using Karma and a browser provided by Bazel (Chromium) 18 19 This rule injects some JS code into the karma config file and produces both that modified 20 configuration file and a bash script which invokes Karma. That script is then invoked 21 in an environment that has the Bazel-downloaded browser available and the tests run using it. 22 23 When invoked via `bazel test`, the test runs in headless mode. When invoked via `bazel run`, 24 a visible web browser appears for the user to inspect and debug. 25 26 This draws inspiration from the karma_web_test implementation in concatjs 27 https://github.com/bazelbuild/rules_nodejs/blob/700b7a3c5f97f2877320e6e699892ee706f85269/packages/concatjs/web_test/karma_web_test.bzl 28 but we were unable to use it because they prevented us from defining some proxies ourselves, 29 which we need in order to communicate our test gms (PNG files) to a server that runs alongside 30 the test. This implementation is simpler than concatjs's and does not try to work for all 31 situations nor bundle everything together. 32 33 Args: 34 name: The name of the rule which actually runs the tests. generated dependent rules will use 35 this name plus an applicable suffix. 36 config_file: A karma config file. The user is to expect a function called BAZEL_APPLY_SETTINGS 37 is defined and should call it with the configuration object before passing it to config.set. 38 srcs: A list of JavaScript test files or helpers. 39 static_files: Arbitrary files which are available to be loaded. 40 Files are served at: 41 - `/static/<WORKSPACE_NAME>/<path-to-file>` or 42 - `/static/<WORKSPACE_NAME>/<path-to-rule>/<file>` 43 Examples: 44 - `/static/skia/modules/canvaskit/tests/assets/color_wheel.gif` 45 - `/static/skia/modules/canvaskit/canvaskit_wasm/canvaskit.wasm` 46 env: An optional label to a binary. If set, the test will be wrapped in a test_on_env rule, 47 and this binary will be used as the "env" part of test_on_env. It will be started before 48 the tests run and be running in parallel to them. See the test_on_env.bzl in the 49 Skia Infra repo for more. 50 **kwargs: Additional arguments are passed to @io_bazel_rules_webtesting/web_test. 51 """ 52 if len(srcs) == 0: 53 fail("Must pass at least one file into srcs or there will be no tests to run") 54 if not static_files: 55 static_files = [] 56 57 karma_test_name = name + "_karma_test" 58 _karma_test( 59 name = karma_test_name, 60 srcs = srcs, 61 deps = [ 62 "@npm//karma-chrome-launcher", 63 "@npm//karma-firefox-launcher", 64 "@npm//karma-jasmine", 65 "@npm//jasmine-core", 66 ], 67 config_file = config_file, 68 static_files = static_files, 69 visibility = ["//visibility:private"], 70 tags = ["manual", "no-remote"], 71 ) 72 73 # See the following link for the options. 74 # https://github.com/bazelbuild/rules_webtesting/blob/e9cf17123068b1123c68219edf9b274bf057b9cc/web/internal/web_test.bzl#L164 75 # TODO(kjlubick) consider using web_test_suite to test on Firefox as well. 76 if not env: 77 web_test( 78 name = name, 79 launcher = ":" + karma_test_name, 80 browser = "@io_bazel_rules_webtesting//browsers:chromium-local", 81 test = karma_test_name, 82 tags = [ 83 # https://bazel.build/reference/be/common-definitions#common.tags 84 "no-remote", 85 # native is required to be set by web_test for reasons that are not 86 # abundantly clear. 87 "native", 88 ], 89 **kwargs 90 ) 91 else: 92 web_test_name = name + "_web_test" 93 web_test( 94 name = web_test_name, 95 launcher = ":" + karma_test_name, 96 browser = "@io_bazel_rules_webtesting//browsers:chromium-local", 97 test = karma_test_name, 98 visibility = ["//visibility:private"], 99 tags = [ 100 # https://bazel.build/reference/be/common-definitions#common.tags 101 "no-remote", 102 "manual", 103 # native is required to be set by web_test for reasons that are not 104 # abundantly clear. 105 "native", 106 ], 107 **kwargs 108 ) 109 test_on_env( 110 name = name, 111 env = env, 112 test = ":" + web_test_name, 113 test_on_env_binary = "@org_skia_go_infra//bazel/test_on_env:test_on_env", 114 tags = ["no-remote"], 115 ) 116 117# This JS code is injected into the the provided karma configuration file. It contains 118# Bazel-specific logic that could be re-used across different configuration files. 119# Concretely, it sets up the browser configuration and whether we want to just run the tests 120# and exit (e.g. the user ran `bazel test foo`) or if we want to have an interactive session 121# (e.g. the user ran `bazel run foo`). 122_apply_bazel_settings_js_code = """ 123(function(cfg) { 124// This is is a JS function provided via environment variables to let us resolve files 125// https://bazelbuild.github.io/rules_nodejs/Built-ins.html#nodejs_binary-templated_args 126const runfiles = require(process.env['BAZEL_NODE_RUNFILES_HELPER']); 127 128// Apply the paths to any files that are coming from other Bazel rules (e.g. compiled JS). 129function addFilePaths(cfg) { 130 if (!cfg.files) { 131 cfg.files = []; 132 } 133 cfg.files = cfg.files.concat([_BAZEL_SRCS]); 134 cfg.basePath = "_BAZEL_BASE_PATH"; 135 136 if (!cfg.proxies) { 137 cfg.proxies = {}; 138 } 139 // The following is based off of the concatjs version 140 // https://github.com/bazelbuild/rules_nodejs/blob/700b7a3c5f97f2877320e6e699892ee706f85269/packages/concatjs/web_test/karma.conf.js#L276 141 const staticFiles = [_BAZEL_STATIC_FILES]; 142 for (const file of staticFiles) { 143 // We need to find the actual path (symlinks can apparently cause issues on Windows). 144 const resolvedFile = runfiles.resolve(file); 145 cfg.files.push({pattern: resolvedFile, included: false}); 146 // We want the file to be available on a path according to its location in the workspace 147 // (and not the path on disk), so we use a proxy to redirect. 148 // Prefixing the proxy path with '/absolute' allows karma to load files that are not 149 // underneath the basePath. This doesn't see to be an official API. 150 // https://github.com/karma-runner/karma/issues/2703 151 cfg.proxies['/static/' + file] = '/absolute' + resolvedFile; 152 } 153} 154 155// Returns true if invoked with bazel run, i.e. the user wants to see the results on a real 156// browser. 157function isBazelRun() { 158 // This env var seems to be a good indicator on Linux, at least. 159 return !!process.env['DISPLAY']; 160} 161 162// Configures the settings to run chrome. 163function applyChromiumSettings(cfg, chromiumPath) { 164 if (isBazelRun()) { 165 cfg.browsers = ['Chrome']; 166 cfg.singleRun = false; 167 } else { 168 // Invoked via bazel test, so run the tests once in a headless browser and be done 169 // When running on the CI, we saw errors like "No usable sandbox! Update your kernel or .. 170 // --no-sandbox". concatjs's version https://github.com/bazelbuild/rules_nodejs/blob/700b7a3c5f97f2877320e6e699892ee706f85269/packages/concatjs/web_test/karma.conf.js#L69 171 // detects if sandboxing is supported, but to avoid that complexity, we just always disable 172 // the sandbox. https://docs.travis-ci.com/user/chrome#karma-chrome-launcher 173 cfg.browsers = ['ChromeHeadlessNoSandbox']; 174 cfg.customLaunchers = { 175 'ChromeHeadlessNoSandbox': { 176 'base': 'ChromeHeadless', 177 'flags': [ 178 '--no-sandbox', 179 // may help tests be less flaky 180 // https://peter.sh/experiments/chromium-command-line-switches/#browser-test 181 '--browser-test', 182 ], 183 }, 184 } 185 cfg.singleRun = true; 186 } 187 188 try { 189 // Setting the CHROME_BIN environment variable tells Karma which chrome to use. 190 // We want it to use the Chrome brought via Bazel. 191 process.env.CHROME_BIN = runfiles.resolve(chromiumPath); 192 } catch { 193 throw new Error(`Failed to resolve Chromium binary '${chromiumPath}' in runfiles`); 194 } 195} 196 197function applyBazelSettings(cfg) { 198 addFilePaths(cfg) 199 200 // This is a JSON file that contains this metadata, mixed in with some other data, e.g. 201 // the link to the correct executable for the given platform. 202 // https://github.com/bazelbuild/rules_webtesting/blob/e9cf17123068b1123c68219edf9b274bf057b9cc/browsers/chromium-local.json 203 const webTestMetadata = require(runfiles.resolve(process.env['WEB_TEST_METADATA'])); 204 205 const webTestFiles = webTestMetadata['webTestFiles'][0]; 206 const path = webTestFiles['namedFiles']['CHROMIUM']; 207 if (path) { 208 applyChromiumSettings(cfg, path); 209 } else { 210 throw new Error("not supported yet"); 211 } 212} 213 214applyBazelSettings(cfg) 215 216// The user is expected to treat the BAZEL_APPLY_SETTINGS as a function name and pass in 217// the configuration as a parameter. Thus, we need to end such that our IIFE will be followed 218// by the parameter in parentheses and get passed in as cfg. 219})""" 220 221def _expand_templates_in_karma_config(ctx): 222 # Wrap the absolute paths of our files in quotes and make them comma seperated so they 223 # can go in the Karma files list. 224 srcs = ['"{}"'.format(_absolute_path(ctx, f)) for f in ctx.files.srcs] 225 src_list = ", ".join(srcs) 226 227 # Set our base path to that which contains the karma configuration file. 228 # This requires going up a few directory segments. This allows our absolute paths to 229 # all be compatible with each other. 230 config_segments = len(ctx.outputs.configuration.short_path.split("/")) 231 base_path = "/".join([".."] * config_segments) 232 233 static_files = ['"{}"'.format(_absolute_path(ctx, f)) for f in ctx.files.static_files] 234 static_list = ", ".join(static_files) 235 236 # Replace the placeholders in the embedded JS with those files. We cannot use .format() because 237 # the curly braces from the JS code throw it off. 238 apply_bazel_settings = _apply_bazel_settings_js_code.replace("_BAZEL_SRCS", src_list) 239 apply_bazel_settings = apply_bazel_settings.replace("_BAZEL_BASE_PATH", base_path) 240 apply_bazel_settings = apply_bazel_settings.replace("_BAZEL_STATIC_FILES", static_list) 241 242 # Add in the JS fragment that applies the Bazel-specific settings to the provided config. 243 # https://docs.bazel.build/versions/main/skylark/lib/actions.html#expand_template 244 ctx.actions.expand_template( 245 output = ctx.outputs.configuration, 246 template = ctx.file.config_file, 247 substitutions = { 248 "BAZEL_APPLY_SETTINGS": apply_bazel_settings, 249 }, 250 ) 251 252def _absolute_path(ctx, file): 253 # Referencing things in @npm yields a short_path that starts with ../ 254 # For those cases, we can just remove the ../ 255 if file.short_path.startswith("../"): 256 return file.short_path[3:] 257 258 # Otherwise, we have a local file, so we need to include the workspace path to make it 259 # an absolute path 260 return ctx.workspace_name + "/" + file.short_path 261 262_invoke_karma_bash_script = """#!/usr/bin/env bash 263# --- begin runfiles.bash initialization v2 --- 264# Copy-pasted from the Bazel Bash runfiles library v2. 265# https://github.com/bazelbuild/bazel/blob/master/tools/bash/runfiles/runfiles.bash 266set -uo pipefail; f=build_bazel_rules_nodejs/third_party/github.com/bazelbuild/bazel/tools/bash/runfiles/runfiles.bash 267source "${{RUNFILES_DIR:-/dev/null}}/$f" 2>/dev/null || \ 268 source "$(grep -sm1 "^$f " "${{RUNFILES_MANIFEST_FILE:-/dev/null}}" | cut -f2- -d' ')" 2>/dev/null || \ 269 source "$0.runfiles/$f" 2>/dev/null || \ 270 source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \ 271 source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \ 272 {{ echo>&2 "ERROR: cannot find $f"; exit 1; }}; f=; set -e 273# --- end runfiles.bash initialization v2 --- 274 275readonly KARMA=$(rlocation "{_KARMA_EXECUTABLE_SCRIPT}") 276readonly CONF=$(rlocation "{_KARMA_CONFIGURATION_FILE}") 277 278# set a temporary directory as the home directory, because otherwise Chrome fails to 279# start up, complaining about a read-only file system. This does not get cleaned up automatically 280# by Bazel, so we do so after Karma finishes. 281export HOME=$(mktemp -d) 282 283readonly COMMAND="${{KARMA}} "start" ${{CONF}}" 284${{COMMAND}} 285KARMA_EXIT_CODE=$? 286echo "Karma returned ${{KARMA_EXIT_CODE}}" 287# Attempt to clean up the temporary home directory. If this fails, that's not a big deal because 288# the contents are small and will be cleaned up by the OS on reboot. 289rm -rf $HOME || true 290exit $KARMA_EXIT_CODE 291""" 292 293def _create_bash_script_to_invoke_karma(ctx): 294 ctx.actions.write( 295 output = ctx.outputs.executable, 296 is_executable = True, 297 content = _invoke_karma_bash_script.format( 298 _KARMA_EXECUTABLE_SCRIPT = _absolute_path(ctx, ctx.executable.karma), 299 _KARMA_CONFIGURATION_FILE = _absolute_path(ctx, ctx.outputs.configuration), 300 ), 301 ) 302 303def _karma_test_impl(ctx): 304 _expand_templates_in_karma_config(ctx) 305 _create_bash_script_to_invoke_karma(ctx) 306 307 # The files that need to be included when we run the bash script that invokes Karma are: 308 # - The templated configuration file 309 # - Any JS test files the user provided 310 # - Any static files the user specified 311 # - The other dependencies from npm (e.g. jasmine-core) 312 runfiles = [ 313 ctx.outputs.configuration, 314 ] 315 runfiles += ctx.files.srcs 316 runfiles += ctx.files.static_files 317 runfiles += ctx.files.deps 318 319 # We need to add the sources for our Karma dependencies as transitive dependencies, otherwise 320 # things like the karma-chrome-launcher will not be available for Karma to load. 321 # https://docs.bazel.build/versions/main/skylark/lib/depset.html 322 node_modules_depsets = [] 323 for dep in ctx.attr.deps: 324 if ExternalNpmPackageInfo in dep: 325 node_modules_depsets.append(dep[ExternalNpmPackageInfo].sources) 326 else: 327 fail("Not an external npm file: " + dep) 328 node_modules = depset(transitive = node_modules_depsets) 329 330 # https://docs.bazel.build/versions/main/skylark/lib/DefaultInfo.html 331 return [DefaultInfo( 332 runfiles = ctx.runfiles( 333 files = runfiles, 334 transitive_files = node_modules, 335 ).merge(ctx.attr.karma[DefaultInfo].data_runfiles), 336 executable = ctx.outputs.executable, 337 )] 338 339_karma_test = rule( 340 implementation = _karma_test_impl, 341 test = True, 342 executable = True, 343 attrs = { 344 "config_file": attr.label( 345 doc = "The karma config file", 346 mandatory = True, 347 allow_single_file = [".js"], 348 ), 349 "srcs": attr.label_list( 350 doc = "A list of JavaScript test files", 351 allow_files = [".js"], 352 mandatory = True, 353 ), 354 "deps": attr.label_list( 355 doc = """Any karma plugins (aka peer deps) required. These are generally listed 356 in the provided config_file""", 357 allow_files = True, 358 aspects = [node_modules_aspect], 359 mandatory = True, 360 ), 361 "karma": attr.label( 362 doc = "karma binary label", 363 # By default, we use the karma pulled in via Bazel running npm install 364 default = "@npm//karma/bin:karma", 365 executable = True, 366 cfg = "exec", 367 allow_files = True, 368 ), 369 "static_files": attr.label_list( 370 doc = "Additional files which are available to be loaded", 371 allow_files = True, 372 ), 373 }, 374 outputs = { 375 "configuration": "%{name}.conf.js", 376 }, 377) 378