0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 07:23:30 +08:00
discourse/spec/lib/asset_processor_spec.rb
David Taylor e3054ef590
DEV: Improve cross-plugin/theme import handling (#40939)
1. Remove 'federated exports' system, which was named entrypoint exports
for every module inside the plugin. Replace it with a single import of
the target plugin's 'compatModules', and then update call sites to do a
'just in time' lookup of the module and export. This is implemented in a
new `babel-resolve-plugin-imports` plugin

2. Update theme & plugin build systems to produce a list of external
plugins which are imported. For plugins, it's stored in the manifest.
For themes, it's stored in a new column of the javascript_caches table.

3. Refactor theme extra_js loading to use a more structured data model,
and move the HTML generation to the erb template

4. Update core importmap to identify any missing plugin dependencies and
add a fake placeholder module for them. This allows optional imports to
exist without causing a boot error. The imported values will resolve to
'null'.

5. Update core plugins to remove use of `optionalRequire`, and replace
it with regular imports, and a `with { discourseImport: "optional" }`
suffix. This is functionally equivalent to optionalRequire, but without
leaning on the legacy `loader.js` system of core

6. Add support for `with { discourseImport: "optional" }` for
plugins/themes importing core modules. This is useful when a
theme/plugin needs to target multiple versions of Discourse core.

Co-authored-by: Jarek Radosz <jarek@cvx.dev>
2026-06-30 16:11:38 +01:00

554 lines
15 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe AssetProcessor do
def entrypoint(result, name)
result.values.find { |chunk| chunk["name"] == name }
end
describe "skip_module?" do
it "returns false for empty strings" do
expect(AssetProcessor.skip_module?(nil)).to eq(false)
expect(AssetProcessor.skip_module?("")).to eq(false)
end
it "returns true if the header is present" do
expect(AssetProcessor.skip_module?("// cool comment\n// discourse-skip-module")).to eq(true)
end
it "returns false if the header is not present" do
expect(AssetProcessor.skip_module?("// just some JS\nconsole.log()")).to eq(false)
end
it "works end-to-end" do
source = <<~JS.chomp
// discourse-skip-module
console.log("hello world");
JS
expect(AssetProcessor.transpile(source, "test", "test")).to eq(source)
end
end
it "passes through modern JS syntaxes which are supported in our target browsers" do
script = <<~JS.chomp
optional?.chaining;
const template = func`test`;
let numericSeparator = 100_000_000;
logicalAssignment ||= 2;
nullishCoalescing ?? 'works';
try {
"optional catch binding";
} catch {
"works";
}
async function* asyncGeneratorFunction() {
yield await Promise.resolve('a');
}
let a = {
x,
y,
...spreadRest
};
JS
result = AssetProcessor.transpile(script, "blah", "blah/mymodule")
expect(result).to eq <<~JS.strip
define("blah/mymodule", [], function () {
"use strict";
#{script.indent(2)}
});
JS
end
it "supports decorators and class properties without error" do
script = <<~JS.chomp
class MyClass {
classProperty = 1;
#privateProperty = 1;
#privateMethod() {
console.log("hello world");
}
@decorated
myMethod(){
}
}
JS
result = AssetProcessor.transpile(script, "blah", "blah/mymodule")
expect(result).to include("dt7948.n(")
end
describe "Transpiler#terser" do
it "can minify code and provide sourcemaps" do
sources = {
"multiply.js" => "let multiply = (firstValue, secondValue) => firstValue * secondValue;",
"add.js" => "let add = (firstValue, secondValue) => firstValue + secondValue;",
}
result = AssetProcessor.new.terser(sources, { sourceMap: { includeSources: true } })
expect(result.keys).to contain_exactly("code", "decoded_map", "map")
begin
# Check the code still works
ctx = MiniRacer::Context.new
ctx.eval(result["code"])
expect(ctx.eval("multiply(2, 3)")).to eq(6)
expect(ctx.eval("add(2, 3)")).to eq(5)
ensure
ctx.dispose
end
map = JSON.parse(result["map"])
expect(map["sources"]).to contain_exactly(*sources.keys)
expect(map["sourcesContent"]).to contain_exactly(*sources.values)
end
end
describe "Transpiler#rollup" do
it "can rollup code" do
sources = { "discourse/initializers/hello.gjs" => <<~JS }
someDecorator = () => {}
export default class MyClass {
@someDecorator
myMethod() {
console.log("hello world");
}
<template>
<div>template content</div>
</template>
}
JS
result =
AssetProcessor.new.rollup(
sources,
{ entrypoints: { main: { modules: ["discourse/initializers/hello.gjs"] } } },
)
code = entrypoint(result, "main")["code"]
expect(code).to include('"hello world"')
expect(code).to include("dt7948") # Decorator transform
expect(entrypoint(result, "main")["map"]).not_to be_nil
end
it "supports decorators and class properties without error" do
script = <<~JS.chomp
export default class MyClass {
classProperty = 1;
#privateProperty = 1;
#privateMethod() {
console.log("hello world");
}
@decorated
myMethod(){
}
}
JS
result =
AssetProcessor.new.rollup(
{ "discourse/initializers/foo.js" => script },
{ entrypoints: { main: { modules: ["discourse/initializers/foo.js"] } } },
)
expect(entrypoint(result, "main")["code"]).to include("dt7948.n")
end
it "supports object literal decorators without errors" do
script = <<~JS.chomp
export default {
@decorated foo: "bar",
@decorated
myMethod() {
console.log("hello world");
}
}
JS
result =
AssetProcessor.new.rollup(
{ "discourse/initializers/foo.js" => script },
{ entrypoints: { main: { modules: ["discourse/initializers/foo.js"] } } },
)
expect(entrypoint(result, "main")["code"]).to include("dt7948")
end
it "can use themePrefix in a template" do
script = <<~JS.chomp
themePrefix();
export default class Foo {
<template>{{themePrefix "bar"}}</template>
}
JS
result =
AssetProcessor.new.rollup(
{ "discourse/initializers/foo.gjs" => script },
{ themeId: 22, entrypoints: { main: { modules: ["discourse/initializers/foo.gjs"] } } },
)
expect(entrypoint(result, "main")["code"]).to include(
'window.moduleBroker.lookup("discourse/lib/theme-settings-store")',
)
end
it "strips data-test-* attributes in production mode" do
script = <<~JS.chomp
export default class Foo {
<template>
<div data-test-my-element class="keep">hello</div>
</template>
}
JS
modules = { "discourse/components/foo.gjs" => script }
entrypoints = { main: { modules: ["discourse/components/foo.gjs"] } }
unminified = AssetProcessor.new.rollup(modules, { themeId: 22, entrypoints: entrypoints })
expect(entrypoint(unminified, "main")["code"]).to include("data-test-my-element")
minified =
AssetProcessor.new.rollup(modules, { minify: true, themeId: 22, entrypoints: entrypoints })
code = entrypoint(minified, "main")["code"]
expect(code).not_to include("data-test-my-element")
expect(code).to include("keep")
end
it "preserves optionality of cross-plugin imports" do
script = <<~JS.chomp
import Example from "discourse/plugins/styleguide/discourse/components/example" with { discourseImport: "optional" };
console.log(Example);
JS
result =
AssetProcessor.new.rollup(
{ "discourse/components/foo.js" => script },
{
pluginName: "chat",
entrypoints: {
main: {
modules: ["discourse/components/foo.js"],
},
},
},
)
code = entrypoint(result, "main")["code"]
expect(code).to include('"discourse/plugins/styleguide?"')
expect(code).not_to include('"discourse/plugins/styleguide"')
end
it "can use themePrefix not in a template" do
script = <<~JS.chomp
export default function foo() {
return themePrefix("bar");
}
JS
result =
AssetProcessor.new.rollup(
{ "discourse/initializers/foo.js" => script },
{ themeId: 22, entrypoints: { main: { modules: ["discourse/initializers/foo.js"] } } },
)
expect(entrypoint(result, "main")["code"]).to include(
'window.moduleBroker.lookup("discourse/lib/theme-settings-store")',
)
end
end
it "can compile hbs" do
template = <<~HBS.chomp
{{log "hello world"}}
HBS
result =
AssetProcessor.new.rollup(
{ "discourse/connectors/outlet-name/foo.hbs" => template },
{
themeId: 22,
entrypoints: {
main: {
modules: ["discourse/connectors/outlet-name/foo.hbs"],
},
},
},
)
code = entrypoint(result, "main")["code"]
expect(code).to include("createTemplateFactory")
expect(code).to include("deprecated(")
expect(code).to include('id: "discourse.hbs-extension"')
end
it "handles colocation" do
js = <<~JS.chomp
import Component from "@glimmer/component";
export default class MyComponent extends Component {}
JS
template = <<~HBS.chomp
{{log "hello world"}}
HBS
onlyTemplate = <<~HBS.chomp
{{log "hello galaxy"}}
HBS
result =
AssetProcessor.new.rollup(
{
"discourse/components/foo.js" => js,
"discourse/components/foo.hbs" => template,
"discourse/components/bar.hbs" => onlyTemplate,
},
{
themeId: 22,
entrypoints: {
main: {
modules: %w[discourse/components/foo.js discourse/components/bar.hbs],
},
},
},
)
expect(entrypoint(result, "main")["code"]).to include("setComponentTemplate")
expect(entrypoint(result, "main")["code"]).to include(
"bar = setComponentTemplate(__COLOCATED_TEMPLATE__, templateOnly());",
)
end
it "handles colocation of connectors" do
js = <<~JS.chomp
export default {
setupComponent(args, component) {
console.log("hello world");
}
}
JS
template = <<~HBS.chomp
{{log "hello world"}}
HBS
result =
AssetProcessor.new.rollup(
{
"discourse/templates/connectors/foo.js" => js,
"discourse/templates/connectors/foo.hbs" => template,
},
{
themeId: 22,
entrypoints: {
main: {
modules: %w[
discourse/templates/connectors/foo.js
discourse/templates/connectors/foo.hbs
],
},
},
},
)
expect(entrypoint(result, "main")["code"]).to include(
'"discourse/templates/connectors/foo":',
).once
expect(entrypoint(result, "main")["code"]).to include('"discourse/connectors/foo":').once
end
it "handles relative imports from one module to another" do
mod_1 = <<~JS.chomp
export default "test";
JS
mod_2 = <<~JS.chomp
import MyComponent from "../components/my-component";
console.log(MyComponent);
JS
result =
AssetProcessor.new.rollup(
{
"discourse/components/my-component.js" => mod_1,
"discourse/components/other-component.js" => mod_2,
},
{
themeId: 22,
entrypoints: {
main: {
modules: ["discourse/components/other-component.js"],
},
},
},
)
expect(entrypoint(result, "main")["code"]).not_to include("../components/my-component")
end
it "handles relative import of index file" do
mod_1 = <<~JS.chomp
import MyComponent from "./other-component";
console.log(MyComponent);
JS
mod_2 = <<~JS.chomp
export default "test";
JS
result =
AssetProcessor.new.rollup(
{
"discourse/components/my-component.js" => mod_1,
"discourse/components/other-component/index.js" => mod_2,
},
{
themeId: 22,
entrypoints: {
main: {
modules: %w[
discourse/components/my-component.js
discourse/components/other-component/index.js
],
},
},
},
)
expect(entrypoint(result, "main")["code"]).not_to include("../components/my-component")
end
it "handles relative import of gjs index file" do
mod_1 = <<~JS.chomp
import MyComponent from "./other-component";
console.log(MyComponent);
JS
mod_2 = <<~JS.chomp
export default "test";
JS
result =
AssetProcessor.new.rollup(
{
"discourse/components/my-component.gjs" => mod_1,
"discourse/components/other-component/index.gjs" => mod_2,
},
{
themeId: 22,
entrypoints: {
main: {
modules: %w[
discourse/components/my-component.gjs
discourse/components/other-component/index.gjs
],
},
},
},
)
expect(entrypoint(result, "main")["code"]).not_to include("../components/my-component")
end
it "prioritizes exact match over /index match" do
mod_1 = <<~JS.chomp
export default "module 1";
JS
mod_2 = <<~JS.chomp
export default "module 2";
JS
result =
AssetProcessor.new.rollup(
{
"discourse/components/my-component.gjs" => mod_1,
"discourse/components/my-component/index.gjs" => mod_2,
},
{
themeId: 22,
entrypoints: {
main: {
modules: %w[
discourse/components/my-component/index.gjs
discourse/components/my-component.gjs
],
},
},
},
)
expect(entrypoint(result, "main")["code"]).to include("module 1")
expect(entrypoint(result, "main")["code"]).to include("module 2")
end
it "returns the ember version" do
expect(AssetProcessor.ember_version).to match(/\A\d+\.\d+\.\d+\z/)
end
it "errors on missing relative imports" do
mod_1 = <<~JS.chomp
import SomeModule from "../some-module";
console.log(SomeModule);
JS
expect do
AssetProcessor.new.rollup(
{ "discourse/components/my-component.gjs" => mod_1 },
{ pluginName: "myplugin" },
)
end.to raise_error(AssetProcessor::TranspileError)
end
it "outputs entrypoint manifest data" do
mod = <<~JS.chomp
export default "module 1";
JS
admin_mod = <<~JS.chomp
import comp from "./my-component";
console.log(comp);
export default "module 2";
JS
result =
AssetProcessor.new.rollup(
{
"discourse/components/my-component.gjs" => mod,
"discourse/components/my-admin-component.gjs" => admin_mod,
},
{
themeId: 22,
entrypoints: {
main: {
modules: %w[discourse/components/my-component.gjs],
},
admin: {
modules: %w[discourse/components/my-admin-component.gjs],
},
},
},
)
expect(entrypoint(result, "main")["imports"].length).to eq(1)
expect(entrypoint(result, "main")["imports"].first).to include("chunk")
expect(entrypoint(result, "main")["name"]).to eq("main")
expect(entrypoint(result, "main")["isEntry"]).to eq(true)
expect(entrypoint(result, "admin")["imports"].length).to eq(1)
expect(entrypoint(result, "admin")["imports"].first).to include("chunk")
expect(entrypoint(result, "admin")["name"]).to eq("admin")
expect(entrypoint(result, "admin")["isEntry"]).to eq(true)
end
it "errors on missing relative imports" do
mod_1 = <<~JS.chomp
import SomeModule from "../some-module";
console.log(SomeModule);
JS
expect do
AssetProcessor.new.rollup(
{ "discourse/components/my-component.gjs" => mod_1 },
{ pluginName: "my-plugin" },
)
end.to raise_error(AssetProcessor::TranspileError)
end
end