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