xref: /aosp_15_r20/external/skia/bazel/karma/karma_test.bzl (revision c8dee2aa9b3f27cf6c858bd81872bdeb2c07ed17)
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