diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a8e86a..048d85e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 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)). + +### Fixed + +- Give vendor modules a new URL when a package changes. The dev server serves `/@vendor/.js?v=` as immutable, and the hash covered the lockfiles and plugins but not the packages, so a package that changed in place, such as a linked one, kept its URL and browsers kept their copy of it. The hash now also covers the generation of the pre-bundle, which changes whenever a package is bundled again, and a compiled application module is compiled again when the hash it was compiled with is no longer current: after a lockfile change, unchanged modules kept importing the old URL, which answered 504. Packages linked from outside `node_modules` are watched, and a change bundles them again and reloads the page. `npm.lock`, the lockfile of `mix npm.install`, now counts as a lockfile; it was not read, so an install did not change the URL either ([#67](https://github.com/elixir-volt/volt/issues/67)). +- 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/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index 3935bed..fce33a6 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 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' diff --git a/test/volt/dev_server/basic_test.exs b/test/volt/dev_server/basic_test.exs index b86c214..019b8d8 100644 --- a/test/volt/dev_server/basic_test.exs +++ b/test/volt/dev_server/basic_test.exs @@ -237,6 +237,69 @@ defmodule Volt.DevServer.BasicTest do assert stale_conn.status == 504 assert stale_conn.resp_body =~ "outdated optimized dependency" end + + test "gives a package that changed in place a new URL once it is bundled again" do + package = Path.join(@fixture_dir, "node_modules/linked-lib") + File.mkdir_p!(package) + + File.write!( + Path.join(package, "package.json"), + Jason.encode!(%{"name" => "linked-lib", "main" => "index.js"}) + ) + + File.write!(Path.join(package, "index.js"), "export const version = 1") + + File.write!( + Path.join(@fixture_dir, "src/app.ts"), + "import { version } from 'linked-lib'\nconsole.log(version)" + ) + + Volt.Dev.Prebundled.forget() + first = call_dev_server("/assets/app.ts") + assert [first_url] = Regex.run(~r(/@vendor/linked-lib\.js\?v=[a-f0-9]+), first.resp_body) + assert call_dev_server(first_url).resp_body =~ "version = 1" + + File.write!(Path.join(package, "index.js"), "export const version = 2") + # What the watcher does when a linked package changes. + Volt.Dev.Prebundled.forget() + + # The application module is unchanged, yet imports the new URL. + second = call_dev_server("/assets/app.ts") + assert [second_url] = Regex.run(~r(/@vendor/linked-lib\.js\?v=[a-f0-9]+), second.resp_body) + refute second_url == first_url + assert call_dev_server(second_url).resp_body =~ "version = 2" + assert call_dev_server(first_url).status == 504 + end + + test "serves unchanged application modules with new vendor URLs after the lockfile changes" do + package = Path.join(@fixture_dir, "node_modules/locked-lib") + File.mkdir_p!(package) + + File.write!( + Path.join(package, "package.json"), + Jason.encode!(%{"name" => "locked-lib", "main" => "index.js"}) + ) + + File.write!(Path.join(package, "index.js"), "export const value = 1") + + File.write!( + Path.join(@fixture_dir, "src/app.ts"), + "import { value } from 'locked-lib'\nconsole.log(value)" + ) + + lockfile = Path.join(@fixture_dir, "npm.lock") + File.write!(lockfile, ~s({"lockfileVersion":1,"packages":{}})) + first = call_dev_server("/assets/app.ts") + assert [first_url] = Regex.run(~r(/@vendor/locked-lib\.js\?v=[a-f0-9]+), first.resp_body) + + File.write!(lockfile, ~s({"lockfileVersion":1,"packages":{"locked-lib":{}}})) + File.touch!(lockfile, System.os_time(:second) + 2) + + second = call_dev_server("/assets/app.ts") + assert [second_url] = Regex.run(~r(/@vendor/locked-lib\.js\?v=[a-f0-9]+), second.resp_body) + refute second_url == first_url + assert call_dev_server(second_url).status == 200 + end end describe "TypeScript files" do diff --git a/test/volt/js/package_test.exs b/test/volt/js/package_test.exs new file mode 100644 index 0000000..841f43e --- /dev/null +++ b/test/volt/js/package_test.exs @@ -0,0 +1,31 @@ +defmodule Volt.JS.PackageTest do + use ExUnit.Case, async: true + + describe "linked_dirs/1" do + @tag :tmp_dir + test "lists the real directories of packages linked from outside node_modules", %{ + tmp_dir: project + } do + node_modules = Path.join(project, "node_modules") + linked = Path.join(project, "packages/client") + scoped = Path.join(project, "packages/scoped") + File.mkdir_p!(Path.join(node_modules, "installed")) + File.mkdir_p!(Path.join(node_modules, "@scope")) + File.mkdir_p!(linked) + File.mkdir_p!(scoped) + File.ln_s!("../packages/client", Path.join(node_modules, "client")) + File.ln_s!("../../packages/scoped", Path.join(node_modules, "@scope/scoped")) + # A link inside node_modules, as package managers make for workspaces' + # hoisted copies, is not a linked package. + File.ln_s!("./installed", Path.join(node_modules, "alias")) + + assert Enum.sort(Volt.JS.Package.linked_dirs(node_modules)) == + Enum.sort([Path.expand(linked), Path.expand(scoped)]) + end + + test "has nothing to list without node_modules" do + assert Volt.JS.Package.linked_dirs(nil) == [] + assert Volt.JS.Package.linked_dirs("/nonexistent/node_modules") == [] + end + end +end diff --git a/test/volt/js/vendor_test.exs b/test/volt/js/vendor_test.exs index 1d14363..7f2b26a 100644 --- a/test/volt/js/vendor_test.exs +++ b/test/volt/js/vendor_test.exs @@ -783,12 +783,15 @@ defmodule Volt.JS.VendorTest do ) == url end - test "browser hash changes when lockfile changes" do - lockfile = Path.join(@fixture_dir, "package-lock.json") - File.write!(lockfile, ~s({"lockfileVersion":1})) + # A lockfile is known by its size and modification time, as a package + # manager's write changes both. + test "browser hash changes when a lockfile changes" do + lockfile = Path.join(@fixture_dir, "npm.lock") + File.write!(lockfile, ~s({"lockfileVersion":1,"packages":{}})) first = Volt.JS.Vendor.browser_hash(node_modules: @node_modules, plugins: []) - File.write!(lockfile, ~s({"lockfileVersion":2})) + File.write!(lockfile, ~s({"lockfileVersion":1,"packages":{"vue":{}}})) + File.touch!(lockfile, System.os_time(:second) + 2) second = Volt.JS.Vendor.browser_hash(node_modules: @node_modules, plugins: []) refute first == second diff --git a/test/volt/test/browser_runner_test.exs b/test/volt/test/browser_runner_test.exs index bd7eb7f..8d2a6be 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.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.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.Supervisor.start(config) + {:ok, _browser} = Volt.Test.Browser.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""" diff --git a/test/volt/watcher_test.exs b/test/volt/watcher_test.exs index 274178e..01c05ef 100644 --- a/test/volt/watcher_test.exs +++ b/test/volt/watcher_test.exs @@ -211,6 +211,34 @@ defmodule Volt.WatcherTest do assert assets in pruned.tailwind_dirs end + @tag :tmp_dir + test "a change in a linked package forgets the pre-bundle and reloads the page", %{ + tmp_dir: root + } do + assets = Path.join(root, "assets") + linked = Path.join(root, "packages/client") + File.mkdir_p!(assets) + File.mkdir_p!(linked) + File.mkdir_p!(Path.join(root, "node_modules")) + File.ln_s!("../packages/client", Path.join(root, "node_modules/client")) + File.write!(Path.join(linked, "index.js"), "export const version = 1") + + watcher = start_supervised!({Volt.Watcher, root: assets, name: nil}) + Registry.register(Volt.HMR.Registry, :clients, nil) + state = :sys.get_state(watcher) + assert state.linked_dirs == [Path.expand(linked)] + + prebundle_opts = [root: assets, plugins: []] + Volt.Dev.Prebundled.ensure(prebundle_opts, fn -> :ok end) + changed = Path.join(linked, "index.js") + + assert {:noreply, _state} = + Volt.Watcher.handle_info({:file_event, self(), {changed, [:modified]}}, state) + + assert_receive {:volt_hmr, :update, %{changes: [:vendor]}} + assert :ets.lookup(:volt_vendor_prebundled, prebundle_opts) == [] + end + @tag :tmp_dir test "document reload waits for successful CSS and remains pending on failure", %{tmp_dir: root} do File.mkdir_p!(Path.join(root, "pages"))