diff --git a/docs/ui.md b/docs/ui.md index 4afccd6..a322beb 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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//` 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 @@ -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. diff --git a/internal/BUILD.bazel b/internal/BUILD.bazel index 44937cb..9964876 100644 --- a/internal/BUILD.bazel +++ b/internal/BUILD.bazel @@ -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", @@ -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"], +) diff --git a/internal/ui-config.mjs.tpl b/internal/ui-config.mjs.tpl new file mode 100644 index 0000000..ae88212 --- /dev/null +++ b/internal/ui-config.mjs.tpl @@ -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, + })), +} diff --git a/internal/ui-launcher.mjs.tpl b/internal/ui-launcher.mjs.tpl new file mode 100644 index 0000000..9e87ce3 --- /dev/null +++ b/internal/ui-launcher.mjs.tpl @@ -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) diff --git a/internal/ui-runfiles.mjs.tpl b/internal/ui-runfiles.mjs.tpl new file mode 100644 index 0000000..8fc2b2c --- /dev/null +++ b/internal/ui-runfiles.mjs.tpl @@ -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) +} diff --git a/internal/ui-wrapper-tool.mjs b/internal/ui-wrapper-tool.mjs new file mode 100644 index 0000000..9215fe1 --- /dev/null +++ b/internal/ui-wrapper-tool.mjs @@ -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`) +} diff --git a/internal/ui.bzl b/internal/ui.bzl index ea5e6fd..66991d7 100644 --- a/internal/ui.bzl +++ b/internal/ui.bzl @@ -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"]) @@ -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() @@ -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, diff --git a/runtime/BUILD.bazel b/runtime/BUILD.bazel index 324f679..34fe189 100644 --- a/runtime/BUILD.bazel +++ b/runtime/BUILD.bazel @@ -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"], diff --git a/runtime/ui.test.ts b/runtime/ui.test.ts deleted file mode 100644 index 33090d4..0000000 --- a/runtime/ui.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import assert from 'node:assert/strict' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import {test} from 'node:test' -import {pathToFileURL} from 'node:url' -import {aggregateConfig} from './ui.js' - -test('aggregate selection includes only selected specs and overrides project discovery', async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-config-test-')) - try { - const original = path.join(directory, 'original.mjs') - fs.writeFileSync(original, 'export default {use: {baseURL: "http://localhost:1234"}, webServer: {command: "node server.js"}, projects: [{name: "chromium", testDir: "/wrong", testMatch: "wrong"}]}') - const tests = [path.join(directory, 'auth/a.spec.js'), path.join(directory, 'billing/b[1].spec.js')] - const output = path.join(directory, 'aggregate.mjs') - fs.writeFileSync(output, aggregateConfig(original, tests)) - const config = (await import(pathToFileURL(output).href)).default - assert.equal(config.testDir, directory) - assert.equal(config.use.baseURL, 'http://localhost:1234') - assert.equal(config.webServer[0].cwd, directory) - for (const file of tests) assert.ok(config.testMatch.some((pattern: RegExp) => pattern.test(file))) - assert.ok(!config.testMatch.some((pattern: RegExp) => pattern.test(path.join(directory, 'other.spec.js')))) - assert.equal(config.projects[0].name, 'chromium') - assert.equal(config.projects[0].testDir, directory) - assert.deepEqual(config.projects[0].testMatch, config.testMatch) - } finally { - fs.rmSync(directory, {recursive: true, force: true}) - } -}) - -test('empty aggregation fails before Playwright can discover unrelated specs', () => { - assert.throws(() => aggregateConfig('/config.js', []), /at least one spec/) -}) diff --git a/runtime/ui.ts b/runtime/ui.ts deleted file mode 100644 index e87a37a..0000000 --- a/runtime/ui.ts +++ /dev/null @@ -1,79 +0,0 @@ -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import {createRequire} from 'node:module' -import {spawn} from 'node:child_process' -import {pathToFileURL} from 'node:url' - -export function aggregateConfig(config: string, tests: string[]): string { - if (!tests.length) throw new Error('UI aggregation requires at least one spec') - let root = path.dirname(tests[0]) - while (tests.some(file => path.relative(root, file).split(path.sep)[0] === '..')) root = path.dirname(root) - const matches = tests.map(file => '^' + file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$') - return `import config from ${JSON.stringify(pathToFileURL(config).href)}; -const selection = {testDir: ${JSON.stringify(root)}, testMatch: ${JSON.stringify(matches)}.map(pattern => new RegExp(pattern)), testIgnore: []}; -const servers = config.webServer ? (Array.isArray(config.webServer) ? config.webServer : [config.webServer]).map(server => ({...server, cwd: server.cwd ?? ${JSON.stringify(path.dirname(config))}})) : undefined; -export default {...config, ...selection, webServer: servers, projects: config.projects?.map(project => ({...project, ...selection}))}; -` -} - -export async function launchUI(): Promise { - const runfiles = process.env.RUNFILES_DIR || process.env.JS_BINARY__RUNFILES - if (!runfiles) throw new Error('UI launcher requires Bazel runfiles') - const manifest = process.env.WEB_E2E_UI_INPUTS - if (!manifest) throw new Error('Missing WEB_E2E_UI_INPUTS') - const input = (relative: string) => path.join(runfiles, relative) - const plan = JSON.parse(fs.readFileSync(input(manifest), 'utf8')) as { - mode: 'compiled' | 'source' - config: string - tests: string[] - playwright: string - } - let testPackage = path.dirname(fs.realpathSync(path.join(input(plan.playwright), 'package.json'))) - const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'web-e2e-ui-')) - try { - let config: string - let cwd: string - if (plan.mode === 'source') { - const workspace = process.env.BUILD_WORKSPACE_DIRECTORY - if (!workspace) throw new Error('Source UI mode requires bazel run from a workspace') - config = path.join(workspace, plan.config) - cwd = workspace - testPackage = path.dirname(createRequire(pathToFileURL(config)).resolve('@playwright/test/package.json')) - } else { - const original = fs.realpathSync(input(plan.config)) - const tests = plan.tests.map(file => fs.realpathSync(input(file))) - for (const module of [...tests, original]) { - const resolved = createRequire(pathToFileURL(module)).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: ' + module) - } - config = path.join(scratch, 'playwright.config.mjs') - fs.writeFileSync(config, aggregateConfig(original, tests)) - cwd = path.join(runfiles, process.env.JS_BINARY__WORKSPACE || '_main') - } - const args = process.argv.slice(2) - const child = spawn(process.execPath, [path.join(testPackage, 'cli.js'), 'test', '--config', config, - ...(!args.includes('--list') && !args.includes('--help') && !args.includes('--ui') ? ['--ui'] : []), ...args], { - cwd, stdio: 'inherit', env: {...process.env, JS_BINARY__PATCH_NODE_FS: '0'}, - }) - const interrupt = () => child.kill('SIGINT') - const terminate = () => child.kill('SIGTERM') - process.on('SIGINT', interrupt) - process.on('SIGTERM', terminate) - try { - return await new Promise((resolve, reject) => { - child.once('error', reject) - child.once('exit', (code, signal) => resolve(code ?? (signal === 'SIGINT' ? 130 : 143))) - }) - } finally { - process.off('SIGINT', interrupt) - process.off('SIGTERM', terminate) - } - } finally { - fs.rmSync(scratch, {recursive: true, force: true}) - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) - launchUI().then(code => {process.exitCode = code}).catch(error => {console.error(error); process.exitCode = 1}) diff --git a/tests/ui/BUILD.bazel b/tests/ui/BUILD.bazel index 1ef78c6..763f81d 100644 --- a/tests/ui/BUILD.bazel +++ b/tests/ui/BUILD.bazel @@ -1,4 +1,4 @@ -load("@aspect_rules_js//js:defs.bzl", "js_library") +load("@aspect_rules_js//js:defs.bzl", "js_library", "js_test") load("@aspect_rules_ts//ts:defs.bzl", "ts_project") load("@rules_shell//shell:sh_test.bzl", "sh_test") load("//e2e:defs.bzl", "web_e2e_test", "web_e2e_ui") @@ -6,8 +6,10 @@ load("//e2e:defs.bzl", "web_e2e_test", "web_e2e_ui") ts_project( name = "compiled", srcs = [ + "aggregate.config.ts", "auth.spec.ts", "billing.spec.ts", + "expected-base-url.ts", "ui.config.ts", "unused.spec.ts", ], @@ -21,6 +23,7 @@ ts_project( "strict": True, "types": ["node"], "declaration": True, + "sourceMap": True, "skipLibCheck": True, }}, deps = [ @@ -70,16 +73,6 @@ web_e2e_ui( ], ) -web_e2e_ui( - name = "source", - mode = "source", - source_config = "source.config.ts", - suites = [ - ":auth_test", - ":billing_test", - ], -) - test_suite( name = "auth_suite", tags = ["manual"], @@ -97,7 +90,7 @@ test_suite( web_e2e_ui( name = "suite_aggregate", - config = ":config", + config = ":aggregate_config", data = ["package.json"], env = {"UI_FIXTURE_PACKAGE": "$(rootpath package.json)"}, suites = [ @@ -121,3 +114,38 @@ sh_test( args = ["$(rootpath :suite_aggregate)"], data = [":suite_aggregate"], ) + +js_library( + name = "aggregate_config", + srcs = ["aggregate.config.js"], + deps = [":compiled"], +) + +filegroup( + name = "suite_bundle", + testonly = True, + srcs = [":suite_aggregate_ui_inputs"], + output_group = "ui_bundle", +) + +js_test( + name = "bundle_test", + size = "small", + data = [ + ":suite_aggregate", + ":suite_bundle", + ], + entry_point = "bundle_test.mjs", + env = {"UI_BUNDLE": "$(rootpath :suite_bundle)"}, + patch_node_fs = False, +) + +js_test( + name = "headless_startup_test", + size = "small", + data = [":suite_aggregate"], + entry_point = "headless_startup_test.mjs", + env = {"UI_LAUNCHER": "$(rootpath :suite_aggregate)"}, + patch_node_fs = False, + target_compatible_with = ["@platforms//os:linux"], +) diff --git a/tests/ui/aggregate.config.ts b/tests/ui/aggregate.config.ts new file mode 100644 index 0000000..149b98b --- /dev/null +++ b/tests/ui/aggregate.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from '@playwright/test' +import config from './ui.config.js' + +export default defineConfig({ + ...config, + projects: [{name: 'overridden', testDir: './unselected', testMatch: '**/*.never'}], +}) diff --git a/tests/ui/auth.spec.ts b/tests/ui/auth.spec.ts index d10e7b0..69ad1eb 100644 --- a/tests/ui/auth.spec.ts +++ b/tests/ui/auth.spec.ts @@ -1,3 +1,4 @@ import {test, expect} from '@playwright/test' +import {expectedBaseURL} from './expected-base-url.js' -test('auth UI fixture', ({baseURL}) => {expect(baseURL).toBe('http://localhost:1234')}) +test('auth UI fixture', ({baseURL}) => {expect(baseURL).toBe(expectedBaseURL)}) diff --git a/tests/ui/bundle_test.mjs b/tests/ui/bundle_test.mjs new file mode 100644 index 0000000..c6db1e0 --- /dev/null +++ b/tests/ui/bundle_test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import {spawnSync} from 'node:child_process' +import {pathToFileURL} from 'node:url' + +const configFile = fs.realpathSync(path.join(process.env.UI_BUNDLE, 'playwright.config.mjs')) +const bundle = path.dirname(configFile) +const {default: config} = await import(pathToFileURL(configFile).href) +const {playwrightCli} = await import(pathToFileURL(path.join(bundle, 'runfiles.mjs')).href) +const snapshot = () => fs.readdirSync(bundle, {recursive: true}).sort().filter(file => fs.statSync(path.join(bundle, file)).isFile()) + .map(file => [file, fs.readFileSync(path.join(bundle, file), 'utf8')]) +const before = snapshot() +assert.equal(config.projects[0].name, 'overridden') +assert.equal(config.projects[0].testDir, path.join(bundle, 'specs')) +assert.equal(path.relative(bundle, config.outputDir).split(path.sep)[0], '..') + +const result = spawnSync(process.execPath, [playwrightCli, 'test', '--config', configFile, '--reporter=json'], { + encoding: 'utf8', + env: {...process.env, UI_FIXTURE_PACKAGE: undefined}, +}) +assert.equal(result.status, 0, result.stderr + result.stdout) +const report = JSON.parse(result.stdout) +assert.equal(report.stats.expected, 2) +assert.equal(report.stats.unexpected, 0) +assert.deepEqual(snapshot(), before) + +const selected = spawnSync(process.execPath, [playwrightCli, 'test', '--config', configFile, 'auth\\.spec\\.ts', '--reporter=json'], { + encoding: 'utf8', + env: {...process.env, UI_FIXTURE_PACKAGE: undefined}, +}) +assert.equal(selected.status, 0, selected.stderr + selected.stdout) +assert.equal(JSON.parse(selected.stdout).stats.expected, 1) diff --git a/tests/ui/expected-base-url.ts b/tests/ui/expected-base-url.ts new file mode 100644 index 0000000..a47ff1b --- /dev/null +++ b/tests/ui/expected-base-url.ts @@ -0,0 +1 @@ +export const expectedBaseURL = 'http://localhost:1234' diff --git a/tests/ui/headless_startup_test.mjs b/tests/ui/headless_startup_test.mjs new file mode 100644 index 0000000..6d9bba9 --- /dev/null +++ b/tests/ui/headless_startup_test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import {spawn} from 'node:child_process' +import path from 'node:path' + +const child = spawn(path.resolve(process.env.UI_LAUNCHER), [], { + env: {...process.env, DISPLAY: '', WAYLAND_DISPLAY: '', PWTEST_UNDER_TEST: ''}, + stdio: ['pipe', 'pipe', 'pipe'], +}) +let output = '' +try { + const url = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(output || 'UI startup timed out')), 20000) + const read = chunk => { + output += chunk + const match = output.match(/http:\/\/127\.0\.0\.1:\d+/) + if (match) { clearTimeout(timer); resolve(match[0]) } + } + child.stdout.on('data', read) + child.stderr.on('data', read) + child.once('error', error => { clearTimeout(timer); reject(error) }) + child.once('exit', code => { clearTimeout(timer); reject(new Error(`UI exited ${code}: ${output}`)) }) + }) + const response = await fetch(url) + assert.equal(response.status, 200) + assert.match(await response.text(), /Playwright/) + assert.equal(child.exitCode, null) +} finally { + child.kill('SIGINT') +} diff --git a/tests/ui/source.config.ts b/tests/ui/source.config.ts deleted file mode 100644 index d11d07f..0000000 --- a/tests/ui/source.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {defineConfig} from '@playwright/test' - -export default defineConfig({testMatch: ['auth.spec.ts', 'billing.spec.ts'], use: {baseURL: 'http://localhost:1234'}})