From 648aa1069f272d8a7bf9657b89c9ff8a615e0d99 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 7 Oct 2026 15:17:38 +0400 Subject: [PATCH 1/5] Resolve Node built-in subpaths and the package browser field Bundling a package that requires timers/promises failed with {:not_found, "timers/promises"}: the built-in check only knew top-level names, and the browser field that maps the specifier to false was not read. Specifiers a browser field maps to false now resolve to an empty module, those mapped to a path resolve to that path, and the builder, dev server and Tailwind resolver share one built-in check that covers subpaths. Fixes #64 --- CHANGELOG.md | 6 +++ lib/volt/builder/collector.ex | 6 ++- lib/volt/builder/resolver.ex | 54 +++++++++++++++++++++-- lib/volt/dev_server.ex | 2 +- lib/volt/js/specifier.ex | 14 ++++++ lib/volt/tailwind/resolver.ex | 3 +- test/volt/builder/resolution_test.exs | 63 +++++++++++++++++++++++++++ 7 files changed, 140 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a8e86a..16afbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- Treat Node built-in subpaths such as `timers/promises`, `fs/promises` and `stream/web` as built-ins, and read the `browser` field of packages. Bundling a package that requires `timers/promises`, such as `@sinonjs/fake-timers`, failed with `{:not_found, "timers/promises"}` even though its `browser` field maps the specifier to `false`. Specifiers a `browser` field maps to `false` now resolve to an empty module, and those mapped to a path resolve to that path, as in esbuild and webpack. The builder, the dev server and the Tailwind resolver share the built-in check ([#64](https://github.com/elixir-volt/volt/issues/64)). + ## 0.21.0 - 2026-10-05 ### Compatibility diff --git a/lib/volt/builder/collector.ex b/lib/volt/builder/collector.ex index 6960091..f06acc9 100644 --- a/lib/volt/builder/collector.ex +++ b/lib/volt/builder/collector.ex @@ -76,7 +76,8 @@ defmodule Volt.Builder.Collector do end defp read_module(module_id, plugins) do - with nil <- read_embedded_module(module_id, plugins), + with false <- Volt.Builder.Resolver.empty_module?(module_id), + nil <- read_embedded_module(module_id, plugins), nil <- read_plugin_module(module_id, plugins) do path = module_path(module_id) @@ -84,6 +85,9 @@ defmodule Volt.Builder.Collector do {:ok, source} -> {:ok, source, nil} error -> error end + else + true -> {:ok, "export {};\n", nil} + other -> other end end diff --git a/lib/volt/builder/resolver.ex b/lib/volt/builder/resolver.ex index 2afe1c2..dcebca9 100644 --- a/lib/volt/builder/resolver.ex +++ b/lib/volt/builder/resolver.ex @@ -6,16 +6,62 @@ defmodule Volt.Builder.Resolver do Returns `{:ok, path}`, `:skip` (for externals/node builtins), or `{:error, reason}`. """ + # A module with nothing in it, for imports a package's `browser` field maps + # to `false`. The collector supplies its source; see `empty_module?/1`. + @empty_module "volt:empty.js" + def resolve(specifier, importer, ctx) do {path_specifier, query} = Volt.JS.Specifier.split_query(specifier) - if external?(path_specifier, ctx.external) do - :skip + if external?(path_specifier, ctx.external), + do: :skip, + else: resolve_for_browser(path_specifier, importer, ctx, query) + end + + @doc "Return whether a resolved path is the empty module." + def empty_module?(path), do: path == @empty_module + + # A package's `browser` field says what a specifier means in browsers: `false` + # for nothing, as for Node built-ins a package only uses when it has them, or a + # path to a replacement. esbuild, webpack and Rollup honour it the same way. + defp resolve_for_browser(specifier, importer, ctx, query) do + case browser_field(specifier, importer) do + false -> {:ok, @empty_module} + {:replace, path} -> append_query(resolve_relative(path, importer, ctx), query) + nil -> do_resolve(specifier, importer, ctx, query) + end + end + + defp browser_field(_specifier, nil), do: nil + + defp browser_field(specifier, importer) do + with {:ok, package_dir, %{"browser" => browser}} when is_map(browser) <- + NPM.Resolution.PackageResolver.nearest_package(Path.dirname(importer_path(importer))), + {:ok, value} <- fetch_browser_mapping(browser, specifier) do + case value do + false -> false + path when is_binary(path) -> {:replace, Path.expand(path, package_dir)} + _other -> nil + end else - do_resolve(path_specifier, importer, ctx, query) + _none -> nil end end + defp fetch_browser_mapping(browser, specifier) do + Enum.find_value([specifier, "node:" <> specifier], :error, fn key -> + case Map.fetch(browser, key) do + {:ok, value} -> {:ok, value} + :error -> nil + end + end) + end + + defp importer_path(importer) do + {path, _query} = Volt.URL.split_query(importer) + path + end + def absolute?(specifier), do: Path.type(specifier) == :absolute defp do_resolve(specifier, importer, ctx, query) do @@ -46,7 +92,7 @@ defmodule Volt.Builder.Resolver do defp resolve_by_type(specifier, importer, ctx) do cond do - NPM.Resolution.PackageResolver.node_builtin?(specifier) -> + Volt.JS.Specifier.node_builtin?(specifier) -> :skip # OXC's transform imports helpers from `@oxc-project/runtime`, and `OXC.bundle/2` diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index 5551c72..b692e24 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -850,7 +850,7 @@ defmodule Volt.DevServer do specifier = URL.append_query(specifier, query) cond do - NPM.Resolution.PackageResolver.node_builtin?(specifier) -> + Volt.JS.Specifier.node_builtin?(specifier) -> :keep String.starts_with?(specifier, "#") -> diff --git a/lib/volt/js/specifier.ex b/lib/volt/js/specifier.ex index f418d80..1e7f6e6 100644 --- a/lib/volt/js/specifier.ex +++ b/lib/volt/js/specifier.ex @@ -16,6 +16,20 @@ defmodule Volt.JS.Specifier do def split_query(specifier), do: Volt.URL.split_query(specifier) + @doc """ + Return whether a specifier names a Node.js built-in module, including a + subpath of one such as `timers/promises` or `node:fs/promises`. + """ + @spec node_builtin?(String.t()) :: boolean() + def node_builtin?("node:" <> _rest), do: true + + def node_builtin?(specifier) do + specifier + |> String.split("/", parts: 2) + |> hd() + |> NPM.Resolution.PackageResolver.node_builtin?() + end + @oxc_runtime "@oxc-project/runtime" @doc """ diff --git a/lib/volt/tailwind/resolver.ex b/lib/volt/tailwind/resolver.ex index 81aef74..0b8e93b 100644 --- a/lib/volt/tailwind/resolver.ex +++ b/lib/volt/tailwind/resolver.ex @@ -42,8 +42,7 @@ defmodule Volt.Tailwind.Resolver do end end - def node_builtin_specifier?(specifier), - do: NPM.Resolution.PackageResolver.node_builtin?(specifier) + def node_builtin_specifier?(specifier), do: Volt.JS.Specifier.node_builtin?(specifier) def relative_specifier?(specifier), do: NPM.Resolution.PackageResolver.relative?(specifier) diff --git a/test/volt/builder/resolution_test.exs b/test/volt/builder/resolution_test.exs index 345b55e..95ccaf0 100644 --- a/test/volt/builder/resolution_test.exs +++ b/test/volt/builder/resolution_test.exs @@ -442,6 +442,69 @@ defmodule Volt.Builder.ResolutionTest do assert js =~ "ref(" end + test "leaves Node built-in subpaths out, as it does the built-ins themselves" do + File.write!(Path.join(@fixture_dir, "src/builtin_subpath_app.js"), """ + import { setTimeout as wait } from 'timers/promises' + import { readFile } from 'node:fs/promises' + export const run = () => wait(1).then(() => readFile('x')) + """) + + assert {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/builtin_subpath_app.js"), + outdir: @outdir, + format: :esm, + minify: false, + sourcemap: false + ) + + js = File.read!(result.js.path) + assert js =~ ~s(from "timers/promises") + assert js =~ ~s(from "node:fs/promises") + end + + test "maps what a package's browser field sets to false to an empty module" do + package = Path.join(@fixture_dir, "node_modules/fake-timers") + File.mkdir_p!(package) + + File.write!( + Path.join(package, "package.json"), + Jason.encode!(%{ + "name" => "fake-timers", + "main" => "index.js", + "browser" => %{"timers/promises" => false, "./clock.js" => "./clock-browser.js"} + }) + ) + + File.write!(Path.join(package, "index.js"), """ + let promises = null + try { promises = require('timers/promises') } catch (e) {} + const clock = require('./clock.js') + module.exports = { promises, clock } + """) + + File.write!(Path.join(package, "clock.js"), "module.exports = 'node clock'") + File.write!(Path.join(package, "clock-browser.js"), "module.exports = 'browser clock'") + + File.write!(Path.join(@fixture_dir, "src/browser_field_app.js"), """ + import timers from 'fake-timers' + console.log(timers.promises, timers.clock) + """) + + assert {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/browser_field_app.js"), + outdir: @outdir, + minify: false, + sourcemap: false + ) + + js = File.read!(result.js.path) + refute js =~ "timers/promises" + assert js =~ "browser clock" + refute js =~ "node clock" + end + test "keeps external imports in ES module output" do File.write!(Path.join(@fixture_dir, "src/external_app.js"), """ import { Socket } from 'phoenix' From a9221df177964c442bb9b68c230c8c91ceaf3eae Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 7 Oct 2026 15:37:19 +0400 Subject: [PATCH 2/5] Share one browser per test run and a context per test Every browser test started a Playwright driver and launched a browser for itself: the driver supervisor was linked to the test process, so it went away with each test, and starting both again cost more than the test. Volt.Test.Browser now keeps the driver and the launched browsers for the run under Volt.Supervisor, installs the test runtime in each context and bundles a test file once for all of its tests, bundling again only when one of its source files changes. The generated ExUnit modules for browser tests are async: true, since contexts isolate the tests from each other. Fixes #65 --- CHANGELOG.md | 4 + guides/features/testing.md | 2 + lib/volt/test/browser.ex | 167 +++++++++++++++++++++++++ lib/volt/test/browser/run.ex | 161 ++++++++++++++++++++++++ lib/volt/test/browser_runner.ex | 135 +++++--------------- lib/volt/test/ex_unit.ex | 13 +- test/volt/test/browser_runner_test.exs | 80 ++++++++++++ 7 files changed, 454 insertions(+), 108 deletions(-) create mode 100644 lib/volt/test/browser.ex create mode 100644 lib/volt/test/browser/run.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 16afbcb..6331557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- Browser tests share one browser per run, in `Volt.Test.Browser`, and each test gets a browser context of its own. Every test started a Playwright driver and launched a browser for itself, because the driver was linked to the test process and went away with it, which cost more than running the test. A test file is now bundled once for all of its tests and bundled again only when one of its source files changes, and the ExUnit modules generated for browser tests are `async: true` ([#65](https://github.com/elixir-volt/volt/issues/65)). + ### Fixed - Treat Node built-in subpaths such as `timers/promises`, `fs/promises` and `stream/web` as built-ins, and read the `browser` field of packages. Bundling a package that requires `timers/promises`, such as `@sinonjs/fake-timers`, failed with `{:not_found, "timers/promises"}` even though its `browser` field maps the specifier to `false`. Specifiers a `browser` field maps to `false` now resolve to an empty module, and those mapped to a path resolve to that path, as in esbuild and webpack. The builder, the dev server and the Tailwind resolver share the built-in check ([#64](https://github.com/elixir-volt/volt/issues/64)). diff --git a/guides/features/testing.md b/guides/features/testing.md index 38d96ef..0777204 100644 --- a/guides/features/testing.md +++ b/guides/features/testing.md @@ -311,6 +311,8 @@ npx playwright install chromium By default Volt uses `node_modules/playwright/cli.js` when present, otherwise `playwright` from `PATH`. Pass PlaywrightEx supervisor options with `playwright: [...]` if your executable lives elsewhere. +A test run shares one browser. `Volt.Test.Browser` starts the Playwright driver and launches the browser the first time a test asks for it, and every test gets a browser context of its own, which isolates its storage, cookies and pages. A test file is bundled once for all of its tests and bundled again only when one of its source files changes. Because tests no longer share state, the ExUnit modules Volt generates for browser tests are `async: true` and run concurrently with other async modules. + ## ExUnit integration Each collected JS/TS `test(...)` becomes an ExUnit test. That means normal ExUnit filtering, formatters, failures, and CI behavior continue to work. diff --git a/lib/volt/test/browser.ex b/lib/volt/test/browser.ex new file mode 100644 index 0000000..b18282f --- /dev/null +++ b/lib/volt/test/browser.ex @@ -0,0 +1,167 @@ +defmodule Volt.Test.Browser do + @moduledoc """ + The browser a test run shares. + + Launching a browser costs more than running a test in it, and starting the + Playwright driver that launches it costs about as much again. Both live here + for the whole run: the driver under this supervisor and the browsers in + `Volt.Test.Browser.Run`, launched the first time a test asks for one. Each + test gets a browser context of its own, which isolates its storage, cookies + and pages the way a browser of its own would. + + The run also keeps the bundled test modules the browser loads, so a file is + bundled once for all of its tests. See `module/2`. + + The supervisor is started under `Volt.Supervisor` on first use, with the + `:playwright` options of the configuration that started it. + """ + + use Supervisor + + alias Volt.Test.Browser.Run + alias Volt.Test.Config + + @playwright Volt.Test.Browser.Playwright + + @doc "Start the run's browser tree, or find it already running." + @spec start(Config.t()) :: {:ok, pid()} | {:error, term()} + def start(%Config{} = config) do + case Supervisor.start_child(Volt.Supervisor, {__MODULE__, config}) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + {:error, reason} -> {:error, reason} + end + end + + @doc false + def child_spec(config) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [config]}, + restart: :temporary, + type: :supervisor + } + end + + @doc false + def start_link(%Config{} = config) do + Supervisor.start_link(__MODULE__, config, name: __MODULE__) + end + + @impl true + def init(%Config{} = config) do + # The driver is a port and the browsers are its children, so a driver + # that went away takes the launched browsers with it. + children = [ + %{ + id: @playwright, + start: {PlaywrightEx.Supervisor, :start_link, [playwright_opts(config)]}, + type: :supervisor + }, + {Run, connection: connection()} + ] + + Supervisor.init(children, strategy: :rest_for_one) + end + + @doc "The Playwright connection the run's browsers belong to." + @spec connection() :: GenServer.name() + def connection, do: Module.concat(@playwright, "Connection") + + @doc """ + Open a context in the run's browser of `type`, with the test runtime + installed and a page on the run's blank document, and call `fun` with the + page's main frame. + + The context closes when `fun` returns or raises. A browser that is no longer + reachable, because it crashed since it was launched, is launched again once. + """ + @spec with_page(atom(), timeout(), (map() -> result)) :: result | {:error, term()} + when result: term() + def with_page(type, timeout, fun) do + with {:ok, browser} <- Run.launch(type, timeout), + {:ok, context} <- new_context(browser, type, timeout) do + try do + with {:ok, _} <- browser_context_add_init_script(context.guid, Run.runtime(), timeout), + {:ok, %{main_frame: frame}} <- browser_context_new_page(context.guid, timeout), + {:ok, _} <- frame_goto(frame.guid, Run.page_url(), timeout) do + fun.(frame) + end + after + browser_context_close(context.guid, timeout) + end + end + end + + defp new_context(browser, type, timeout) do + case browser_new_context(browser.guid, timeout) do + {:ok, context} -> + {:ok, context} + + {:error, _reason} -> + with :ok <- Run.forget(type, browser.guid), + {:ok, browser} <- Run.launch(type, timeout) do + browser_new_context(browser.guid, timeout) + end + end + end + + @doc """ + The `file:` URL of the bundled module for the test file at `path`, bundled + with `bundle_opts`. + + The bundle is kept for the run and reused while none of its source files + changed. + """ + @spec module(Path.t(), keyword()) :: {:ok, String.t()} | {:error, term()} + defdelegate module(path, bundle_opts), to: Run + + defp playwright_opts(%Config{} = config) do + config.playwright + |> Keyword.put_new(:timeout, config.timeout) + |> Keyword.put_new(:executable, playwright_executable()) + |> Keyword.put(:name, @playwright) + end + + defp playwright_executable do + local = Path.expand("node_modules/playwright/cli.js", Volt.Paths.root()) + if File.exists?(local), do: local, else: "playwright" + end + + # The playwright_ex dependency is only present in test environments, so its + # functions are called without compile-time references. + defp browser_new_context(browser_guid, timeout) do + apply(PlaywrightEx.Browser, :new_context, [ + browser_guid, + [connection: connection(), timeout: timeout] + ]) + end + + defp browser_context_add_init_script(context_guid, source, timeout) do + apply(PlaywrightEx.BrowserContext, :add_init_script, [ + context_guid, + [source: source, connection: connection(), timeout: timeout] + ]) + end + + defp browser_context_new_page(context_guid, timeout) do + apply(PlaywrightEx.BrowserContext, :new_page, [ + context_guid, + [connection: connection(), timeout: timeout] + ]) + end + + defp browser_context_close(context_guid, timeout) do + apply(PlaywrightEx.BrowserContext, :close, [ + context_guid, + [connection: connection(), timeout: timeout] + ]) + end + + defp frame_goto(frame_guid, url, timeout) do + apply(PlaywrightEx.Frame, :goto, [ + frame_guid, + [url: url, connection: connection(), timeout: timeout] + ]) + end +end diff --git a/lib/volt/test/browser/run.ex b/lib/volt/test/browser/run.ex new file mode 100644 index 0000000..3c5addd --- /dev/null +++ b/lib/volt/test/browser/run.ex @@ -0,0 +1,161 @@ +defmodule Volt.Test.Browser.Run do + @moduledoc """ + What one test run keeps: the browsers it launched, the test runtime and the + test modules it bundled. + + Browsers are launched once per type, on the first request. The test runtime, + `priv/ts/test/browser.ts`, is bundled once when the run starts and installed + in every context before its page loads. Bundled test modules are written to + `_build/volt/browser-tests`, emptied when the run starts, and are + reused while the source files they were bundled from keep their + modification times. + """ + + use GenServer + + @table __MODULE__ + + @type launched :: %{guid: String.t()} + + @doc false + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc "The browser of `type`, launched on first request." + @spec launch(atom(), timeout()) :: {:ok, launched()} | {:error, term()} + def launch(type, timeout) do + GenServer.call(__MODULE__, {:launch, type, timeout}, :infinity) + end + + @doc "Drop a browser that is no longer reachable, so the next request launches a new one." + @spec forget(atom(), String.t()) :: :ok + def forget(type, guid) do + GenServer.call(__MODULE__, {:forget, type, guid}) + end + + @doc "The `file:` URL of the blank page tests open." + @spec page_url() :: String.t() + def page_url do + file_url(Path.join(dir(), "index.html")) + end + + @doc "The test runtime, to install in a context before its page loads." + @spec runtime() :: String.t() + def runtime do + :ets.lookup_element(@table, :runtime, 2) + end + + @doc """ + The `file:` URL of the bundled module for the test file at `path`. + + Bundling happens in the caller, so tests of different files bundle + concurrently. Modules are named by their content, so two callers bundling + the same file at once write the same module. + """ + @spec module(Path.t(), keyword()) :: {:ok, String.t()} | {:error, term()} + def module(path, bundle_opts) do + key = {Path.expand(path), bundle_opts} + + case :ets.lookup(@table, key) do + [{^key, %{sources: sources, url: url}}] -> + if sources == modification_times(sources), do: {:ok, url}, else: bundle(key, bundle_opts) + + [] -> + bundle(key, bundle_opts) + end + end + + defp bundle(key, bundle_opts) do + with {:ok, bundled} <- Volt.Builder.bundle(bundle_opts), + {:ok, url} <- write_module(bundled.code) do + sources = modification_times(Enum.map(bundled.files, &{&1, nil})) + :ets.insert(@table, {key, %{sources: sources, url: url}}) + {:ok, url} + end + end + + defp modification_times(sources) do + Enum.map(sources, fn {path, _mtime} -> + case File.stat(path, time: :posix) do + {:ok, %File.Stat{mtime: mtime}} -> {path, mtime} + {:error, _reason} -> {path, nil} + end + end) + end + + defp write_module(code) do + name = Base.url_encode64(:crypto.hash(:sha256, code), padding: false) <> ".mjs" + path = Path.join(dir(), name) + + if File.regular?(path) do + {:ok, file_url(path)} + else + with :ok <- File.write(path, code), do: {:ok, file_url(path)} + end + end + + defp dir do + :ets.lookup_element(@table, :dir, 2) + end + + defp file_url(path) do + %URI{scheme: "file", path: path} |> URI.to_string() + end + + @impl true + def init(opts) do + # The VM halts at the end of `mix test` without stopping applications, so + # the directory is emptied at the start of a run rather than at its end. + dir = Volt.Paths.expand(Path.join(build_path(), "volt/browser-tests")) + + with {:ok, runtime} <- Volt.Priv.bundle({:volt, "ts"}, "test/browser.ts"), + {:ok, _removed} <- File.rm_rf(dir), + :ok <- File.mkdir_p(dir), + :ok <- + File.write(Path.join(dir, "index.html"), "") do + # The table appears once the run can serve from it. Callers reach it + # after `launch/2`, which waits for this initialization. + :ets.new(@table, [:named_table, :set, :public, read_concurrency: true]) + :ets.insert(@table, [{:dir, dir}, {:runtime, runtime}]) + {:ok, %{connection: Keyword.fetch!(opts, :connection), browsers: %{}}} + else + {:error, reason} -> {:stop, {:browser_test_run, reason}} + end + end + + @impl true + def handle_call({:launch, type, timeout}, _from, state) do + case Map.fetch(state.browsers, type) do + {:ok, browser} -> + {:reply, {:ok, browser}, state} + + :error -> + case launch_browser(type, state.connection, timeout) do + {:ok, browser} -> + {:reply, {:ok, browser}, put_in(state.browsers[type], browser)} + + {:error, reason} -> + {:reply, {:error, reason}, state} + end + end + end + + def handle_call({:forget, type, guid}, _from, state) do + case state.browsers do + %{^type => %{guid: ^guid}} -> + {:reply, :ok, %{state | browsers: Map.delete(state.browsers, type)}} + + _other -> + {:reply, :ok, state} + end + end + + defp build_path, do: System.get_env("MIX_BUILD_PATH") || "_build" + + # The playwright_ex dependency is only present in test environments, so its + # functions are called without compile-time references. + defp launch_browser(type, connection, timeout) do + apply(PlaywrightEx, :launch_browser, [type, [connection: connection, timeout: timeout]]) + end +end diff --git a/lib/volt/test/browser_runner.ex b/lib/volt/test/browser_runner.ex index a879c85..b52c49a 100644 --- a/lib/volt/test/browser_runner.ex +++ b/lib/volt/test/browser_runner.ex @@ -5,8 +5,12 @@ defmodule Volt.Test.BrowserRunner do This runner is intentionally small and mirrors `Volt.Test.Runner`'s result contract so ExUnit integration can switch between QuickBEAM and browser execution without changing assertions or reporting. + + The browser belongs to the run (see `Volt.Test.Browser`); each call opens a + context in it, loads the file's bundled module and closes the context again. """ + alias Volt.Test.Browser alias Volt.Test.Config @type result :: map() @@ -37,117 +41,38 @@ defmodule Volt.Test.BrowserRunner do config = Keyword.fetch!(opts, :config) timeout = Keyword.get(opts, :timeout, config.timeout) - with {:ok, bundled} <- Volt.Builder.bundle(Volt.Test.Shared.bundle_opts(path, config, opts)), - {:ok, test_url, cleanup} <- write_browser_test_module(bundled.code), - {:ok, runtime_code} <- browser_runtime_code(), - :ok <- ensure_playwright_started(config, timeout), - {:ok, browser} <- launch_browser(browser(config), timeout: timeout), - {:ok, context} <- browser_new_context(browser.guid, timeout: timeout), - {:ok, %{main_frame: frame}} <- browser_context_new_page(context.guid, timeout: timeout), - {:ok, _} <- frame_goto(frame.guid, url: test_url.page, timeout: timeout) do - try do - evaluate(frame, runtime_code, test_url.module, path, mode, test_id, timeout) - after - cleanup.() - browser_context_close(context.guid, timeout: timeout) - browser_close(browser.guid, timeout: timeout) - end - end - end - - defp browser_runtime_code do - Volt.Priv.bundle({:volt, "ts"}, "test/browser.ts") - end - - defp ensure_playwright_started(%Config{} = config, timeout) do - opts = - config.playwright - |> Keyword.put_new(:timeout, timeout) - |> Keyword.put_new(:executable, playwright_executable()) - - case playwright_supervisor_start_link(opts) do - {:ok, _pid} -> :ok - {:error, {:already_started, _pid}} -> :ok - {:error, reason} -> {:error, reason} + with {:ok, _pid} <- Browser.start(config) do + Browser.with_page(browser(config), timeout, fn frame -> + with {:ok, module_url} <- + Browser.module(path, Volt.Test.Shared.bundle_opts(path, config, opts)) do + evaluate(frame, module_url, path, mode, test_id, timeout) + end + end) end end - defp playwright_executable do - local = Path.expand("node_modules/playwright/cli.js") - if File.exists?(local), do: local, else: "playwright" - end - defp browser(%Config{browsers: [browser | _]}), do: browser defp browser(%Config{}), do: :chromium - defp write_browser_test_module(code) do - dir = Path.join(System.tmp_dir!(), "volt-browser-test-#{System.unique_integer([:positive])}") - page_path = Path.join(dir, "index.html") - module_path = Path.join(dir, "test.mjs") - - with :ok <- File.mkdir_p(dir), - :ok <- File.write(page_path, ""), - :ok <- File.write(module_path, code) do - {:ok, %{page: file_url(page_path), module: file_url(module_path)}, - fn -> File.rm_rf(dir) end} - end - end - - defp file_url(path) do - %URI{scheme: "file", path: Path.expand(path)} |> URI.to_string() - end - - defp evaluate(frame, runtime_code, test_url, file, mode, test_id, timeout) do - with {:ok, _} <- - frame_evaluate(frame.guid, - expression: runtime_code, - is_function: false, - arg: nil, - timeout: timeout - ) do - frame_evaluate(frame.guid, - expression: "payload => globalThis.__voltExecuteBrowserTest(payload)", - is_function: true, - arg: %{ - "testUrl" => test_url, - "file" => file, - "mode" => Atom.to_string(mode), - "testId" => test_id - }, - timeout: timeout - ) - end - end - - defp playwright_supervisor_start_link(opts) do - apply(PlaywrightEx.Supervisor, :start_link, [opts]) - end - - defp launch_browser(browser, opts) do - apply(PlaywrightEx, :launch_browser, [browser, opts]) - end - - defp browser_new_context(browser_guid, opts) do - apply(PlaywrightEx.Browser, :new_context, [browser_guid, opts]) - end - - defp browser_close(browser_guid, opts) do - apply(PlaywrightEx.Browser, :close, [browser_guid, opts]) - end - - defp browser_context_new_page(context_guid, opts) do - apply(PlaywrightEx.BrowserContext, :new_page, [context_guid, opts]) - end - - defp browser_context_close(context_guid, opts) do - apply(PlaywrightEx.BrowserContext, :close, [context_guid, opts]) - end - - defp frame_goto(frame_guid, opts) do - apply(PlaywrightEx.Frame, :goto, [frame_guid, opts]) - end - + # The page the run opens carries the test runtime, so the frame already has + # `__voltExecuteBrowserTest`. + defp evaluate(frame, test_url, file, mode, test_id, timeout) do + frame_evaluate(frame.guid, + expression: "payload => globalThis.__voltExecuteBrowserTest(payload)", + is_function: true, + arg: %{ + "testUrl" => test_url, + "file" => file, + "mode" => Atom.to_string(mode), + "testId" => test_id + }, + timeout: timeout + ) + end + + # The playwright_ex dependency is only present in test environments, so its + # functions are called without compile-time references. defp frame_evaluate(frame_guid, opts) do - apply(PlaywrightEx.Frame, :evaluate, [frame_guid, opts]) + apply(PlaywrightEx.Frame, :evaluate, [frame_guid, [connection: Browser.connection()] ++ opts]) end end diff --git a/lib/volt/test/ex_unit.ex b/lib/volt/test/ex_unit.ex index a6514ee..6558b29 100644 --- a/lib/volt/test/ex_unit.ex +++ b/lib/volt/test/ex_unit.ex @@ -19,7 +19,9 @@ defmodule Volt.Test.ExUnit do in normal ExUnit tags, formatters, failures, and CI behavior. Pass `browser: true` to execute tests in a real Playwright browser instead - of QuickBEAM while keeping the same ExUnit registration model. + of QuickBEAM while keeping the same ExUnit registration model. The run + shares one browser (see `Volt.Test.Browser`), every test gets a browser + context of its own, and the generated modules are `async: true`. By default, each JavaScript test becomes an ExUnit test. Pass `granularity: :file` to register one ExUnit test per file and execute the @@ -77,7 +79,7 @@ defmodule Volt.Test.ExUnit do quote do defmodule unquote(module) do - use ExUnit.Case, async: false + use ExUnit.Case, async: unquote(async?(config)) unquote_splicing(test_defs) end @@ -89,7 +91,7 @@ defmodule Volt.Test.ExUnit do quote do defmodule unquote(module) do - use ExUnit.Case, async: false + use ExUnit.Case, async: unquote(async?(config)) @moduletag js: true @moduletag volt_file: unquote(file) @@ -149,6 +151,11 @@ defmodule Volt.Test.ExUnit do defp runner(%Config{browser: true}), do: Volt.Test.BrowserRunner defp runner(%Config{}), do: Volt.Test.Runner + # Browser tests each run in a context of their own in the run's browser, so + # their modules run concurrently like any other async ExUnit modules. + defp async?(%Config{browser: true}), do: true + defp async?(%Config{}), do: false + defp test_tags(file, test) do [ js: true, diff --git a/test/volt/test/browser_runner_test.exs b/test/volt/test/browser_runner_test.exs index bd7eb7f..eb1542a 100644 --- a/test/volt/test/browser_runner_test.exs +++ b/test/volt/test/browser_runner_test.exs @@ -39,6 +39,86 @@ defmodule Volt.Test.BrowserRunnerTest do assert :ok = apply(module, name, [%{}]) end + test "browser test modules run concurrently", %{tmp_dir: tmp_dir} do + write!(tmp_dir, "async.browser.test.ts", ~TS""" + import { test, expect } from 'volt:test' + + test('is registered', () => { + expect(1).toBe(1) + }) + """) + + assert [module] = + Volt.Test.ExUnit.install( + root: tmp_dir, + include: ["async.browser.test.ts"], + browser: true + ) + + assert %{async?: true} = module.__ex_unit__(:config) + end + + test "tests share the run's browser and get a context each", %{tmp_dir: tmp_dir} do + file = + write!(tmp_dir, "shared.browser.test.ts", ~TS""" + import { test, expect } from 'volt:test' + + test('starts from an empty page', () => { + expect(localStorage.getItem('seen')).toBeNull() + localStorage.setItem('seen', 'yes') + expect(document.body.dataset.volt).toBeUndefined() + document.body.dataset.volt = 'seen' + }) + """) + + config = + Volt.Test.Config.read(browser: true, include: ["shared.browser.test.ts"], root: tmp_dir) + + assert {:ok, %Result{status: :passed}} = + Volt.Test.BrowserRunner.run_test(file, 1, config: config) + + {:ok, %{guid: guid}} = Volt.Test.Browser.Run.launch(:chromium, config.timeout) + + assert {:ok, %Result{status: :passed}} = + Volt.Test.BrowserRunner.run_test(file, 1, config: config) + + assert {:ok, %{guid: ^guid}} = Volt.Test.Browser.Run.launch(:chromium, config.timeout) + end + + test "bundles a file once until one of its sources changes", %{tmp_dir: tmp_dir} do + helper = write!(tmp_dir, "answer.ts", "export const answer = 41\n") + + file = + write!(tmp_dir, "bundled.browser.test.ts", ~TS""" + import { test, expect } from 'volt:test' + import { answer } from './answer' + + test('reads the helper', () => { + expect(answer).toBe(42) + }) + """) + + config = Volt.Test.Config.read(browser: true, root: tmp_dir) + bundle_opts = Volt.Test.Shared.bundle_opts(file, config, []) + {:ok, _pid} = Volt.Test.Browser.start(config) + {:ok, _browser} = Volt.Test.Browser.Run.launch(:chromium, config.timeout) + + assert {:ok, url} = Volt.Test.Browser.module(file, bundle_opts) + assert {:ok, ^url} = Volt.Test.Browser.module(file, bundle_opts) + + assert {:ok, %Result{status: :failed}} = + Volt.Test.BrowserRunner.run_test(file, 1, config: config) + + File.write!(helper, "export const answer = 42\n") + File.touch!(helper, System.os_time(:second) + 1) + + assert {:ok, next} = Volt.Test.Browser.module(file, bundle_opts) + assert next != url + + assert {:ok, %Result{status: :passed}} = + Volt.Test.BrowserRunner.run_test(file, 1, config: config) + end + test "collects and runs tests in a browser context", %{tmp_dir: tmp_dir} do file = write!(tmp_dir, "browser.test.ts", ~TS""" From c8c83158d658369080bf1f29ff5d1343701cd1f3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 7 Oct 2026 15:46:59 +0400 Subject: [PATCH 3/5] Type .vue imports by their generated declarations mix volt.js.check resolved an import of a .vue file only through a hand-written declare module "*.vue" shim, so every component had the same type. The Vue plugin now generates a declaration per checked .vue file with Vize.generate_dts/2, through a new Volt.Plugin.declaration/3 callback, and the check sends it to tsgolint as .vue.d.ts, which TypeScript resolves the import to without any compiler option. A declaration the project wrote under that name is kept. Fixes #63 --- CHANGELOG.md | 4 ++ guides/features/formatting-and-linting.md | 2 + lib/volt/js/check.ex | 65 +++++++++++++------ lib/volt/plugin.ex | 12 ++++ lib/volt/plugin/vue.ex | 11 ++++ lib/volt/plugin_runner.ex | 9 +++ test/mix/tasks/volt/js/check_test.exs | 77 +++++++++++++++++++++++ 7 files changed, 160 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6331557..21f4661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- `mix volt.js.check --type-aware` types imports of `.vue` files by the component's own declaration. The Vue plugin generates it from each checked `.vue` file with `Vize.generate_dts/2` and Volt sends it to tsgolint as `.vue.d.ts`, which TypeScript resolves an import of the `.vue` file to. Props, emits and slots of imported components are then checked in `h(Button, {...})` and `InstanceType`, and no `declare module "*.vue"` shim is needed for checked files. Plugins declare their own files through the new `c:Volt.Plugin.declaration/3` callback ([#63](https://github.com/elixir-volt/volt/issues/63)). + ### Changed - Browser tests share one browser per run, in `Volt.Test.Browser`, and each test gets a browser context of its own. Every test started a Playwright driver and launched a browser for itself, because the driver was linked to the test process and went away with it, which cost more than running the test. A test file is now bundled once for all of its tests and bundled again only when one of its source files changes, and the ExUnit modules generated for browser tests are `async: true` ([#65](https://github.com/elixir-volt/volt/issues/65)). diff --git a/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index 3935bed..4bbd8c8 100644 --- a/guides/features/formatting-and-linting.md +++ b/guides/features/formatting-and-linting.md @@ -141,6 +141,8 @@ mix volt.js.check --type-aware --type-check `--type-aware` also checks JavaScript-like scripts embedded in framework component files when the enabled plugin exposes them. Volt's built-in Vue and Svelte plugins expose ` + + """) + + File.write!(Path.join(@tmp_dir, "Legacy.vue"), "\n") + File.write!(Path.join(@tmp_dir, "Legacy.vue.d.ts"), "export default 1\n") + + tsgolint = fake_tsgolint_capture!(@tmp_dir) + Application.put_env(:volt, :sources, ["**/*.{ts,vue}"]) + Application.put_env(:volt, :lint, tsgolint: tsgolint, rules: %{}) + + capture_io(fn -> Mix.Tasks.Volt.Js.Check.run(["--type-aware", "--type-check"]) end) + + payload = @tmp_dir |> Path.join("payload.json") |> File.read!() |> Jason.decode!() + [%{"file_paths" => file_paths}] = payload["configs"] + overrides = payload["source_overrides"] + + # The declaration is resolved from the import, not checked as a file. + declaration = overrides[Path.expand(Path.join(@tmp_dir, "Button.vue.d.ts"))] + assert declaration =~ "export type Props = { variant?: 'primary' | 'ghost' }" + assert declaration =~ "export default" + refute "Button.vue.d.ts" in Enum.map(file_paths, &Path.basename/1) + + # A declaration the project wrote itself is left to it. + refute Map.has_key?(overrides, Path.expand(Path.join(@tmp_dir, "Legacy.vue.d.ts"))) + end + @tsgolint Path.expand("node_modules/.bin/tsgolint") + # Runs the real tsgolint, which CI and `npm ci` install. + @tag skip: not File.exists?(@tsgolint) + test "imports of single-file components are typed by their props without a shim" do + write = fn path, content -> + path = Path.join(@tmp_dir, path) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, content) + end + + write.("tsconfig.json", """ + { + "compilerOptions": { "strict": true, "noEmit": true, "moduleResolution": "Bundler" }, + "include": ["assets/js/**/*.ts"] + } + """) + + write.("assets/js/ui/Button.vue", """ + + + """) + + write.("assets/js/app.ts", """ + import Button from "./ui/Button.vue" + type Variant = InstanceType["$props"]["variant"] + export const ghost: Variant = "ghost" + export const huge: Variant = "huge" + """) + + Application.put_env(:volt, :sources, ["assets/js/**/*.{ts,vue}"]) + Application.put_env(:volt, :lint, tsgolint: @tsgolint, rules: %{}) + + output = + capture_io(:stderr, fn -> + catch_exit(Mix.Tasks.Volt.Js.Check.run(["--type-aware", "--type-check"])) + end) + + assert output =~ "app.ts:4" + assert output =~ "TS2322" + refute output =~ "app.ts:3" + refute output =~ "Cannot find module" + end + # Runs the real tsgolint, which CI and `npm ci` install. @tag skip: not File.exists?(@tsgolint) test "component scripts are type-checked with the project's tsconfig" do From b87a9eaf68152c9517d2f4fe9da2d5b86047f467 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 7 Oct 2026 16:19:37 +0400 Subject: [PATCH 4/5] Name the browser tree after the Tailwind one and trim the guides Volt.Test.Browser, Volt.Test.Browser.Run and Volt.Test.BrowserRunner were three names for two things. The process that launches browsers and keeps the bundled modules is Volt.Test.Browser, and the tree around it and the Playwright driver is Volt.Test.Browser.Supervisor, as with Volt.Tailwind. The guides describe what users see; how it is done stays in the changelog and module docs. --- guides/features/formatting-and-linting.md | 2 +- guides/features/testing.md | 2 +- lib/volt/test/browser.ex | 226 ++++++++++++++-------- lib/volt/test/browser/run.ex | 161 --------------- lib/volt/test/browser/supervisor.ex | 74 +++++++ lib/volt/test/browser_runner.ex | 7 +- test/volt/test/browser_runner_test.exs | 8 +- 7 files changed, 230 insertions(+), 250 deletions(-) delete mode 100644 lib/volt/test/browser/run.ex create mode 100644 lib/volt/test/browser/supervisor.ex diff --git a/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index 4bbd8c8..fce33a6 100644 --- a/guides/features/formatting-and-linting.md +++ b/guides/features/formatting-and-linting.md @@ -141,7 +141,7 @@ mix volt.js.check --type-aware --type-check `--type-aware` also checks JavaScript-like scripts embedded in framework component files when the enabled plugin exposes them. Volt's built-in Vue and Svelte plugins expose `