Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 23 additions & 29 deletions docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,20 @@ entries recursively; specify those entries rather than relying on implicit
package-wide test discovery. Bazel `test_suite` tag filtering is not applied by
UI aggregation: every explicitly listed managed E2E target is selected.

The rule selects compiled specs from each suite and includes their dependencies
and explicit data. Duplicate specs appear once. At launch, it writes a temporary
aggregate config that discovers exactly those specs. Source maps and original
source inputs are retained for debugging. Temporary configuration is removed
when Playwright exits. The UI process runs until closed and receives terminal
interrupts; it has no suite execution timer.
Bazel selects and deduplicates compiled specs during analysis. Declared actions
build an immutable directory artifact containing the aggregate config and import
modules for exactly those specs. A generated `js_binary` entry point starts the
declared Playwright CLI directly with Bazel's Node toolchain. No session-time
config generation, temporary spec copies, or workspace package-manager lookup.

The UI watches only the bundle's spec directory. It cannot recursively watch
neighboring Bazel outputs and runfiles, which exhausted macOS file descriptors
and caused `spawn EBADF` when workers started. Specs execute at their original
compiled locations, retaining relative imports, source maps, and debugger inputs.

Test artifacts use the caller's configured output directory or default to
`test-results/<package>/<target>` under the workspace. Bazel tests use their
output/temp directory instead. The bundle itself is never modified by the UI.

Supply one aggregate compiled config, including browser settings and a shared
`webServer` if needed. Individual suite configs and environment variables are
Expand All @@ -50,28 +58,14 @@ working directory. Declare extra server dependencies in `data` and values in
Specs and config must resolve the same `@playwright/test` instance as the declared
runtime, matching the regular test rule's package contract.

Compiled mode displays the emitted JavaScript executed by CI. Watching workspace
TypeScript does not rebuild Bazel outputs automatically. Rebuild/relaunch after
source edits, or use source mode for the live edit/watch workflow.

## Workspace source mode

```starlark
web_e2e_ui(
name = "e2e_ui_source",
suites = [":e2e_tests"],
mode = "source",
source_config = "playwright.source.config.ts",
)
```
The UI displays the emitted JavaScript executed by CI. Rebuild/relaunch after
TypeScript edits. `mode = "source"` and `source_config` are no longer supported;
this rule uses declared compiled suites and the declared Playwright runtime only.

Source mode runs the caller's original config from `BUILD_WORKSPACE_DIRECTORY`.
That config owns source test discovery, TypeScript aliases, server startup, and
watch behavior. Configure it to discover the same feature specs selected by the
suite list. It uses the workspace's installed `@playwright/test`, so run the
workspace package-manager setup first. It inherits the developer's environment
and browser installation. It deliberately runs outside hermetic test actions;
source discovery is not constrained to the compiled suite selection.
Managed-server E2E suites are supported. Component, visual, and process-owned
suites require different runtime contracts and are rejected.

Both modes currently support managed-server E2E suites. Component, visual, and
process-owned suites require different runtime contracts and are rejected.
On Linux without `DISPLAY` or `WAYLAND_DISPLAY`, the launcher serves the UI on
loopback and prints its URL instead of opening a headed browser. Forward that
port from your devbox and open it locally. Explicit `--ui-host` and `--ui-port`
options override the default.
16 changes: 15 additions & 1 deletion internal/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
load("@aspect_rules_js//js:defs.bzl", "js_binary")
load(":remote.bzl", "linux_platform")

exports_files([
"capture-launcher.sh.tpl",
"ui-config.mjs.tpl",
"ui-runfiles.mjs.tpl",
"ui-launcher.mjs.tpl",
"browser.bzl",
"snapshots.bzl",
"matching.bzl",
Expand Down Expand Up @@ -31,4 +35,14 @@ linux_platform(
target_compatible_with = ["@platforms//os:linux"],
)

exports_files(["matching-config.ts"], visibility = ["//visibility:private"])
exports_files(
["matching-config.ts"],
visibility = ["//visibility:private"],
)

js_binary(
name = "ui_wrapper_tool",
entry_point = "ui-wrapper-tool.mjs",
patch_node_fs = False,
visibility = ["//visibility:private"],
)
33 changes: 33 additions & 0 deletions internal/ui-config.mjs.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {fileURLToPath} from 'node:url'
import {importModule, resolveRunfile} from './runfiles.mjs'

const configFile = fs.realpathSync(resolveRunfile(%{config}))
const {default: config} = await importModule(%{config})
const originalDirectory = path.dirname(configFile)
const selection = {
testDir: path.join(path.dirname(fileURLToPath(import.meta.url)), 'specs'),
testMatch: '**/*.js',
testIgnore: [],
}
const reportRoot = path.resolve(path.dirname(fs.realpathSync(resolveRunfile(%{root_anchor}))), %{root_up})
const resultsRoot = process.env.TEST_UNDECLARED_OUTPUTS_DIR || process.env.TEST_TMPDIR || process.env.BUILD_WORKSPACE_DIRECTORY || os.tmpdir()
const outputDir = config.outputDir ? path.resolve(originalDirectory, config.outputDir) : path.join(resultsRoot, 'test-results', %{output_path})
const servers = config.webServer ? (Array.isArray(config.webServer) ? config.webServer : [config.webServer]).map(server => ({
...server, cwd: server.cwd ?? originalDirectory,
})) : undefined

export default {
...config,
...selection,
testDir: reportRoot,
outputDir,
webServer: servers,
projects: (config.projects ?? [{}]).map(project => ({
...project,
...selection,
outputDir: project.outputDir ? path.resolve(originalDirectory, project.outputDir) : outputDir,
})),
}
18 changes: 18 additions & 0 deletions internal/ui-launcher.mjs.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import path from 'node:path'
import {pathToFileURL} from 'node:url'

const root = process.env.RUNFILES_DIR || process.env.JS_BINARY__RUNFILES
if (!root) throw new Error('Playwright UI requires Bazel runfiles')
const bundle = path.join(root, %{bundle})
const {playwrightCli} = await import(pathToFileURL(path.join(bundle, 'runfiles.mjs')).href)
const args = process.argv.slice(2)
const serverOnly = process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY
if (serverOnly && !args.includes('--list') && !args.includes('--help') && !args.some(arg => /^--ui-(host|port)(=|$)/.test(arg)))
args.push('--ui-host=127.0.0.1')
process.chdir(path.join(root, process.env.JS_BINARY__WORKSPACE || '_main'))
process.argv = [
process.execPath, playwrightCli, 'test', '--config', path.join(bundle, 'playwright.config.mjs'),
...(!args.includes('--list') && !args.includes('--help') && !args.includes('--ui') ? ['--ui'] : []),
...args,
]
await import(pathToFileURL(playwrightCli).href)
22 changes: 22 additions & 0 deletions internal/ui-runfiles.mjs.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import fs from 'node:fs'
import path from 'node:path'
import {createRequire} from 'node:module'
import {pathToFileURL} from 'node:url'

const root = process.env.RUNFILES_DIR || process.env.JS_BINARY__RUNFILES
if (!root) throw new Error('Playwright UI requires Bazel runfiles')

export function resolveRunfile(relative) {
return path.join(root, relative)
}

const testPackage = path.dirname(fs.realpathSync(path.join(resolveRunfile(%{playwright}), 'package.json')))
export const playwrightCli = path.join(testPackage, 'cli.js')

export async function importModule(relative) {
const file = fs.realpathSync(resolveRunfile(relative))
const resolved = createRequire(pathToFileURL(file)).resolve('@playwright/test/package.json')
if (path.dirname(fs.realpathSync(resolved)) !== testPackage)
throw new Error('UI specs and config must resolve the declared playwright runtime: ' + file)
return import(pathToFileURL(file).href)
}
19 changes: 19 additions & 0 deletions internal/ui-wrapper-tool.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import fs from 'node:fs'
import path from 'node:path'

process.chdir(process.env.JS_BINARY__EXECROOT)
for (const spec of JSON.parse(fs.readFileSync(process.argv[2], 'utf8'))) {
const content = fs.readFileSync(spec.input, 'utf8')
const reference = [...content.matchAll(/\/\/# sourceMappingURL=(.+)/g)].at(-1)?.[1].trim()
let sources = [spec.input]
if (reference) {
const inline = reference.startsWith('data:')
const mapFile = inline ? spec.input : path.join(path.dirname(spec.input), reference)
const map = JSON.parse(inline ? Buffer.from(reference.split(',')[1], 'base64').toString() : fs.readFileSync(mapFile, 'utf8'))
sources = map.sources.map(source => path.join(path.dirname(mapFile), map.sourceRoot || '', source))
}
// UI file filters use source locations; imported specs keep their own mappings.
const map = {version: 3, sources: sources.map(source => path.relative(path.dirname(spec.destination), source)), names: [], mappings: ''}
const encoded = Buffer.from(JSON.stringify(map)).toString('base64')
fs.writeFileSync(spec.output, `import { importModule } from ${JSON.stringify(spec.helper)};\nawait importModule(${JSON.stringify(spec.runfile)});\n//# sourceMappingURL=data:application/json;base64,${encoded}\n`)
}
133 changes: 100 additions & 33 deletions internal/ui.bzl
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Aggregate browser suites for local Playwright development."""
"""Aggregate browser suites into a declared Playwright UI bundle."""

load("@aspect_rules_js//js:defs.bzl", "js_binary")
load("@bazel_lib//lib:paths.bzl", "to_rlocation_path")
load("@bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory_bin_action")
load("@bazel_lib//lib:paths.bzl", "to_repository_relative_path", "to_rlocation_path")
load("//playwright:defs.bzl", "PlaywrightInfo")

BrowserSuiteInfo = provider(fields = ["tests", "runfiles", "mode"])
Expand All @@ -21,6 +22,15 @@ def _suites_impl(target, ctx):

_suites = aspect(implementation = _suites_impl, attr_aspects = ["data", "tests"])

def _common_directory(files):
parts = [file.path.split("/")[:-1] for file in files]
common = []
for i in range(min([len(value) for value in parts])):
if any([value[i] != parts[0][i] for value in parts]):
break
common.append(parts[0][i])
return common

def _inputs_impl(ctx):
tests = []
runfiles = ctx.runfiles()
Expand All @@ -36,59 +46,116 @@ def _inputs_impl(ctx):
tests = depset(tests).to_list()
if not tests:
fail("suites must select at least one compiled spec")
if ctx.attr.mode == "compiled":
if not ctx.attr.config:
fail("compiled mode requires an aggregate config")
config_files = [file for file in ctx.files.config if file.extension in ["js", "mjs"]]
if len(config_files) != 1 or config_files[0].is_source:
fail("config must supply one compiled JavaScript module")
config = to_rlocation_path(ctx, config_files[0])
runfiles = runfiles.merge(ctx.attr.config[DefaultInfo].default_runfiles).merge(ctx.runfiles(files = ctx.files.config))
else:
if not ctx.file.source_config or not ctx.file.source_config.is_source or ctx.file.source_config.short_path.startswith("../"):
fail("source mode requires a workspace source_config")
config = ctx.file.source_config.short_path
config_files = [file for file in ctx.files.config if file.extension in ["js", "mjs"]]
if len(config_files) != 1 or config_files[0].is_source:
fail("config must supply one compiled JavaScript module")
runtime = ctx.attr.playwright[PlaywrightInfo]
manifest = ctx.actions.declare_file(ctx.label.name + ".json")
ctx.actions.write(manifest, json.encode({
"mode": ctx.attr.mode,
"config": config,
"tests": [to_rlocation_path(ctx, file) for file in tests],
"playwright": runtime.test,
}))
for target in [ctx.attr.playwright] + ctx.attr.data:
bundle = ctx.actions.declare_directory(ctx.label.name + ".suite")
generated = []
destinations = {}
wrappers = []
for file in tests:
destination = "specs/" + to_rlocation_path(ctx, file) + ".js"
wrapper = ctx.actions.declare_file(ctx.label.name + ".generated/" + destination)
helper = "../" * (len(destination.split("/")) - 1) + "runfiles.mjs"
wrappers.append({
"input": file.path,
"output": wrapper.path,
"destination": bundle.path + "/" + destination,
"helper": helper,
"runfile": to_rlocation_path(ctx, file),
})
generated.append(wrapper)
destinations[to_repository_relative_path(wrapper)] = destination
manifest = ctx.actions.declare_file(ctx.label.name + ".wrappers.json")
ctx.actions.write(manifest, json.encode(wrappers))
ctx.actions.run(
executable = ctx.executable._wrapper_tool,
arguments = [manifest.path],
inputs = depset([manifest] + tests + [file for file in runfiles.files.to_list() if file.path.endswith(".map")]),
outputs = generated[:],
env = {"BAZEL_BINDIR": ctx.bin_dir.path},
mnemonic = "PlaywrightUiWrappers",
)
package = ctx.actions.declare_file(ctx.label.name + ".generated/package.json")
ctx.actions.write(package, '{"type":"module"}')
generated.append(package)
destinations[to_repository_relative_path(package)] = "package.json"
root_up = "/".join([".."] * (len(tests[0].path.split("/")) - 1 - len(_common_directory(tests)))) or "."
substitutions = {
"%{config}": json.encode(to_rlocation_path(ctx, config_files[0])),
"%{playwright}": json.encode(runtime.test),
"%{root_anchor}": json.encode(to_rlocation_path(ctx, tests[0])),
"%{root_up}": json.encode(root_up),
"%{output_path}": json.encode(ctx.label.package + "/" + ctx.attr.output_name),
}
for template, destination in [
(ctx.file._config_template, "playwright.config.mjs"),
(ctx.file._runfiles_template, "runfiles.mjs"),
]:
output = ctx.actions.declare_file(ctx.label.name + ".generated/" + destination)
ctx.actions.expand_template(template = template, output = output, substitutions = substitutions)
generated.append(output)
destinations[to_repository_relative_path(output)] = destination

# Playwright ignores individually symlinked specs. A tree artifact contains real
# files and bounds directory watching to the selected specs, not Bazel runfiles.
copy_to_directory_bin_action(
ctx,
name = ctx.label.name,
dst = bundle,
copy_to_directory_bin = ctx.toolchains["@bazel_lib//lib:copy_to_directory_toolchain_type"].copy_to_directory_info.bin,
files = generated,
root_paths = [],
include_external_repositories = ["**"],
replace_prefixes = destinations,
hardlink = "off",
)
launcher = ctx.actions.declare_file(ctx.label.name + ".mjs")
ctx.actions.expand_template(
template = ctx.file._launcher_template,
output = launcher,
substitutions = {"%{bundle}": json.encode(to_rlocation_path(ctx, bundle))},
)
for target in [ctx.attr.config, ctx.attr.playwright] + ctx.attr.data:
runfiles = runfiles.merge(target[DefaultInfo].default_runfiles).merge(ctx.runfiles(transitive_files = target[DefaultInfo].files))
return [DefaultInfo(files = depset([manifest]), runfiles = runfiles.merge(ctx.runfiles(files = [manifest])))]
return [
DefaultInfo(files = depset([launcher]), runfiles = runfiles.merge(ctx.runfiles(files = [launcher, bundle]))),
OutputGroupInfo(ui_bundle = depset([bundle])),
]

_inputs = rule(
implementation = _inputs_impl,
attrs = {
"suites": attr.label_list(mandatory = True, aspects = [_suites]),
"config": attr.label(allow_files = True),
"source_config": attr.label(allow_single_file = True),
"mode": attr.string(default = "compiled", values = ["compiled", "source"]),
"config": attr.label(mandatory = True, allow_files = True),
"playwright": attr.label(mandatory = True, providers = [PlaywrightInfo]),
"data": attr.label_list(allow_files = True),
"output_name": attr.string(mandatory = True),
"_wrapper_tool": attr.label(default = Label("//internal:ui_wrapper_tool"), executable = True, cfg = "exec"),
"_config_template": attr.label(default = Label("//internal:ui-config.mjs.tpl"), allow_single_file = True),
"_runfiles_template": attr.label(default = Label("//internal:ui-runfiles.mjs.tpl"), allow_single_file = True),
"_launcher_template": attr.label(default = Label("//internal:ui-launcher.mjs.tpl"), allow_single_file = True),
},
toolchains = ["@bazel_lib//lib:copy_to_directory_toolchain_type"],
)

def web_e2e_ui(name, suites, config = None, source_config = None, mode = "compiled", playwright = Label("//runtime:playwright"), data = [], env = {}, args = [], visibility = None):
"""Launch one local UI session over existing E2E suites with a shared config."""
def web_e2e_ui(name, suites, config, playwright = Label("//runtime:playwright"), data = [], env = {}, args = [], visibility = None):
"""Build an aggregate UI bundle and run it with declared Node and Playwright."""
_inputs(
name = name + "_ui_inputs",
suites = depset(suites).to_list(),
config = config,
source_config = source_config,
mode = mode,
playwright = playwright,
data = data,
output_name = name,
testonly = True,
)
js_binary(
name = name,
entry_point = Label("//runtime:ui_entry"),
data = [":" + name + "_ui_inputs", Label("//runtime:ui_files")] + data,
env = env | {"WEB_E2E_UI_INPUTS": "$(rlocationpath :%s_ui_inputs)" % name},
entry_point = ":" + name + "_ui_inputs",
data = [":" + name + "_ui_inputs"] + data,
env = env,
args = args,
patch_node_fs = False,
copy_data_to_bin = False,
Expand Down
19 changes: 0 additions & 19 deletions runtime/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -111,25 +111,6 @@ filegroup(
visibility = ["//visibility:public"],
)

filegroup(
name = "ui_entry",
srcs = ["ui.js"],
visibility = ["//visibility:public"],
)

js_library(
name = "ui_files",
srcs = ["ui.js", "package.json"],
visibility = ["//visibility:public"],
)

js_test(
name = "ui_test",
size = "small",
data = ["package.json", ":test_typecheck"],
entry_point = "ui.test.js",
)

js_library(
name = "remote_result_files",
srcs = ["remote-result.js", "baselines.js", "package.json"],
Expand Down
Loading
Loading