From caecc030c3b3e43f5696b2b3bdb73e7466b55e22 Mon Sep 17 00:00:00 2001 From: Long Ho Date: Thu, 17 Sep 2026 14:28:06 +0000 Subject: [PATCH] feat: accept declarative VRT matching options --- README.md | 12 +++++++++--- docs/api.md | 20 +++++++++++++++++++- docs/component-vrt.md | 3 ++- docs/getting-started.md | 3 ++- examples/react/BUILD.bazel | 3 ++- internal/BUILD.bazel | 1 + internal/browser.bzl | 10 +++++++++- internal/matching.bzl | 21 +++++++++++++++++++++ runtime/BUILD.bazel | 22 ++++++++++++++++++++++ runtime/matching-config.test.ts | 27 +++++++++++++++++++++++++++ 10 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 internal/matching.bzl create mode 100644 runtime/matching-config.test.ts diff --git a/README.md b/README.md index 184e00f..366353e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ component_visual_test( name = "editor_vrt", browser = ":linux_browser", # browser_runtime; see docs/browser-runtime.md shell = ":editor_shell", - matching = ":matching", + matching = {"threshold": "0.1", "maxDiffPixels": "0"}, baselines = glob(["screenshots/*.png"], allow_empty = True), baseline_dir = "screenshots", ) @@ -50,9 +50,15 @@ component_visual_test( | `base_url` / `base_url_env` | Existing application endpoint, replacing `server` or `shell` | | `playwright` | Optional reusable runtime target grouping client packages; minimum 1.63.0 | | `browser` | Required VRT runtime containing declared Linux Chromium, Node, libraries, and fonts | -| `matching` | Compiled VRT comparison policy; render settings stay in `.visual.tsx` | +| `matching` | Declared comparison options or a compiled policy module; render settings stay in `.visual.tsx` | -For example, compile this `matching.ts` module: +Both component and page VRT accept the same `matching` dictionary. Values are +JSON numeric strings because Starlark has no floating-point values; for example, +`matching = {"maxDiffPixelRatio": "0.01"}` allows a one-percent mismatch budget. +The options are declared action inputs, without an environment adapter or a +TypeScript compilation target. See the [matching reference](docs/api.md#vrt-matching). + +Existing compiled `matching.ts` modules remain supported: ```ts import type {VisualMatching} from '@rules-web-e2e/vrt' diff --git a/docs/api.md b/docs/api.md index de6676f..fbe6423 100644 --- a/docs/api.md +++ b/docs/api.md @@ -46,7 +46,7 @@ Both `component_visual_test` and `visual_test` accept: | Attribute | Default | Contract | | -------------- | ------------------- | ------------------------------------------------------------------ | -| `matching` | Exact pixel budget | Compiled module exporting `VisualMatching` | +| `matching` | Exact pixel budget | Dictionary of JSON numeric strings, or compiled `VisualMatching` module | | `baselines` | `[]` | Existing PNG input labels | | `baseline_dir` | `"__screenshots__"` | Package-relative directory exclusively owned by this visual target | @@ -110,6 +110,24 @@ only the documented pinned version has been exercised by this repository's CI. ## VRT matching +Both `component_visual_test` and `visual_test` accept declared comparison +options directly: + +```starlark +matching = {"threshold": "0.1", "maxDiffPixelRatio": "0.01"} +``` + +Use JSON numeric strings for all dictionary values (Starlark has no floats). +The rule generates a dependency-free ESM policy included in the action's +runfiles. Each target can supply its own options without reading ambient +environment variables or compiling an adapter. Omitted options retain the +defaults below; `matching = {}` uses those defaults too. String contents are +parsed as JSON, never evaluated as JavaScript, and go through the same numeric +validation as compiled policies. Malformed or invalid values fail before capture. + +For policies that need a compiled module, the existing label form remains +supported: + ```ts import type {VisualMatching} from '@rules-web-e2e/vrt' diff --git a/docs/component-vrt.md b/docs/component-vrt.md index cdde4dc..45a6bbf 100644 --- a/docs/component-vrt.md +++ b/docs/component-vrt.md @@ -47,7 +47,8 @@ The consumer build owns strict typechecking, transpilation, providers, CSS, fonts, and generated assets. The runner does not compile the application. See [the complete example](../examples/react/BUILD.bazel). -A compiled `matching` module exports `VisualMatching`: configure per-pixel +`matching` accepts a dictionary of JSON numeric strings or a compiled module +exporting `VisualMatching`, just like page VRT: configure per-pixel `threshold` and either `maxDiffPixels` or `maxDiffPixelRatio`. Defaults use Playwright's pixelmatch comparator with threshold 0.1 and zero mismatched pixels. Viewport, language, theme, density, and capture hooks remain visual options. diff --git a/docs/getting-started.md b/docs/getting-started.md index fa37fda..e9e04c1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -71,7 +71,8 @@ see the [React gallery](../examples/react/gallery.tsx). ## VRT matching and updates -Compile a module exporting `VisualMatching` and pass it as `matching`. +Pass declared options such as `matching = {"maxDiffPixelRatio": "0.01"}`, +or compile a module exporting `VisualMatching` and pass its label as `matching`. Use `threshold` for per-pixel color tolerance, and either `maxDiffPixels` or `maxDiffPixelRatio` for the allowed mismatch budget. Defaults are threshold 0.1 and zero mismatched pixels. See the [matching reference](api.md#vrt-matching). diff --git a/examples/react/BUILD.bazel b/examples/react/BUILD.bazel index 03cae07..dc21c30 100644 --- a/examples/react/BUILD.bazel +++ b/examples/react/BUILD.bazel @@ -44,6 +44,7 @@ visual_test( allow_empty = True, ), config = ":native_config", + matching = {"threshold": "0.1", "maxDiffPixels": "0"}, tests = ":native_visual_specs", ) @@ -160,7 +161,7 @@ component_visual_test( ["__screenshots__/*.png"], allow_empty = True, ), - matching = ":matching", + matching = {"threshold": "0.1", "maxDiffPixels": "0"}, shell = ":app_shell", ) diff --git a/internal/BUILD.bazel b/internal/BUILD.bazel index 6f3be53..2953862 100644 --- a/internal/BUILD.bazel +++ b/internal/BUILD.bazel @@ -2,6 +2,7 @@ load(":remote.bzl", "linux_platform") exports_files([ "browser.bzl", + "matching.bzl", "remote.bzl", ]) diff --git a/internal/browser.bzl b/internal/browser.bzl index 350a04a..155dbeb 100644 --- a/internal/browser.bzl +++ b/internal/browser.bzl @@ -2,6 +2,7 @@ load("@aspect_rules_js//js:defs.bzl", "js_library", "js_test") load("//playwright:defs.bzl", "BrowserRuntimeInfo", "PlaywrightInfo", "runfile") +load(":matching.bzl", "matching_config") load(":remote.bzl", "remote_browser_test") ShellInfo = provider(fields = ["directory", "entry_point"]) @@ -123,7 +124,7 @@ def browser_test( fail("Remote browser tests require explicit env values instead of env_inherit") if browser and base_url_env and base_url_env not in env: fail("Remote browser base_url_env must have an explicit env value") - if not visual and matching: + if not visual and matching != None: fail("matching is only supported by visual targets") if execution_timeout_seconds <= 0: fail("execution_timeout_seconds must be positive") @@ -138,6 +139,13 @@ def browser_test( fail("base_url must not be empty") if base_url_env and base_url_env not in env and base_url_env not in env_inherit: env_inherit = env_inherit + [base_url_env] + if type(matching) == "dict": + matching_config( + name = name + "_matching", + options = matching, + tags = tags, + ) + matching = ":" + name + "_matching" js_library(name = name + "_sources", srcs = baselines, data = data) _inputs( name = name + "_inputs", diff --git a/internal/matching.bzl b/internal/matching.bzl new file mode 100644 index 0000000..c7a911a --- /dev/null +++ b/internal/matching.bzl @@ -0,0 +1,21 @@ +"""Generate an ESM comparison policy from declared Bazel options.""" + +def _matching_config_impl(ctx): + for name in ctx.attr.options: + if name not in ["threshold", "maxDiffPixels", "maxDiffPixelRatio"]: + fail("Unknown VRT matching option: " + name) + module = ctx.actions.declare_file(ctx.label.name + ".mjs") + + # Starlark has no floats. Parse JSON numeric strings without evaluating code; + # the shared runtime validator enforces numeric types, ranges, and budgets. + entries = [ + "%s: JSON.parse(%s)" % (json.encode(name), json.encode(value)) + for name, value in ctx.attr.options.items() + ] + ctx.actions.write(module, "export default {" + ", ".join(entries) + "};\n") + return [DefaultInfo(files = depset([module]), runfiles = ctx.runfiles(files = [module]))] + +matching_config = rule( + implementation = _matching_config_impl, + attrs = {"options": attr.string_dict()}, +) diff --git a/runtime/BUILD.bazel b/runtime/BUILD.bazel index cf065b0..aac4b7b 100644 --- a/runtime/BUILD.bazel +++ b/runtime/BUILD.bazel @@ -1,6 +1,7 @@ load("@aspect_rules_js//js:defs.bzl", "js_library", "js_test") load("@aspect_rules_js//npm:defs.bzl", "npm_package") load("@aspect_rules_ts//ts:defs.bzl", "ts_project") +load("//internal:matching.bzl", "matching_config") load("//playwright:defs.bzl", "playwright_runtime") ts_project( @@ -213,6 +214,27 @@ js_test( entry_point = "built-inputs.test.js", ) +MATCHING_FIXTURES = { + "matching_defaults": {}, + "matching_ratio": {"threshold": "0.2", "maxDiffPixelRatio": "1e-2"}, + "matching_count": {"maxDiffPixels": "5"}, + "matching_empty": {"threshold": ""}, + "matching_nonfinite": {"threshold": "1e999"}, + "matching_null": {"threshold": "null"}, + "matching_out_of_range": {"maxDiffPixelRatio": "1.1"}, + "matching_fractional_count": {"maxDiffPixels": "0.5"}, + "matching_conflicting_budgets": {"maxDiffPixels": "1", "maxDiffPixelRatio": "0.01"}, +} + +[matching_config(name = name, options = options, testonly = True) for name, options in MATCHING_FIXTURES.items()] + +js_test( + name = "matching_config_test", + size = "small", + data = ["package.json", ":typecheck"] + [":" + name for name in MATCHING_FIXTURES], + entry_point = "matching-config.test.js", +) + js_test( name = "capture_browser_test", diff --git a/runtime/matching-config.test.ts b/runtime/matching-config.test.ts new file mode 100644 index 0000000..ca2d133 --- /dev/null +++ b/runtime/matching-config.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {screenshotMatching} from './matching.js' + +test('declared comparison policies use the same defaults and validation as compiled modules', async () => { + for (const [name, expected] of [ + ['matching_defaults', {threshold: 0.1, maxDiffPixelRatio: 0}], + ['matching_ratio', {threshold: 0.2, maxDiffPixelRatio: 0.01}], + ['matching_count', {threshold: 0.1, maxDiffPixels: 5}], + ] as const) { + const {default: matching} = await import(`./${name}.mjs`) + assert.deepEqual(screenshotMatching(matching), expected) + } + for (const name of [ + 'matching_empty', + 'matching_nonfinite', + 'matching_null', + 'matching_out_of_range', + 'matching_fractional_count', + 'matching_conflicting_budgets', + ]) { + await assert.rejects(async () => { + const {default: matching} = await import(`./${name}.mjs`) + screenshotMatching(matching) + }, {message: /JSON|VRT matching option|Choose maxDiffPixels/}) + } +})