diff --git a/src/Fable.Build/Fable.Build.fsproj b/src/Fable.Build/Fable.Build.fsproj
index 95f0ac478..43aaad065 100644
--- a/src/Fable.Build/Fable.Build.fsproj
+++ b/src/Fable.Build/Fable.Build.fsproj
@@ -29,6 +29,7 @@
+
diff --git a/src/Fable.Build/Main.fs b/src/Fable.Build/Main.fs
index 178bf3496..d2df0287d 100644
--- a/src/Fable.Build/Main.fs
+++ b/src/Fable.Build/Main.fs
@@ -46,6 +46,7 @@ Available commands:
integration Run the integration test suite
standalone Tests the standalone version of Fable
(Fable running on top of Node.js)
+ plugins Run the plugin test suite (tests MemberDeclarationPluginAttribute)
Options for all except integration and standalone:
--watch Watch for changes and re-run the tests
@@ -153,6 +154,7 @@ let main argv =
// This test is using quicktest project for now,
// because it can't compile (yet?) the Main JavaScript tests
| "compiler-js" :: _ -> Test.CompilerJs.handle args
+ | "plugins" :: args -> Test.Plugins.handle args
| _ -> printHelp ()
| "quicktest" :: args ->
match args with
diff --git a/src/Fable.Build/Test/Plugins.fs b/src/Fable.Build/Test/Plugins.fs
new file mode 100644
index 000000000..44ad0df6f
--- /dev/null
+++ b/src/Fable.Build/Test/Plugins.fs
@@ -0,0 +1,32 @@
+module Build.Test.Plugins
+
+open Build.FableLibrary
+open System.IO
+open BlackFox.CommandLine
+open Build.Utils
+open SimpleExec
+open Fake.IO
+
+let private pluginsTestsDir =
+ Path.Resolve("tests", "Integration", "Plugins", "Tests")
+
+let handle (args: string list) =
+ BuildFableLibraryJavaScript().Run()
+
+ let destinationDir = Path.Resolve("temp", "tests", "Plugins")
+
+ Directory.clean destinationDir
+
+ CmdLine.empty
+ |> CmdLine.appendRaw pluginsTestsDir
+ |> CmdLine.appendPrefix "--outDir" destinationDir
+ |> CmdLine.appendPrefix "--lang" "javascript"
+ |> CmdLine.appendPrefix "--exclude" "Fable.Core"
+ // The plugin assembly must be loaded via reflection, not compiled from source.
+ |> CmdLine.appendPrefix "--exclude" "Fable.Tests.Plugin"
+ // Fable.AST must stay the same assembly the plugin was built against, not be re-cracked from source.
+ |> CmdLine.appendPrefix "--exclude" "Fable.AST"
+ |> CmdLine.appendRaw "--noCache"
+ |> Command.Fable
+
+ Command.Run("node", "--test-reporter spec --test-timeout 20000 --test Main.js", workingDirectory = destinationDir)
diff --git a/tests/Integration/Plugins/Plugin/Fable.Tests.Plugin.fsproj b/tests/Integration/Plugins/Plugin/Fable.Tests.Plugin.fsproj
new file mode 100644
index 000000000..7dec09735
--- /dev/null
+++ b/tests/Integration/Plugins/Plugin/Fable.Tests.Plugin.fsproj
@@ -0,0 +1,12 @@
+
+
+ Library
+ netstandard2.0
+
+
+
+
+
+
+
+
diff --git a/tests/Integration/Plugins/Plugin/Plugin.fs b/tests/Integration/Plugins/Plugin/Plugin.fs
new file mode 100644
index 000000000..85eaaf028
--- /dev/null
+++ b/tests/Integration/Plugins/Plugin/Plugin.fs
@@ -0,0 +1,27 @@
+namespace Fable.Tests
+
+open Fable.AST
+open Fable.AST.Fable
+
+[]
+do ()
+
+/// Replaces the whole member body with a constant, proving `Transform` fires.
+type ReturnConstPlugin() =
+ inherit Fable.MemberDeclarationPluginAttribute()
+ override _.FableMinimumVersion = "5.0"
+
+ override _.Transform(_, _, decl) =
+ { decl with Body = Value(NumberConstant(NumberValue.Int32 42, NumberInfo.Empty), None) }
+
+ override _.TransformCall(_, _, expr) = expr
+
+/// Replaces the call site with the called member's name, proving `TransformCall` fires.
+type ReturnMemberNamePlugin() =
+ inherit Fable.MemberDeclarationPluginAttribute()
+ override _.FableMinimumVersion = "5.0"
+
+ override _.Transform(_, _, decl) = decl
+
+ override _.TransformCall(_, member_, expr) =
+ Value(StringConstant member_.DisplayName, expr.Range)
diff --git a/tests/Integration/Plugins/Tests/Fable.Tests.Plugins.fsproj b/tests/Integration/Plugins/Tests/Fable.Tests.Plugins.fsproj
new file mode 100644
index 000000000..7f62eb2b7
--- /dev/null
+++ b/tests/Integration/Plugins/Tests/Fable.Tests.Plugins.fsproj
@@ -0,0 +1,17 @@
+
+
+ Exe
+ net10.0
+ Major
+ Preview
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Integration/Plugins/Tests/Main.fs b/tests/Integration/Plugins/Tests/Main.fs
new file mode 100644
index 000000000..822b37270
--- /dev/null
+++ b/tests/Integration/Plugins/Tests/Main.fs
@@ -0,0 +1,27 @@
+module Fable.Tests.Main
+
+open Fable.Core
+open Fable.Core.JsInterop
+
+let allTests = [| PluginsTests.tests |]
+
+let inline describe (name: string) (f: unit -> unit) : unit = import "describe" "node:test"
+let inline it (msg: string) (f: unit -> unit) : unit = import "it" "node:test"
+
+let rec flattenTest (test: Util.Testing.TestKind) : unit =
+ match test with
+ | Util.Testing.TestKind.TestList(name, tests) ->
+ describe name (fun () ->
+ for t in tests do
+ flattenTest t)
+ | Util.Testing.TestKind.TestCase(name, test) -> it name (unbox test)
+
+let run () =
+ for t in allTests do
+ flattenTest t
+
+// This project only ever runs through Fable; this branch just keeps it a valid .NET project.
+[]
+let main _ =
+ run ()
+ 0
diff --git a/tests/Integration/Plugins/Tests/PluginsTests.fs b/tests/Integration/Plugins/Tests/PluginsTests.fs
new file mode 100644
index 000000000..29667a9c4
--- /dev/null
+++ b/tests/Integration/Plugins/Tests/PluginsTests.fs
@@ -0,0 +1,21 @@
+module Fable.Tests.PluginsTests
+
+open Fable.Tests
+open Util.Testing
+
+[]
+let constFn () = 0
+
+[]
+let memberNameFn () = ""
+
+let tests =
+ testList
+ "Member Declaration Plugins"
+ [
+ testCase "Transform replaces the compiled member body"
+ <| fun () -> equal 42 (constFn ())
+
+ testCase "TransformCall rewrites the call site"
+ <| fun () -> equal "memberNameFn" (memberNameFn ())
+ ]