• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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