diff --git a/.reach.exs b/.reach.exs index 95d1719..f4e26ce 100644 --- a/.reach.exs +++ b/.reach.exs @@ -102,6 +102,7 @@ logic = [ "Volt.JS.Check", "Volt.JS.CommonJS", "Volt.JS.Lint.Config", + "Volt.JS.Lint.TSConfigOverlay", "Volt.JS.Extensions", "Volt.JS.Format", "Volt.JS.Discovery", @@ -141,6 +142,7 @@ infrastructure = [ "Volt.Cache", "Volt.Dev", "Volt.Dev.Cleanup", + "Volt.Dev.Prebundled", "Volt.Dev.Error", "Volt.Priv.Vendor", "Volt.DevServer.ClientTag", diff --git a/CHANGELOG.md b/CHANGELOG.md index c849de3..7969a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Fixed + +- Leave Vue ` + + """) + + Application.put_env(:volt, :sources, ["lib/**/*.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) + + # The script is type-checked, and with the project's options: the path + # alias and the `*.vue` declaration resolve. + assert output =~ "TS2322" + refute output =~ "TS2307" + refute output =~ "Cannot find module" + end + test "type-aware overrides batch by effective rules and use original SFC paths" do files = Enum.map(["app.ts", "Component.vue", "Widget.svelte"], &Path.join(@tmp_dir, &1)) [app, vue, svelte] = files diff --git a/test/volt/dev_server/import_rewriting_test.exs b/test/volt/dev_server/import_rewriting_test.exs index 7f9452e..42df997 100644 --- a/test/volt/dev_server/import_rewriting_test.exs +++ b/test/volt/dev_server/import_rewriting_test.exs @@ -81,6 +81,36 @@ defmodule Volt.DevServer.ImportRewritingTest do end end + describe "syntax lowered for a target" do + @tag :tmp_dir + test "serves the runtime helper a lowered class field imports", %{tmp_dir: project} do + # A project of its own, so the helper is not already in the vendor cache. + root = Path.join(project, "src") + File.mkdir_p!(root) + File.mkdir_p!(Path.join(project, "node_modules")) + + File.write!(Path.join(root, "search.ts"), """ + export class Search { + private readonly style = 'field' + } + """) + + # Class fields are lowered below ES2022, into an import of a helper from + # `@oxc-project/runtime`, which is not installed in `node_modules`. + opts = [root: root, target: :es2020] + conn = call_dev_server("/assets/search.ts", opts) + assert conn.status == 200 + + assert [helper_url] = + Regex.run(~r{/@vendor/[^"]*defineProperty\.js\?v=[a-f0-9]+}, conn.resp_body) + + helper = call_dev_server(helper_url, opts) + assert helper.status == 200 + assert helper.resp_body =~ "function _defineProperty" + assert helper.resp_body =~ ~r/export \{[^}]*default/ + end + end + describe "import rewriting" do test "rewrites relative imports to absolute paths" do File.write!(Path.join(@fixture_dir, "src/utils.ts"), "export const y = 1") diff --git a/test/volt/js/lint/tsconfig_overlay_test.exs b/test/volt/js/lint/tsconfig_overlay_test.exs new file mode 100644 index 0000000..bfeb21e --- /dev/null +++ b/test/volt/js/lint/tsconfig_overlay_test.exs @@ -0,0 +1,72 @@ +defmodule Volt.JS.Lint.TSConfigOverlayTest do + use ExUnit.Case, async: true + + alias Volt.JS.Lint.TSConfigOverlay + + @moduletag :tmp_dir + + defp write!(root, path, content) do + path = Path.join(root, path) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, content) + path + end + + test "adds virtual modules to the project of the nearest tsconfig", %{tmp_dir: root} do + original = """ + { + // comments and trailing commas are allowed in a tsconfig + "compilerOptions": { "paths": { "@/*": ["./assets/js/*"] }, }, + "include": ["assets/js/**/*.ts"], + } + """ + + tsconfig = write!(root, "tsconfig.json", original) + first = Path.join(root, "lib/app_web/Tally.vue.script0.ts") + second = Path.join(root, "assets/js/Card.vue.script0.ts") + + overrides = TSConfigOverlay.overrides([first, second]) + + # The original is carried unchanged beside the tsconfig that replaces it. + assert overrides[Path.join(root, "tsconfig.volt-base.json")] == original + + assert Jason.decode!(overrides[tsconfig]) == %{ + "extends" => "./tsconfig.volt-base.json", + "files" => [second, first] + } + end + + test "keeps the files the original names, which `files` would replace", %{tmp_dir: root} do + tsconfig = write!(root, "tsconfig.json", ~s({ files: ["src/main.ts", "./env.d.ts"] })) + virtual = Path.join(root, "src/App.vue.script0.ts") + + assert %{"files" => ["src/main.ts", "./env.d.ts", ^virtual]} = + TSConfigOverlay.overrides([virtual])[tsconfig] |> Jason.decode!() + end + + test "spells out the default include a tsconfig without one relies on", %{tmp_dir: root} do + tsconfig = write!(root, "tsconfig.json", ~s({ "compilerOptions": { "strict": true } })) + virtual = Path.join(root, "App.vue.script0.ts") + + assert %{"include" => ["**/*"], "files" => [^virtual]} = + TSConfigOverlay.overrides([virtual])[tsconfig] |> Jason.decode!() + end + + test "uses the nearest tsconfig for each virtual module", %{tmp_dir: root} do + outer = write!(root, "tsconfig.json", ~s({ "include": ["**/*"] })) + inner = write!(root, "assets/tsconfig.json", ~s({ "include": ["**/*"] })) + in_lib = Path.join(root, "lib/Page.vue.script0.ts") + in_assets = Path.join(root, "assets/js/App.vue.script0.ts") + + overrides = TSConfigOverlay.overrides([in_lib, in_assets]) + + assert %{"files" => [^in_lib]} = Jason.decode!(overrides[outer]) + assert %{"files" => [^in_assets]} = Jason.decode!(overrides[inner]) + end + + test "leaves virtual modules alone without a readable tsconfig above them", %{tmp_dir: root} do + write!(root, "broken/tsconfig.json", "not a config") + + assert TSConfigOverlay.overrides([Path.join(root, "broken/App.vue.script0.ts")]) == %{} + end +end diff --git a/test/volt/js/vendor_test.exs b/test/volt/js/vendor_test.exs index 45f8ff3..1d14363 100644 --- a/test/volt/js/vendor_test.exs +++ b/test/volt/js/vendor_test.exs @@ -73,6 +73,73 @@ defmodule Volt.JS.VendorTest do "Pre-bundled" end + @tag :tmp_dir + test "writes the cache under the project root while the working directory is elsewhere", + %{tmp_dir: elsewhere} do + # Mix changes the VM's working directory while it compiles a dependency, + # as Phoenix's code reloader does during a request. + {:ok, vendor_map} = + File.cd!(elsewhere, fn -> + Volt.JS.Vendor.prebundle( + root: Path.join(@fixture_dir, "src"), + node_modules: @node_modules, + force: true + ) + end) + + assert File.ls!(elsewhere) == [] + + for {_specifier, path} <- vendor_map do + assert String.starts_with?(path, Volt.Paths.expand("_build")) + assert File.regular?(path) + end + + assert {:ok, _code} = + File.cd!(elsewhere, fn -> + Volt.JS.Vendor.read("fake-lib", node_modules: @node_modules) + end) + end + + test "an import that resolves to no package does not bundle the others again" do + File.write!( + Path.join(@fixture_dir, "src/provided.ts"), + "import { value } from 'fake-lib'\nimport host from 'provided-by-the-page'\nconsole.log(value, host)" + ) + + on_exit(fn -> File.rm(Path.join(@fixture_dir, "src/provided.ts")) end) + opts = [root: Path.join(@fixture_dir, "src"), node_modules: @node_modules] + + {:ok, vendor_map} = Volt.JS.Vendor.prebundle(Keyword.put(opts, :force, true)) + assert Map.has_key?(vendor_map, "fake-lib") + refute Map.has_key?(vendor_map, "provided-by-the-page") + + refute ExUnit.CaptureLog.capture_log(fn -> Volt.JS.Vendor.prebundle(opts) end) =~ + "Pre-bundled" + end + + test "is not repeated on every request until the sources are said to have changed" do + opts = [root: Path.join(@fixture_dir, "src"), node_modules: @node_modules] + + prebundle = fn -> + Volt.Dev.Prebundled.ensure(opts, fn -> Volt.JS.Vendor.prebundle(opts) end) + end + + Volt.Dev.Prebundled.forget() + on_exit(&Volt.Dev.Prebundled.forget/0) + + :ok = prebundle.() + [cached] = Path.wildcard("_build/volt/vendor/*/fake-lib.js") + File.rm!(cached) + + # Asked again, as on every request: nothing is scanned or bundled. + :ok = prebundle.() + refute File.exists?(cached) + + Volt.Dev.Prebundled.forget() + :ok = prebundle.() + assert File.exists?(cached) + end + test "caches bundled files on disk" do Volt.JS.Vendor.prebundle( root: Path.join(@fixture_dir, "src"), diff --git a/test/volt/plugin/vue_test.exs b/test/volt/plugin/vue_test.exs index 1577d9c..a1d5328 100644 --- a/test/volt/plugin/vue_test.exs +++ b/test/volt/plugin/vue_test.exs @@ -12,6 +12,46 @@ defmodule Volt.Plugin.VueTest do assert result.code =~ "export default { render }" end + describe "embedded_modules/3" do + defp script_modules(source) do + "Example.vue" + |> Volt.Plugin.Vue.embedded_modules(source, []) + |> Volt.Plugin.EmbeddedModule.normalize_all() + |> Enum.filter(&(&1.type == :script)) + |> Enum.map(&{&1.extension, String.trim(&1.source)}) + end + + test "leaves out script blocks that are not JavaScript" do + source = """ + + + + + + """ + + assert script_modules(source) == [{".ts", "const n: number = 1"}] + end + + test "names script blocks by their JavaScript dialect" do + for {lang, extension} <- [ + {"", ".js"}, + {~s( lang="js"), ".js"}, + {~s( lang="jsx"), ".jsx"}, + {~s( lang="ts"), ".ts"}, + {~s( lang="tsx"), ".tsx"} + ] do + source = "export default {}" + + assert script_modules(source) == [{extension, "export default {}"}] + end + end + end + test "script-bearing scoped components receive their CSS scope on the default export" do for script <- [ "", diff --git a/test/volt/watcher_test.exs b/test/volt/watcher_test.exs index c8b5d91..274178e 100644 --- a/test/volt/watcher_test.exs +++ b/test/volt/watcher_test.exs @@ -357,6 +357,24 @@ defmodule Volt.WatcherTest do GenServer.stop(pid) end + test "a changed source makes the next request scan for new package imports", %{ + watch_dir: watch_dir + } do + source = Path.join(watch_dir, "app.ts") + File.write!(source, "export const x = 1") + opts = [root: watch_dir, node_modules: Path.join(watch_dir, "node_modules")] + + :ok = Volt.Dev.Prebundled.ensure(opts, fn -> :ok end) + assert :ets.lookup(:volt_vendor_prebundled, opts) != [] + + {:ok, pid} = Volt.Watcher.start_link(root: watch_dir, name: :test_watcher_forget_prebundled) + state = send_file_event(pid, source) + Process.cancel_timer(state.pending[source]) + + assert :ets.lookup(:volt_vendor_prebundled, opts) == [] + GenServer.stop(pid) + end + test "ignores configured watcher paths", %{watch_dir: watch_dir} do generated_dir = Path.join(watch_dir, ".generated") entry = Path.join(generated_dir, "entry.ts")