From 581f43dc3df62733106a64fca10c24805a26e101 Mon Sep 17 00:00:00 2001 From: aooohan Date: Sat, 12 Sep 2026 16:29:05 +0800 Subject: [PATCH] feat(plugin): add local Lua test and hook debugging commands --- cmd/cmd.go | 3 +- cmd/commands/plugin.go | 108 +++++ cmd/plugin_test.go | 254 +++++++++++ docs/.vitepress/en.ts | 1 + docs/.vitepress/zh.ts | 1 + docs/plugins/create/howto.md | 19 +- docs/plugins/create/testing.md | 105 +++++ docs/zh-hans/plugins/create/howto.md | 18 +- docs/zh-hans/plugins/create/testing.md | 105 +++++ examples/plugins/sample/README.md | 12 + examples/plugins/sample/main.lua | 40 ++ .../plugins/sample/tests/available_test.lua | 36 ++ .../sample/tests/fixtures/releases.json | 1 + .../plugins/sample/tests/windows_test.lua | 15 + internal/plugin/development.go | 312 ++++++++++++++ internal/plugin/development_http.go | 112 +++++ internal/plugin/development_output.go | 154 +++++++ internal/plugin/development_test.go | 406 ++++++++++++++++++ internal/plugin/loading_regression_test.go | 87 ++++ internal/plugin/lua_plugin.go | 71 +-- internal/plugin/luai/codec/decode.go | 262 +++++------ internal/plugin/luai/codec/decode_test.go | 40 ++ internal/plugin/luai/codec/encode.go | 6 + internal/plugin/luai/module/http/http.go | 58 ++- internal/plugin/luai/module/module.go | 19 +- internal/plugin/luai/vm.go | 16 +- internal/plugin/plugin.go | 3 +- internal/plugin_development.go | 165 +++++++ 28 files changed, 2231 insertions(+), 198 deletions(-) create mode 100644 cmd/commands/plugin.go create mode 100644 cmd/plugin_test.go create mode 100644 docs/plugins/create/testing.md create mode 100644 docs/zh-hans/plugins/create/testing.md create mode 100644 examples/plugins/sample/README.md create mode 100644 examples/plugins/sample/main.lua create mode 100644 examples/plugins/sample/tests/available_test.lua create mode 100644 examples/plugins/sample/tests/fixtures/releases.json create mode 100644 examples/plugins/sample/tests/windows_test.lua create mode 100644 internal/plugin/development.go create mode 100644 internal/plugin/development_http.go create mode 100644 internal/plugin/development_output.go create mode 100644 internal/plugin/development_test.go create mode 100644 internal/plugin/loading_regression_test.go create mode 100644 internal/plugin_development.go diff --git a/cmd/cmd.go b/cmd/cmd.go index 627969c4..ccf27308 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -39,7 +39,7 @@ type cmd struct { func (c *cmd) Execute(args []string) { if err := c.app.Run(context.Background(), args); err != nil { - fmt.Println(err) + fmt.Fprintln(os.Stderr, err) os.Exit(1) } } @@ -93,6 +93,7 @@ func newCmd() *cmd { commands.Upgrade, commands.Remove, commands.Add, + commands.Plugin, commands.Activate, commands.Env, commands.Config, diff --git a/cmd/commands/plugin.go b/cmd/commands/plugin.go new file mode 100644 index 00000000..bee5aaa4 --- /dev/null +++ b/cmd/commands/plugin.go @@ -0,0 +1,108 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package commands + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/urfave/cli/v3" + + "github.com/version-fox/vfox/internal" +) + +var Plugin = &cli.Command{ + Name: "plugin", + Usage: "Test and debug a local plugin", + Category: CategoryPlugin, + Commands: []*cli.Command{ + { + Name: "test", Usage: "Run Lua test files (offline by default)", ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "file", Usage: "Run one test file, relative to the plugin directory"}, + &cli.BoolFlag{Name: "online", Usage: "Allow HTTP requests when no response handler is configured"}, + &cli.DurationFlag{Name: "timeout", Value: 30 * time.Second, Usage: "Lua and HTTP deadline per file"}, + }, + Action: pluginTestCmd, + }, + { + Name: "run", Usage: "Invoke one hook (online by default)", ArgsUsage: " ", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "input", Value: "{}", Usage: "Hook context as a JSON object"}, + &cli.StringFlag{Name: "input-file", Usage: "Read hook context from a JSON file"}, + &cli.BoolFlag{Name: "offline", Usage: "Reject HTTP requests"}, + &cli.StringFlag{Name: "os", Usage: "Override RUNTIME.osType (e.g. linux, darwin, windows)"}, + &cli.StringFlag{Name: "arch", Usage: "Override RUNTIME.archType (e.g. amd64, arm64)"}, + &cli.BoolFlag{Name: "json", Usage: "Write only the typed JSON result to stdout"}, + &cli.DurationFlag{Name: "timeout", Value: 60 * time.Second, Usage: "Lua and HTTP deadline"}, + }, + Action: pluginRunCmd, + }, + }, +} + +func pluginTestCmd(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() != 1 { + return fmt.Errorf("usage: vfox plugin test [options]") + } + if cmd.Duration("timeout") <= 0 { + return fmt.Errorf("timeout must be positive") + } + return internal.TestPlugin(ctx, cmd.Args().First(), internal.PluginTestOptions{ + File: cmd.String("file"), Online: cmd.Bool("online"), Timeout: cmd.Duration("timeout"), + Output: cmd.Writer, Diagnostics: cmd.ErrWriter, + }) +} + +func pluginRunCmd(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() != 2 { + return fmt.Errorf("usage: vfox plugin run [options]") + } + if cmd.Duration("timeout") <= 0 { + return fmt.Errorf("timeout must be positive") + } + if cmd.IsSet("input") && cmd.IsSet("input-file") { + return fmt.Errorf("use either --input or --input-file") + } + input := []byte(cmd.String("input")) + if cmd.IsSet("input-file") { + var err error + input, err = os.ReadFile(cmd.String("input-file")) + if err != nil { + return fmt.Errorf("read hook input: %w", err) + } + } + hook := cmd.Args().Get(1) + result, err := internal.RunPluginHook(ctx, cmd.Args().First(), hook, input, internal.PluginRunOptions{ + OS: cmd.String("os"), Arch: cmd.String("arch"), Offline: cmd.Bool("offline"), + Timeout: cmd.Duration("timeout"), Diagnostics: cmd.ErrWriter, + }) + if err != nil { + return err + } + encoder := json.NewEncoder(cmd.Writer) + if !cmd.Bool("json") { + if _, err := fmt.Fprintf(cmd.Writer, "%s:\n", hook); err != nil { + return err + } + encoder.SetIndent("", " ") + } + return encoder.Encode(result) +} diff --git a/cmd/plugin_test.go b/cmd/plugin_test.go new file mode 100644 index 00000000..708ea467 --- /dev/null +++ b/cmd/plugin_test.go @@ -0,0 +1,254 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestPluginCLIProcess(t *testing.T) { + if os.Getenv("VFOX_PLUGIN_CLI_PROCESS") != "1" { + return + } + separator := slices.Index(os.Args, "--") + if separator < 0 { + os.Exit(2) + } + os.Args = append([]string{"vfox"}, os.Args[separator+1:]...) + Execute(os.Args) + os.Exit(0) +} + +func writeCLIPluginFile(t *testing.T, root, name, source string) string { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(source), 0644); err != nil { + t.Fatal(err) + } + return path +} + +const cliTestPlugin = `PLUGIN={name="cli-test",version="0.0.1"} +function PLUGIN:Available(ctx) return {{version="1.2.3"}} end +function PLUGIN:PreInstall(ctx) + print("hook log") + io.write("io log") + io.stdout:write("stdout log") + return {version=ctx.version, url=RUNTIME.osType.."-"..RUNTIME.archType..".zip", unknown="drop me"} +end +function PLUGIN:EnvKeys(ctx) return {} end +function PLUGIN:PreUse(ctx) return nil end +function PLUGIN:PostInstall(ctx) assert(os.execute("echo child log") == 0) end +function PLUGIN:ParseLegacyFile(ctx) return {version=ctx.getInstalledVersions()[1]} end +` + +func runPluginCLI(t *testing.T, root string, args ...string) (string, string, int) { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + command := exec.CommandContext(ctx, executable, append([]string{"-test.run=^TestPluginCLIProcess$", "--"}, args...)...) + command.Dir = root + command.Env = append(os.Environ(), "VFOX_PLUGIN_CLI_PROCESS=1", + "HOME="+filepath.Join(root, "user"), "USERPROFILE="+filepath.Join(root, "user"), + "VFOX_HOME="+filepath.Join(root, "shared")) + var stdout, stderr bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stderr + err = command.Run() + code := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + code = exitErr.ExitCode() + } else { + t.Fatal(err) + } + } + if ctx.Err() != nil { + t.Fatal("plugin CLI hung") + } + for _, path := range []string{"user", "shared", ".vfox", ".vfox.toml", "vfox.toml"} { + if _, err := os.Stat(filepath.Join(root, path)); !os.IsNotExist(err) { + t.Errorf("development command touched %s: %v", path, err) + } + } + return stdout.String(), stderr.String(), code +} + +func TestPluginRunCLI(t *testing.T) { + root := t.TempDir() + writeCLIPluginFile(t, root, "plugin/main.lua", cliTestPlugin) + writeCLIPluginFile(t, root, ".tool-versions", "sample 1.2.3\n") + writeCLIPluginFile(t, root, "input.json", `{"version":"2.0.0"}`) + + stdout, stderr, code := runPluginCLI(t, root, "--debug", "plugin", "run", "plugin", "PreInstall", "--input", `{"version":"1.2.3"}`, "--os", "windows", "--arch", "arm64", "--json", "--offline") + if code != 0 { + t.Fatalf("run exit = %d: %s", code, stderr) + } + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("stdout is not a single JSON result: %q, %v", stdout, err) + } + if result["version"] != "1.2.3" || result["url"] != "windows-arm64.zip" || result["unknown"] != nil { + t.Fatalf("result = %v", result) + } + for _, message := range []string{"hook log", "io log", "stdout log"} { + if !strings.Contains(stderr, message) { + t.Errorf("stderr missing %q: %q", message, stderr) + } + } + + stdout, stderr, code = runPluginCLI(t, root, "plugin", "run", "plugin", "PreInstall", "--input-file", "input.json", "--json") + if code != 0 || !strings.Contains(stdout, `"version":"2.0.0"`) { + t.Fatalf("input file: %q, %q, %d", stdout, stderr, code) + } + for _, hook := range []string{"PostInstall", "PreUse", "EnvKeys"} { + stdout, stderr, code = runPluginCLI(t, root, "plugin", "run", "plugin", hook, "--json") + if code != 0 || strings.TrimSpace(stdout) != "null" { + t.Fatalf("%s: %q, %q, %d", hook, stdout, stderr, code) + } + if hook == "PostInstall" && !strings.Contains(stderr, "child log") { + t.Fatalf("child stdout was not redirected: %q", stderr) + } + } + stdout, stderr, code = runPluginCLI(t, root, "plugin", "run", "plugin", "ParseLegacyFile", "--json", "--input", `{"installedVersions":["3.0.0"]}`) + if code != 0 || strings.TrimSpace(stdout) != `{"version":"3.0.0"}` { + t.Fatalf("legacy: %q, %q, %d", stdout, stderr, code) + } + + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"plugin", "run", "plugin", "PreInstall", "--input", "[]", "--json"}, "JSON object"}, + {[]string{"plugin", "run", "plugin", "PreInstall", "--input", "null", "--json"}, "JSON object"}, + {[]string{"plugin", "run", "plugin", "PreInstall", "--input", "{", "--json"}, "JSON object"}, + {[]string{"plugin", "run", "plugin", "PreInstall", "--input", "{}", "--input-file", "input.json"}, "either --input or --input-file"}, + {[]string{"plugin", "run", "plugin", "preUse", "--json"}, "unknown hook"}, + {[]string{"plugin", "run", "plugin", "Available", "--timeout", "0s", "--json"}, "timeout must be positive"}, + {[]string{"plugin", "run", "plugin", "PreUninstall", "--json"}, "function not found"}, + } { + stdout, stderr, code := runPluginCLI(t, root, tc.args...) + if code != 1 || stdout != "" || !strings.Contains(stderr, tc.want) { + t.Errorf("%v: %q, %q, %d", tc.args, stdout, stderr, code) + } + } +} + +func TestPluginTestCLI(t *testing.T) { + root := t.TempDir() + writeCLIPluginFile(t, root, "plugin/main.lua", cliTestPlugin) + writeCLIPluginFile(t, root, ".tool-versions", "sample 1.2.3\n") + writeCLIPluginFile(t, root, "plugin/tests/a_test.lua", `require("vfox.test").load()`+"\n"+`assert(false,"first failure")`+"\n"+`error("must not reach")`) + writeCLIPluginFile(t, root, "plugin/tests/nested/b_test.lua", `assert(PLUGIN == nil); local p=require("vfox.test").load(); assert(p:Available({})[1].version == "1.2.3")`) + writeCLIPluginFile(t, root, "plugin/tests/ignored.lua", `error("not a test file")`) + stdout, stderr, code := runPluginCLI(t, root, "plugin", "test", "plugin") + if code != 1 || stdout != "FAIL tests/a_test.lua\nPASS tests/nested/b_test.lua\n" || !strings.Contains(stderr, "a_test.lua:2: first failure") || strings.Contains(stderr, "must not reach") { + t.Fatalf("discovery: %q, %q, %d", stdout, stderr, code) + } + stdout, stderr, code = runPluginCLI(t, root, "plugin", "test", "plugin", "--file", "tests/nested/b_test.lua") + if code != 0 || stdout != "PASS tests/nested/b_test.lua\n" || stderr != "" { + t.Fatalf("filter: %q, %q, %d", stdout, stderr, code) + } + writeCLIPluginFile(t, root, "plugin/tests/a_test.lua", `require("vfox.test").load(); while true do end`) + stdout, stderr, code = runPluginCLI(t, root, "plugin", "test", "plugin", "--timeout", "50ms") + if code != 1 || !strings.Contains(stdout, "PASS tests/nested/b_test.lua") || !strings.Contains(stderr, "deadline exceeded") { + t.Fatalf("timeout continuation: %q, %q, %d", stdout, stderr, code) + } + + writeCLIPluginFile(t, root, "empty/main.lua", cliTestPlugin) + stdout, stderr, code = runPluginCLI(t, root, "plugin", "test", "empty") + if code != 1 || stdout != "" || !strings.Contains(stderr, "no test files") { + t.Fatalf("empty: %q, %q, %d", stdout, stderr, code) + } + stdout, stderr, code = runPluginCLI(t, root, "plugin", "test", "plugin", "--file", "missing.lua") + if code != 1 || stdout != "" { + t.Fatalf("missing filter: %q, %q, %d", stdout, stderr, code) + } +} + +func TestPluginExampleCLI(t *testing.T) { + example, err := filepath.Abs(filepath.Join("..", "examples", "plugins", "sample")) + if err != nil { + t.Fatal(err) + } + stdout, stderr, code := runPluginCLI(t, t.TempDir(), "plugin", "test", example) + if code != 0 || stderr != "" || strings.Count(stdout, "PASS ") != 2 { + t.Fatalf("example: %q, %q, %d", stdout, stderr, code) + } +} + +func TestPluginNetworkCLI(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + fmt.Fprint(w, "1.2.3") + })) + defer server.Close() + root := t.TempDir() + writeCLIPluginFile(t, root, "plugin/main.lua", cliTestPlugin+fmt.Sprintf(` +local http=require("http") +function PLUGIN:Available(ctx) + local response, err=http.get({url=%q}) + assert(response, err) + return {{version=response.body}} +end`, server.URL)) + writeCLIPluginFile(t, root, "plugin/tests/network_test.lua", `local p=require("vfox.test").load(); assert(p:Available({})[1].version=="1.2.3")`) + _, stderr, code := runPluginCLI(t, root, "plugin", "test", "plugin") + if code != 1 || !strings.Contains(stderr, "offline") || requests.Load() != 0 { + t.Fatalf("offline test: %d, %q, %d requests", code, stderr, requests.Load()) + } + _, stderr, code = runPluginCLI(t, root, "plugin", "test", "plugin", "--online") + if code != 0 || requests.Load() != 1 { + t.Fatalf("online test: %d, %q, %d requests", code, stderr, requests.Load()) + } + stdout, stderr, code := runPluginCLI(t, root, "plugin", "run", "plugin", "Available", "--json") + if code != 0 || !strings.Contains(stdout, "1.2.3") || requests.Load() != 2 { + t.Fatalf("online run: %d, %q, %q", code, stdout, stderr) + } + stdout, stderr, code = runPluginCLI(t, root, "plugin", "run", "plugin", "Available", "--json", "--offline") + if code != 1 || stdout != "" || !strings.Contains(stderr, "offline") || requests.Load() != 2 { + t.Fatalf("offline run: %d, %q, %q", code, stdout, stderr) + } + writeCLIPluginFile(t, root, "plugin/tests/network_test.lua", ` +local p=require("vfox.test").load({http=function() return nil,"fixture failure" end}) +local ok,err=pcall(function() p:Available({}) end) +assert(not ok and tostring(err):find("fixture failure",1,true)) +`) + _, stderr, code = runPluginCLI(t, root, "plugin", "test", "plugin", "--online") + if code != 0 || requests.Load() != 2 { + t.Fatalf("handler must not fall through to real HTTP: %d, %q, %d requests", code, stderr, requests.Load()) + } +} diff --git a/docs/.vitepress/en.ts b/docs/.vitepress/en.ts index 0cecd6a9..33007a6b 100644 --- a/docs/.vitepress/en.ts +++ b/docs/.vitepress/en.ts @@ -71,6 +71,7 @@ function sidebar(): DefaultTheme.Sidebar { text: 'Authors', items:[ {text: 'Create a Plugin', link: '/plugins/create/howto'}, + {text: 'Test and Debug', link: '/plugins/create/testing'}, {text: 'How to publish a plugin?', link: '/plugins/create/howto_registry'}, {text: 'Plugin Template', link: 'https://github.com/version-fox/vfox-plugin-template'}, ] diff --git a/docs/.vitepress/zh.ts b/docs/.vitepress/zh.ts index 142ecb0d..31442fc3 100644 --- a/docs/.vitepress/zh.ts +++ b/docs/.vitepress/zh.ts @@ -79,6 +79,7 @@ function sidebar(): DefaultTheme.Sidebar { text: '成为作者', items:[ {text: '创建插件', link: '/zh-hans/plugins/create/howto'}, + {text: '测试与调试', link: '/zh-hans/plugins/create/testing'}, {text: '如何提交到索引仓库?', link: '/zh-hans/plugins/create/howto_registry'}, {text: '插件模板', link: 'https://github.com/version-fox/vfox-plugin-template'}, ] diff --git a/docs/plugins/create/howto.md b/docs/plugins/create/howto.md index 7b82f1ca..94bf8d10 100644 --- a/docs/plugins/create/howto.md +++ b/docs/plugins/create/howto.md @@ -290,24 +290,15 @@ end ## Test Plugin -Currently, VersionFox plugin testing is straightforward. You only need to place the plugin file in the -`${HOME}/.version-fox/plugin` directory and verify that your features are working using different commands. You can use -`print`/`printTable` statements in Lua scripts for printing log. - -- PLUGIN:PreInstall -> `vfox install @` -- PLUGIN:PostInstall -> `vfox install @` -- PLUGIN:Available -> `vfox search ` -- PLUGIN:EnvKeys -> `vfox use @` - -In addition, you can use the `--debug` parameter to view more log information, for example: +Run Lua assertions against your local plugin without installing it: ```shell -vfox --debug install @ -vfox --debug use @ - -... +vfox plugin test . +vfox plugin run . PreInstall --input '{"version":"latest"}' --json ``` +See [Test and debug a plugin](./testing.md) for HTTP fixtures, environment overrides, all hook inputs, and CI execution boundaries. Run real installations in disposable CI runners or VMs. + ## Example Here is an example of a plugin that supports the `Node.js`. diff --git a/docs/plugins/create/testing.md b/docs/plugins/create/testing.md new file mode 100644 index 00000000..589ed506 --- /dev/null +++ b/docs/plugins/create/testing.md @@ -0,0 +1,105 @@ +# Test and debug a plugin + +Use `vfox plugin test` to run ordinary Lua assertions against a local plugin, and `vfox plugin run` to inspect a single hook. Both use the core's plugin loader, built-in modules, and typed hook conversion. They do not register the plugin, install an SDK, or initialize user/project vfox configuration. + +## Write a test + +Add `tests/available_test.lua` to your plugin repository. The following test targets the [sample plugin](https://github.com/version-fox/vfox/tree/main/examples/plugins/sample) shipped with vfox: + +```lua +local test = require("vfox.test") +local plugin = test.load({ + os = "linux", + arch = "amd64", + env = { VFOX_SAMPLE_MIRROR = false }, + http = function(request) + assert(request.method == "GET") + assert(request.url == "https://example.com/releases") + return { + status_code = 200, + body = '{"versions":["1.2.3","1.2.2"]}', + headers = {}, + } + end, +}) + +local versions = plugin:Available({}) +assert(#versions == 2) +assert(versions[1].version == "1.2.3") +local archive = plugin:PreInstall({ version = "latest" }) +assert(archive.version == "1.2.3") +assert(archive.url == "https://example.com/sample-1.2.3-linux-amd64.zip") +``` + +Run it from the plugin repository: + +```shell +vfox plugin test . +vfox plugin test . --file tests/available_test.lua +vfox plugin test . --online --timeout 45s +``` + +The runner discovers `tests/**/*_test.lua` in path order. Each file has fresh Lua globals, loaded modules, environment overrides, and HTTP state. Call `test.load()` once per file; its returned object invokes the real typed hook methods. Assertions stop the current file on the first uncaught failure; other files still run. The command reports `PASS`/`FAIL` and error locations, and exits nonzero if any file fails or no test files exist. A file that never calls `test.load()` fails. + +`--file` is relative to the plugin directory, or may be an absolute file path. Commands retain the current working directory. For fixtures that must work from any directory, read `RUNTIME.pluginDirPath .. "/tests/fixtures/releases.json"` with ordinary `io.open`, and close the file after reading it. See the sample's complete fixture and error-response tests. + +## Configure the test VM + +`test.load({ ... })` accepts: + +| Option | Behavior | +| --- | --- | +| `os`, `arch` | Override `RUNTIME.osType`/`archType` and `OS_TYPE`/`ARCH_TYPE`; default to the host platform. This does not emulate another operating system. | +| `env` | Values returned by this VM's `os.getenv`. Keys are strings; values are strings or `false` for an absent variable. Unspecified variables are absent. The host environment is unchanged. | +| `http` | A function receiving `{method, url, headers}` and returning `{status_code, body, headers}` or `nil, error`. `body` and `headers` are optional. | + +The HTTP function handles every built-in GET, HEAD and download request, including requests made during plugin loading. It receives the constructed request headers, including User-Agent once plugin initialization has finished. Header names use HTTP canonical casing (for example `User-Agent`). A response still goes through the normal HTTP response conversion and real HTML/JSON parsing. Assertions inside the handler fail the calling operation; returning `nil, error` simulates a transport failure. + +Without a handler, tests reject HTTP unless `--online` is set. With a handler, `--online` does not add a fallback to real requests. Use ordinary Lua conditionals, counters, closures and fixture files to model different responses; no mock DSL is needed. + +Both `main.lua` plugins and `metadata.lua` plus `hooks/` plugins are supported. Loading retains the production module search order and initializes runtime globals after the plugin scripts. Test options are installed before loading so top-level imports capture the configured modules. + +## Invoke a hook + +```shell +vfox plugin run . PreInstall --input '{"version":"latest"}' --json +vfox plugin run . EnvKeys --input-file tests/env-keys.json --offline --json +vfox plugin run . PreInstall --input '{"version":"1.2.3"}' --os windows --arch amd64 +vfox plugin run . ParseLegacyFile --input '{"filename":".example-version","filepath":"tests/fixtures/version","strategy":"latest_installed","installedVersions":["1.2.3"]}' --json +``` + +On shells with different JSON quoting rules, prefer `--input-file`. `--input` and `--input-file` are mutually exclusive; omitted input is `{}`. + +Hook names are case-sensitive: `Available`, `PreInstall`, `EnvKeys`, `PostInstall`, `PreUse`, `ParseLegacyFile`, `PreUninstall`. Supply the normal hook context, including paths and installed SDK information when needed. For `ParseLegacyFile`, supply an `installedVersions` array; the adapter provides the hook's `getInstalledVersions()` callback. No installation, scope switching or SDK lookup happens automatically. Invoking an unimplemented optional hook is an error. + +`run` allows real HTTP and retains the real environment by default; `--offline` rejects HTTP. `--json` writes only the typed result to stdout. Void hooks and hooks that provide no result produce `null`. Unknown result fields are discarded by the normal codec. Lua prints, standard output writes, download progress and `os.execute` output go to stderr. Errors go to stderr and cause a nonzero exit. Without `--json`, the result is indented and prefixed with the hook name. + +## Execution boundaries and CI + +`--timeout` must be positive. It defaults to 30 seconds per test file and 60 seconds per `run`, covering Lua execution and built-in HTTP, including response bodies and downloads. It does not guarantee termination of arbitrary external processes or other blocking native operations. + +`os.exit` becomes a Lua error. Tests disable `os.execute` and `io.popen` by default; replace them with ordinary Lua stubs before loading the plugin when needed: + +```lua +local commands = {} +os.execute = function(command) + commands[#commands + 1] = command + return 0 +end +local plugin = require("vfox.test").load() +plugin:PostInstall({ rootPath = "fixture-dir", sdkInfo = {} }) +assert(#commands == 1) +``` + +Use that stub with a plugin whose `PostInstall` invokes one external command. `run` permits external commands for debugging trusted hooks. + +These commands are not a filesystem sandbox. Plugin code, fixture IO, downloads and hooks may write to supplied paths. A simulated `os`/`arch` changes runtime values only. Run installation scripts and real SDK smoke tests in disposable CI runners or VMs, using the actual target OS. Setting `VFOX_HOME` alone does not isolate every normal vfox operation. + +From a vfox checkout, the bundled sample can be tested without downloading an SDK: + +```shell +go run . plugin test examples/plugins/sample +go run . plugin run examples/plugins/sample PreInstall --input '{"version":"1.2.3"}' --os linux --arch amd64 --offline --json +``` + +The core test suite runs the example and CLI regression tests. The repository CI runs that suite on Linux, macOS and Windows. Plugin workflow adoption is a separate change: once a vfox release contains these commands, an ordinary CI step can run `vfox plugin test .`. diff --git a/docs/zh-hans/plugins/create/howto.md b/docs/zh-hans/plugins/create/howto.md index 29251771..959a9a49 100644 --- a/docs/zh-hans/plugins/create/howto.md +++ b/docs/zh-hans/plugins/create/howto.md @@ -282,23 +282,15 @@ end ## 测试插件 -目前,`vfox` 插件测试方法很简单。您需要将插件放在 `${HOME}/.version-fox/plugin` 目录中,并使用不同的命令验证您的功能是否正常工作。 -您可以在插件中使用 `print`/`printTable` 函数来打印日志进行调试。 - -- PLUGIN:PreInstall -> `vfox install @` -- PLUGIN:PostInstall -> `vfox install @` -- PLUGIN:Available -> `vfox search ` -- PLUGIN:EnvKeys -> `vfox use @` - -另外, 你可以通过添加 `--debug` 参数来查看更多的日志信息, 例如: +直接对本地插件运行 Lua 断言,无需安装插件: ```shell -vfox --debug install @ -vfox --debug use @ - -... +vfox plugin test . +vfox plugin run . PreInstall --input '{"version":"latest"}' --json ``` +HTTP fixture、环境覆盖、hook 输入及 CI 边界请参阅[测试与调试插件](./testing.md)。真实安装测试应在一次性 CI runner 或 VM 中执行。 + ## 插件示例 https://github.com/version-fox/vfox-nodejs diff --git a/docs/zh-hans/plugins/create/testing.md b/docs/zh-hans/plugins/create/testing.md new file mode 100644 index 00000000..3f76644d --- /dev/null +++ b/docs/zh-hans/plugins/create/testing.md @@ -0,0 +1,105 @@ +# 测试与调试插件 + +使用 `vfox plugin test` 对本地插件运行普通 Lua 断言,使用 `vfox plugin run` 查看单个 hook 的结果。两者复用 core 的插件加载器、内置模块和强类型 hook 转换,不会注册插件、安装 SDK 或初始化用户及项目的 vfox 配置。 + +## 编写测试 + +在插件仓库新增 `tests/available_test.lua`。下面的测试针对 vfox 仓库内的[示例插件](https://github.com/version-fox/vfox/tree/main/examples/plugins/sample): + +```lua +local test = require("vfox.test") +local plugin = test.load({ + os = "linux", + arch = "amd64", + env = { VFOX_SAMPLE_MIRROR = false }, + http = function(request) + assert(request.method == "GET") + assert(request.url == "https://example.com/releases") + return { + status_code = 200, + body = '{"versions":["1.2.3","1.2.2"]}', + headers = {}, + } + end, +}) + +local versions = plugin:Available({}) +assert(#versions == 2) +assert(versions[1].version == "1.2.3") +local archive = plugin:PreInstall({ version = "latest" }) +assert(archive.version == "1.2.3") +assert(archive.url == "https://example.com/sample-1.2.3-linux-amd64.zip") +``` + +在插件仓库根目录运行: + +```shell +vfox plugin test . +vfox plugin test . --file tests/available_test.lua +vfox plugin test . --online --timeout 45s +``` + +默认按路径顺序发现 `tests/**/*_test.lua`。每个文件拥有独立的 Lua 全局变量、模块缓存、环境覆盖和 HTTP 状态。每个文件调用一次 `test.load()`,返回的对象通过 core 正式的强类型方法调用 hook。未捕获的断言失败会停止当前文件,其他文件继续运行。命令输出文件级 `PASS`/`FAIL` 和错误位置;任何文件失败、没有测试文件或文件未调用 `test.load()`,都会非零退出。 + +`--file` 相对于插件目录,也可以使用绝对文件路径。命令保留当前工作目录。为了让 fixture 在任意工作目录都能读取,可用普通 `io.open` 打开 `RUNTIME.pluginDirPath .. "/tests/fixtures/releases.json"`,读取后关闭文件。示例目录包含完整的 fixture 和错误响应测试。 + +## 配置测试环境 + +`test.load({ ... })` 支持以下选项: + +| 选项 | 行为 | +| --- | --- | +| `os`、`arch` | 覆盖 `RUNTIME.osType`/`archType` 和 `OS_TYPE`/`ARCH_TYPE`,默认使用宿主平台。这些值不会模拟另一个操作系统。 | +| `env` | 覆盖该 VM 的 `os.getenv`。键为字符串,值为字符串或表示不存在的 `false`;未指定的变量也不存在,不修改宿主环境。 | +| `http` | 函数接收 `{method, url, headers}`,返回 `{status_code, body, headers}` 或 `nil, error`;`body`、`headers` 可省略。 | + +HTTP 函数负责全部内置 GET、HEAD 和下载请求,包括插件加载期间发出的请求。它收到构造后的请求头;插件初始化完成后的请求包含 User-Agent。请求头名称使用 HTTP 标准大小写,例如 `User-Agent`。响应继续经过正式 HTTP 转换以及真实 HTML/JSON 解析。函数内的断言失败会使调用失败;返回 `nil, error` 用于模拟传输错误。 + +未配置 HTTP 函数时,测试默认拒绝请求,显式使用 `--online` 才会联网。配置函数后,即使传入 `--online` 也不会回退到真实网络。请求计数、条件响应和读取 fixture 都使用普通 Lua。 + +支持 `main.lua` 和 `metadata.lua` 加 `hooks/` 两种布局。保留正式的模块搜索顺序,以及插件脚本加载后注入运行时变量的顺序;测试选项会在加载前安装,使顶层 `require` 获取到配置后的模块。 + +## 调试单个 hook + +```shell +vfox plugin run . PreInstall --input '{"version":"latest"}' --json +vfox plugin run . EnvKeys --input-file tests/env-keys.json --offline --json +vfox plugin run . PreInstall --input '{"version":"1.2.3"}' --os windows --arch amd64 +vfox plugin run . ParseLegacyFile --input '{"filename":".example-version","filepath":"tests/fixtures/version","strategy":"latest_installed","installedVersions":["1.2.3"]}' --json +``` + +如果 shell 的 JSON 引号规则不同,优先使用 `--input-file`。`--input` 与 `--input-file` 互斥,省略输入时使用 `{}`。 + +hook 名称区分大小写:`Available`、`PreInstall`、`EnvKeys`、`PostInstall`、`PreUse`、`ParseLegacyFile`、`PreUninstall`。输入沿用正式 hook 上下文,所需路径、已安装 SDK 等信息由调用方提供。`ParseLegacyFile` 接收 `installedVersions` 数组,由适配层生成正式的 `getInstalledVersions()` 回调。命令不会自动安装、切换版本或查询已安装 SDK;调用未实现的可选 hook 会报错。 + +`run` 默认联网并保留真实环境,`--offline` 拒绝 HTTP 请求。`--json` 的 stdout 只包含强类型结果;无返回值或未提供结果时输出 `null`,协议以外的字段由正式 codec 丢弃。Lua 打印、标准输出写入、下载进度和 `os.execute` 输出进入 stderr;错误也写 stderr 并非零退出。不使用 `--json` 时,结果带 hook 名称并缩进显示。 + +## 执行边界与 CI + +`--timeout` 必须为正值。默认每个测试文件 30 秒、每次 `run` 60 秒,覆盖 Lua 和内置 HTTP,包括读取响应及下载;不保证终止任意外部进程或其他阻塞的原生操作。 + +开发入口将 `os.exit` 转为 Lua 错误。测试默认禁用 `os.execute`、`io.popen`;需要时可在加载前用普通 Lua 替换: + +```lua +local commands = {} +os.execute = function(command) + commands[#commands + 1] = command + return 0 +end +local plugin = require("vfox.test").load() +plugin:PostInstall({ rootPath = "fixture-dir", sdkInfo = {} }) +assert(#commands == 1) +``` + +这段 stub 适用于 `PostInstall` 会调用一次外部命令的插件。`run` 允许执行外部命令,用于调试可信 hook。 + +这些命令不提供文件系统沙箱。插件代码、fixture IO、下载及 hook 仍可向提供的路径写文件。`os`/`arch` 模拟只改变运行时变量;安装脚本和真实 SDK 冒烟测试应在目标操作系统的一次性 CI runner 或 VM 中执行。仅设置 `VFOX_HOME` 不能隔离所有常规 vfox 操作。 + +在 vfox 仓库中,无需下载 SDK 即可运行示例: + +```shell +go run . plugin test examples/plugins/sample +go run . plugin run examples/plugins/sample PreInstall --input '{"version":"1.2.3"}' --os linux --arch amd64 --offline --json +``` + +core 测试套件会运行示例与 CLI 回归测试,仓库现有 CI 在 Linux、macOS、Windows 执行该套件。插件仓库接入属于后续工作:包含新命令的 vfox 版本发布后,普通 CI 步骤执行 `vfox plugin test .` 即可。 diff --git a/examples/plugins/sample/README.md b/examples/plugins/sample/README.md new file mode 100644 index 00000000..a2943c0b --- /dev/null +++ b/examples/plugins/sample/README.md @@ -0,0 +1,12 @@ +# Sample plugin + +From the vfox repository root: + +```sh +go run . plugin test examples/plugins/sample +go run . plugin run examples/plugins/sample PreInstall --input '{"version":"1.2.3"}' --os linux --arch amd64 --offline --json +``` + +The tests use fixed HTTP fixtures and exercise real hooks, JSON parsing, version selection, error recovery, and platform/environment overrides. They do not download an SDK. The example URLs are placeholders. + +See [the author guide](../../../docs/plugins/create/testing.md) ([中文](../../../docs/zh-hans/plugins/create/testing.md)). diff --git a/examples/plugins/sample/main.lua b/examples/plugins/sample/main.lua new file mode 100644 index 00000000..ae8a8899 --- /dev/null +++ b/examples/plugins/sample/main.lua @@ -0,0 +1,40 @@ +-- A small plugin for exercising the core test runner; no real SDK is downloaded. +local http = require("http") +local json = require("json") + +PLUGIN = { + name = "sample", + version = "0.0.1", + description = "Plugin testing example", +} + +function PLUGIN:Available(ctx) + local response, err = http.get({ url = "https://example.com/releases" }) + if err then error(err) end + assert(response.status_code == 200, "HTTP " .. response.status_code) + + local data = assert(json.decode(response.body)) + local versions = {} + for _, version in ipairs(data.versions) do + table.insert(versions, { version = version }) + end + assert(#versions > 0, "No versions found") + return versions +end + +function PLUGIN:PreInstall(ctx) + local version = ctx.version + if version == "latest" then + version = self:Available({})[1].version + end + assert(version:match("^%d+%.%d+%.%d+$"), "Invalid version: " .. version) + local mirror = os.getenv("VFOX_SAMPLE_MIRROR") or "https://example.com" + return { + version = version, + url = mirror .. "/sample-" .. version .. "-" .. RUNTIME.osType .. "-" .. RUNTIME.archType .. ".zip", + } +end + +function PLUGIN:EnvKeys(ctx) + return { { key = "PATH", value = ctx.main.path .. "/bin" } } +end diff --git a/examples/plugins/sample/tests/available_test.lua b/examples/plugins/sample/tests/available_test.lua new file mode 100644 index 00000000..2c099c55 --- /dev/null +++ b/examples/plugins/sample/tests/available_test.lua @@ -0,0 +1,36 @@ +local test = require("vfox.test") +local status = 200 +local requests = 0 + +local plugin = test.load({ + os = "linux", + arch = "amd64", + http = function(request) + requests = requests + 1 + assert(request.method == "GET") + assert(request.url == "https://example.com/releases") + + -- Use the plugin directory so fixtures work from any working directory. + local file = assert(io.open(RUNTIME.pluginDirPath .. "/tests/fixtures/releases.json", "r")) + local body = file:read("*a") + assert(file:close()) + return { status_code = status, body = body } + end, +}) + +local versions = plugin:Available({}) +assert(#versions == 2) +assert(versions[1].version == "1.2.3") +assert(versions[2].version == "1.2.2") + +local archive = plugin:PreInstall({ version = "latest" }) +assert(archive.version == "1.2.3") +assert(archive.url == "https://example.com/sample-1.2.3-linux-amd64.zip") +assert(requests == 2) + +status = 503 +local ok, err = pcall(function() plugin:Available({}) end) +assert(not ok and tostring(err):find("HTTP 503", 1, true)) + +status = 200 +assert(plugin:Available({})[1].version == "1.2.3") diff --git a/examples/plugins/sample/tests/fixtures/releases.json b/examples/plugins/sample/tests/fixtures/releases.json new file mode 100644 index 00000000..899a0d04 --- /dev/null +++ b/examples/plugins/sample/tests/fixtures/releases.json @@ -0,0 +1 @@ +{"versions":["1.2.3","1.2.2"]} diff --git a/examples/plugins/sample/tests/windows_test.lua b/examples/plugins/sample/tests/windows_test.lua new file mode 100644 index 00000000..856f1cc4 --- /dev/null +++ b/examples/plugins/sample/tests/windows_test.lua @@ -0,0 +1,15 @@ +local test = require("vfox.test") +local plugin = test.load({ + os = "windows", + arch = "amd64", + env = { VFOX_SAMPLE_MIRROR = "https://mirror.example.com" }, +}) + +local archive = plugin:PreInstall({ version = "1.2.3" }) +assert(archive.url == "https://mirror.example.com/sample-1.2.3-windows-amd64.zip") +local keys = plugin:EnvKeys({ main = { path = "sdk-directory" } }) +assert(keys[1].key == "PATH" and keys[1].value == "sdk-directory/bin") + +-- No HTTP handler: an accidental network request fails in the default offline mode. +local ok, err = pcall(function() plugin:Available({}) end) +assert(not ok and tostring(err):find("HTTP is disabled offline", 1, true)) diff --git a/internal/plugin/development.go b/internal/plugin/development.go new file mode 100644 index 00000000..6a0611aa --- /dev/null +++ b/internal/plugin/development.go @@ -0,0 +1,312 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + + lua "github.com/yuin/gopher-lua" + + "github.com/version-fox/vfox/internal/plugin/luai" + "github.com/version-fox/vfox/internal/plugin/luai/codec" + "github.com/version-fox/vfox/internal/plugin/luai/module" + "github.com/version-fox/vfox/internal/shared/util" +) + +// DevelopmentOptions affect only the temporary VM, never installed plugins or +// user/project configuration. The orchestration layer supplies RuntimeVersion. +type DevelopmentOptions struct { + RuntimeVersion string + OS, Arch string + Online bool + Output io.Writer +} + +type developmentVM struct { + vm *luai.LuaVM + hookThread *lua.LState + cancelHooks context.CancelFunc + transport *developmentTransport + runtime RuntimeInfo + environment map[string]lua.LValue + plugin *LuaPlugin + loadAttempted bool +} + +func newDevelopmentVM(ctx context.Context, directory string, options DevelopmentOptions, testing bool) (*developmentVM, error) { + if options.Output == nil { + options.Output = os.Stderr + } + if options.OS == "" { + options.OS = string(util.GetOSType()) + } + if options.Arch == "" { + options.Arch = string(util.GetArchType()) + } + d := &developmentVM{ + vm: luai.NewLuaVM(), + transport: &developmentTransport{online: options.Online}, + runtime: RuntimeInfo{OsType: options.OS, ArchType: options.Arch, Version: options.RuntimeVersion, PluginDirPath: directory}, + environment: make(map[string]lua.LValue), + } + d.vm.Instance.SetContext(ctx) + if err := d.vm.Prepare(&module.PreloadOptions{ + Output: options.Output, + HTTPTransport: func(fallback http.RoundTripper) http.RoundTripper { + d.transport.fallback = fallback + return d.transport + }, + }); err != nil { + d.close() + return nil, err + } + L := d.vm.Instance + configureDevelopmentOutput(L, options.Output, testing) + if testing { + // Use a separate call stack in the SAME VM (shared globals/modules). + // gopher-lua #448 closes the caller's open upvalues on a hook error + // when a nested protected call uses the test's stack. + d.hookThread, d.cancelHooks = L.NewThread() + L.GetGlobal("os").(*lua.LTable).RawSetString("getenv", L.NewFunction(func(L *lua.LState) int { + value, exists := d.environment[L.CheckString(1)] + if !exists || value == lua.LFalse { + value = lua.LNil + } + L.Push(value) + return 1 + })) + L.PreloadModule("vfox.test", func(L *lua.LState) int { + api := L.NewTable() + api.RawSetString("load", L.NewFunction(d.loadForTest)) + L.Push(api) + return 1 + }) + } + return d, nil +} + +func (d *developmentVM) close() { + if d.cancelHooks != nil { + d.cancelHooks() + } + d.vm.Close() + d.transport.close() +} + +func (d *developmentVM) load() error { + original := d.vm.Instance + if d.hookThread != nil { + d.vm.Instance = d.hookThread + } + defer func() { d.vm.Instance = original }() + p, metadata, err := loadLuaPlugin(d.vm, d.runtime.PluginDirPath, d.runtime) + if err != nil { + return err + } + wrapper := &Wrapper{Metadata: metadata, Plugin: p, InstalledPath: d.runtime.PluginDirPath} + if err := wrapper.validate(); err != nil { + return err + } + if metadata.MinRuntimeVersion != "" && util.CompareVersion(metadata.MinRuntimeVersion, d.runtime.Version) > 0 { + return fmt.Errorf("plugin requires vfox >= %s", metadata.MinRuntimeVersion) + } + p.development = true + d.plugin = p + return nil +} + +func (d *developmentVM) loadForTest(L *lua.LState) int { + if d.loadAttempted { + L.RaiseError("test.load may only be called once per test file") + } + d.loadAttempted = true + options := L.OptTable(1, L.NewTable()) + options.ForEach(func(key, value lua.LValue) { + switch key.String() { + case "os", "arch": + text, ok := value.(lua.LString) + if !ok || text == "" { + L.RaiseError("test.load %s must be a nonempty string", key) + } + if key.String() == "os" { + d.runtime.OsType = string(text) + } else { + d.runtime.ArchType = string(text) + } + case "env": + env, ok := value.(*lua.LTable) + if !ok { + L.RaiseError("test.load env must be a table") + } + env.ForEach(func(key, value lua.LValue) { + if key.Type() != lua.LTString || (value.Type() != lua.LTString && value != lua.LFalse) { + L.RaiseError("test.load env requires string keys and string or false values") + } + d.environment[key.String()] = value + }) + case "http": + fn, ok := value.(*lua.LFunction) + if !ok { + L.RaiseError("test.load http must be a function") + } + d.transport.handler = fn + default: + L.RaiseError("unknown test.load option %s", key) + } + }) + if err := d.load(); err != nil { + L.RaiseError("load plugin: %s", err) + } + proxy := L.NewTable() + for name := range HookFuncMap { + proxy.RawSetString(name, L.NewFunction(func(L *lua.LState) int { + if L.Get(1) != proxy { + raiseDevelopmentError(L, fmt.Errorf("use plugin:%s(ctx) with a context table", name)) + } + input, ok := L.Get(2).(*lua.LTable) + if !ok { + raiseDevelopmentError(L, fmt.Errorf("%s context must be a table", name)) + } + original := d.vm.Instance + d.vm.Instance = d.hookThread + defer func() { d.vm.Instance = original }() + result, err := invokeDevelopmentHook(d.plugin, name, func(target any) error { return codec.Unmarshal(input, target) }) + if err != nil { + raiseDevelopmentError(L, fmt.Errorf("%s: %w", name, err)) + } + value, err := codec.Marshal(L, result) + if err != nil { + raiseDevelopmentError(L, fmt.Errorf("%s result: %w", name, err)) + } + L.Push(value) + return 1 + })) + } + L.Push(proxy) + return 1 +} + +// Raise an ordinary Lua error without closing the test caller's upvalues. +// L.RaiseError would close them even when Lua pcall catches the bridge error: +// https://github.com/yuin/gopher-lua/issues/448. +func raiseDevelopmentError(L *lua.LState, err error) { + panic(&lua.ApiError{Type: lua.ApiErrorRun, Object: lua.LString(L.Where(1) + err.Error())}) +} + +// TestDevelopmentFile runs ordinary Lua assertions in a single fresh VM. +func TestDevelopmentFile(ctx context.Context, directory, file string, options DevelopmentOptions) error { + d, err := newDevelopmentVM(ctx, directory, options, true) + if err != nil { + return err + } + defer d.close() + if err := d.vm.Instance.DoFile(file); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if d.plugin == nil { + return fmt.Errorf("test file did not load a plugin with test.load()") + } + return nil +} + +// RunDevelopmentHook calls exactly one hook, without SDK lifecycle orchestration. +func RunDevelopmentHook(ctx context.Context, directory, hook string, input []byte, options DevelopmentOptions) (any, error) { + if _, ok := HookFuncMap[hook]; !ok { + return nil, fmt.Errorf("unknown hook %q", hook) + } + input = bytes.TrimSpace(input) + if len(input) == 0 { + input = []byte("{}") + } + if input[0] != '{' || !json.Valid(input) { + return nil, fmt.Errorf("hook input must be a JSON object") + } + d, err := newDevelopmentVM(ctx, directory, options, false) + if err != nil { + return nil, err + } + defer d.close() + if err := d.load(); err != nil { + return nil, err + } + result, err := invokeDevelopmentHook(d.plugin, hook, func(target any) error { return json.Unmarshal(input, target) }) + if err != nil { + return nil, fmt.Errorf("%s: %w", hook, err) + } + if err := ctx.Err(); err != nil { + return nil, err + } + return result, nil +} + +func callDevelopmentHook[C, R any](decode func(any) error, call func(*C) (R, error)) (any, error) { + var input C + if err := decode(&input); err != nil { + return nil, fmt.Errorf("hook input: %w", err) + } + result, err := call(&input) + if errors.Is(err, ErrNoResultProvide) { + return nil, nil + } + return result, err +} + +type developmentLegacyContext struct { + Filepath string `json:"filepath"` + Filename string `json:"filename"` + Strategy string `json:"strategy"` + InstalledVersions []string `json:"installedVersions"` +} + +func invokeDevelopmentHook(p Plugin, name string, decode func(any) error) (any, error) { + if !p.HasFunction(name) { + return nil, fmt.Errorf("[%s] function not found", name) + } + switch name { + case "Available": + return callDevelopmentHook(decode, p.Available) + case "PreInstall": + return callDevelopmentHook(decode, p.PreInstall) + case "EnvKeys": + return callDevelopmentHook(decode, p.EnvKeys) + case "PreUse": + return callDevelopmentHook(decode, p.PreUse) + case "PostInstall": + return callDevelopmentHook(decode, func(ctx *PostInstallHookCtx) (any, error) { return nil, p.PostInstall(ctx) }) + case "PreUninstall": + return callDevelopmentHook(decode, func(ctx *PreUninstallHookCtx) (any, error) { return nil, p.PreUninstall(ctx) }) + case "ParseLegacyFile": + return callDevelopmentHook(decode, func(ctx *developmentLegacyContext) (*ParseLegacyFileResult, error) { + return p.ParseLegacyFile(&ParseLegacyFileHookCtx{ + Filepath: ctx.Filepath, Filename: ctx.Filename, Strategy: ctx.Strategy, + GetInstalledVersions: func() []string { return ctx.InstalledVersions }, + }) + }) + default: + return nil, fmt.Errorf("unknown hook %q", name) + } +} diff --git a/internal/plugin/development_http.go b/internal/plugin/development_http.go new file mode 100644 index 00000000..85ec4fa8 --- /dev/null +++ b/internal/plugin/development_http.go @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package plugin + +import ( + "fmt" + "io" + "net/http" + "strings" + + lua "github.com/yuin/gopher-lua" + + "github.com/version-fox/vfox/internal/plugin/luai/codec" + luahttp "github.com/version-fox/vfox/internal/plugin/luai/module/http" +) + +// developmentTransport is confined to one VM. RoundTrip never starts Lua work +// on another goroutine; the standard HTTP module calls it synchronously. +type developmentTransport struct { + online bool + handler *lua.LFunction + fallback http.RoundTripper +} + +func (t *developmentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if err := req.Context().Err(); err != nil { + return nil, err + } + if t.handler == nil { + if !t.online { + return nil, fmt.Errorf("HTTP is disabled offline: %s %s", req.Method, req.URL) + } + return t.fallback.RoundTrip(req) + } + L := luahttp.RequestState(req) + if L == nil { + return nil, fmt.Errorf("HTTP handler requires the invoking Lua thread") + } + headers := make(map[string]string, len(req.Header)) + for name := range req.Header { + headers[name] = req.Header.Get(name) + } + request, err := codec.Marshal(L, struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + }{req.Method, req.URL.String(), headers}) + if err != nil { + return nil, err + } + top := L.GetTop() + defer L.SetTop(top) + if err := L.CallByParam(lua.P{Fn: t.handler, NRet: 2, Protect: true}, request); err != nil { + // Assertions in a handler are test failures. Explicit nil,error results + // below instead model a transport failure the plugin can handle. + L.RaiseError("HTTP handler: %s", err) + } + if failure := L.Get(-1); failure != lua.LNil { + return nil, fmt.Errorf("HTTP handler: %s", failure.String()) + } + response, ok := L.Get(-2).(*lua.LTable) + if !ok { + L.RaiseError("HTTP handler must return a response table or nil, error") + } + var data struct { + StatusCode int `json:"status_code"` + Body string `json:"body"` + Headers map[string]string `json:"headers"` + } + if err := codec.Unmarshal(response, &data); err != nil { + L.RaiseError("HTTP handler response: %s", err) + } + if data.StatusCode < 100 || data.StatusCode > 599 { + L.RaiseError("HTTP handler response requires a valid status_code") + } + responseHeaders := make(http.Header, len(data.Headers)) + for name, value := range data.Headers { + responseHeaders.Set(name, value) + } + body := data.Body + if req.Method == http.MethodHead { + body = "" + } + return &http.Response{ + StatusCode: data.StatusCode, + Status: fmt.Sprintf("%d %s", data.StatusCode, http.StatusText(data.StatusCode)), + Header: responseHeaders, + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: int64(len(data.Body)), + Request: req, + }, nil +} + +func (t *developmentTransport) close() { + if closer, ok := t.fallback.(interface{ CloseIdleConnections() }); ok { + closer.CloseIdleConnections() + } +} diff --git a/internal/plugin/development_output.go b/internal/plugin/development_output.go new file mode 100644 index 00000000..68c1e3a1 --- /dev/null +++ b/internal/plugin/development_output.go @@ -0,0 +1,154 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package plugin + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + lua "github.com/yuin/gopher-lua" +) + +// forwardLua preserves all results when wrapping a standard library function. +func forwardLua(L *lua.LState, fn lua.LValue, args ...lua.LValue) int { + top := L.GetTop() + if err := L.CallByParam(lua.P{Fn: fn, NRet: lua.MultRet, Protect: true}, args...); err != nil { + L.RaiseError("%s", err) + } + return L.GetTop() - top +} + +func luaArguments(L *lua.LState) []lua.LValue { + args := make([]lua.LValue, L.GetTop()) + for i := range args { + args[i] = L.Get(i + 1) + } + return args +} + +// Redirect writes per VM, retaining normal Lua file handles and io.output. +// Neither the host's stdout nor its process environment is modified. +func configureDevelopmentOutput(L *lua.LState, output io.Writer, testing bool) { + L.SetGlobal("print", L.NewFunction(func(L *lua.LState) int { + parts := make([]string, L.GetTop()) + for i := range parts { + parts[i] = L.ToStringMeta(L.Get(i + 1)).String() + } + if _, err := fmt.Fprintln(output, strings.Join(parts, "\t")); err != nil { + L.RaiseError("write diagnostics: %s", err) + } + return 0 + })) + ioModule := L.GetGlobal("io").(*lua.LTable) + stdout, stderr := ioModule.RawGetString("stdout"), ioModule.RawGetString("stderr") + isStandard := func(value lua.LValue) bool { return value == stdout || value == stderr } + write := func(L *lua.LState, start int) int { + for i := start; i <= L.GetTop(); i++ { + if _, err := io.WriteString(output, L.CheckString(i)); err != nil { + L.RaiseError("write diagnostics: %s", err) + } + } + // Match gopher-lua's file:write/io.write result. + L.Push(lua.LTrue) + return 1 + } + standardResult := func(L *lua.LState, name string, start int) int { + switch name { + case "write": + return write(L, start) + case "close": + L.Push(lua.LNil) + L.Push(lua.LString("cannot close standard output")) + return 2 + default: + L.Push(lua.LTrue) + return 1 + } + } + methods := L.GetMetatable(stdout).(*lua.LTable) + for _, name := range []string{"write", "flush", "close", "setvbuf"} { + original := methods.RawGetString(name) + methods.RawSetString(name, L.NewFunction(func(L *lua.LState) int { + if isStandard(L.Get(1)) { + return standardResult(L, name, 2) + } + return forwardLua(L, original, luaArguments(L)...) + })) + } + originalOutput := ioModule.RawGetString("output") + for _, name := range []string{"write", "flush", "close"} { + original := ioModule.RawGetString(name) + ioModule.RawSetString(name, L.NewFunction(func(L *lua.LState) int { + file := L.Get(1) + if name != "close" || L.GetTop() == 0 { + if err := L.CallByParam(lua.P{Fn: originalOutput, NRet: 1, Protect: true}); err != nil { + L.RaiseError("%s", err) + } + file = L.Get(-1) + L.Pop(1) + } + if isStandard(file) { + return standardResult(L, name, 1) + } + return forwardLua(L, original, luaArguments(L)...) + })) + } + osModule := L.GetGlobal("os").(*lua.LTable) + osModule.RawSetString("exit", L.NewFunction(func(L *lua.LState) int { + L.RaiseError("os.exit(%d) called", L.OptInt(1, 0)) + return 0 + })) + if testing { + for _, entry := range []struct { + table *lua.LTable + name, label string + }{ + {osModule, "execute", "os.execute"}, {ioModule, "popen", "io.popen"}, + } { + entry.table.RawSetString(entry.name, L.NewFunction(func(L *lua.LState) int { + L.RaiseError("%s is disabled in plugin tests; replace it with a Lua stub or use disposable CI", entry.label) + return 0 + })) + } + } else { + osModule.RawSetString("execute", L.NewFunction(func(L *lua.LState) int { + program, args := "/bin/sh", []string{"-c", L.CheckString(1)} + if runtime.GOOS == "windows" { + root := os.Getenv("SystemRoot") + if root == "" { + root = `C:\Windows` + } + program, args = filepath.Join(root, "System32", "cmd.exe"), []string{"/c", L.CheckString(1)} + } + // As with normal Lua os.execute, arbitrary child processes are not + // covered by the Lua/HTTP deadline. Their output is diagnostic text. + command := exec.Command(program, args...) + command.Stdin, command.Stdout, command.Stderr = os.Stdin, output, output + if err := command.Run(); err != nil { + L.Push(lua.LNumber(1)) + } else { + L.Push(lua.LNumber(0)) + } + return 1 + })) + } +} diff --git a/internal/plugin/development_test.go b/internal/plugin/development_test.go new file mode 100644 index 00000000..1970b89a --- /dev/null +++ b/internal/plugin/development_test.go @@ -0,0 +1,406 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package plugin + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/version-fox/vfox/internal/config" + "github.com/version-fox/vfox/internal/env" +) + +func developmentOptions() DevelopmentOptions { + return DevelopmentOptions{RuntimeVersion: "1.0.12", Output: io.Discard} +} + +func TestDevelopmentAllHooks(t *testing.T) { + dir := t.TempDir() + writePluginFile(t, dir, "main.lua", ` +local http = require("http") +local json = require("json") +local html = require("html") +local initial_env = os.getenv("MIRROR") +local hidden = os.getenv("VFOX_PLUGIN_TEST_SECRET") +local boot = assert(http.get({url="https://example.com/boot"})) +assert(boot.body == "boot") +assert(RUNTIME == nil) +RUNTIME = {osType="overwritten"} +OS_TYPE, ARCH_TYPE = "overwritten", "overwritten" +PLUGIN = {name="example", version="0.1.0"} +function PLUGIN:Available(ctx) + assert(initial_env == "fixture" and hidden == nil) + assert(RUNTIME.osType == "windows" and RUNTIME.archType == "amd64") + assert(OS_TYPE == "windows" and ARCH_TYPE == "amd64") + assert(ctx.args[1] == "stable") + local resp = assert(http.get({url="https://example.com/releases", headers={Accept="application/json"}})) + local document = html.parse(json.decode(resp.body).html) + return {{version=document:find("b"):text(), ignored="not in the typed result"}} +end +function PLUGIN:PreInstall(ctx) + local resp = assert(http.head({url="https://example.com/archive", headers={["User-Agent"]="custom"}})) + assert(resp.headers["X-Fixture"] == "yes") + return {version=ctx.version, url="sdk.zip", sha256="main-hash", headers={Token="value"}, + addition={{name="extra", version="2.0", url="extra.zip", sha512="extra-hash"}}} +end +function PLUGIN:EnvKeys(ctx) + assert(ctx.main.version == "1.2.3" and ctx.sdkInfo.extra.path == "extra-dir") + return {{key="PATH", value=ctx.path.."/bin"}} +end +function PLUGIN:PreUse(ctx) + if ctx.version == "none" then return nil end + assert(ctx.cwd == "project-dir" and ctx.scope == "project" and ctx.previousVersion == "1.0") + return {version=ctx.installedSdks["1.2.3"].version} +end +function PLUGIN:ParseLegacyFile(ctx) + assert(ctx.filename == ".example-version" and ctx.filepath == "fixture-path" and ctx.strategy == "latest_installed") + assert(type(ctx.getInstalledVersions) == "function") + return {version=ctx.getInstalledVersions()[1]} +end +function PLUGIN:PostInstall(ctx) + assert(ctx.sdkInfo.example.version == "1.2.3") + assert(http.download_file({url="https://example.com/archive"}, ctx.rootPath.."/payload") == nil) +end +function PLUGIN:PreUninstall(ctx) + assert(ctx.main.version == "1.2.3" and ctx.sdkInfo.extra.path == "extra-dir") +end +`) + t.Setenv("VFOX_PLUGIN_TEST_SECRET", "host-secret") + script := ` +local test = require("vfox.test") +local requests = 0 +local p = test.load({os="windows", arch="amd64", env={MIRROR="fixture", VFOX_PLUGIN_TEST_SECRET=false}, http=function(req) + requests = requests + 1 + if req.url == "https://example.com/boot" then return {status_code=200, body="boot"} end + if req.url == "https://example.com/releases" then + assert(req.method == "GET" and req.headers.Accept == "application/json") + assert(req.headers["User-Agent"]:find("vfox/1.0.12", 1, true)) + return {status_code=200, body='{"html":"1.2.3"}'} + end + assert(req.url == "https://example.com/archive") + if req.method == "HEAD" then + assert(req.headers["User-Agent"] == "custom") + return {status_code=200, headers={["X-Fixture"]="yes"}} + end + assert(req.method == "GET") + return {status_code=200, body="payload"} +end}) +local versions = p:Available({args={"stable"}}) +assert(versions[1].version == "1.2.3" and versions[1].ignored == nil) +local archive = p:PreInstall({version="1.2.3"}) +assert(archive.url == "sdk.zip" and archive.sha256 == "main-hash" and archive.headers.Token == "value") +assert(archive.addition[1].name == "extra" and archive.addition[1].sha512 == "extra-hash") +local installed = {version="1.2.3", name="example", path="sdk-dir"} +local sdkInfo = {example=installed, extra={path="extra-dir"}} +assert(p:EnvKeys({path="sdk-dir", main=installed, sdkInfo=sdkInfo})[1].value == "sdk-dir/bin") +assert(p:PreUse({cwd="project-dir", scope="project", version="1.2", previousVersion="1.0", installedSdks={["1.2.3"]=installed}}).version == "1.2.3") +assert(p:PreUse({version="none"}) == nil) +assert(p:ParseLegacyFile({filename=".example-version", filepath="fixture-path", strategy="latest_installed", installedVersions={"1.2.3"}}).version == "1.2.3") +assert(p:PostInstall({rootPath=RUNTIME.pluginDirPath, sdkInfo=sdkInfo}) == nil) +assert(p:PreUninstall({main=installed, sdkInfo=sdkInfo}) == nil) +assert(requests == 4) +` + file := writePluginFile(t, dir, "tests/all_test.lua", script) + if err := TestDevelopmentFile(context.Background(), dir, file, developmentOptions()); err != nil { + t.Fatal(err) + } + payload, err := os.ReadFile(filepath.Join(dir, "payload")) + if err != nil || string(payload) != "payload" { + t.Fatalf("download = %q, %v", payload, err) + } + if os.Getenv("VFOX_PLUGIN_TEST_SECRET") != "host-secret" { + t.Fatal("host environment changed") + } +} + +func TestDevelopmentLayouts(t *testing.T) { + for _, layout := range []string{"main", "metadata"} { + t.Run(layout, func(t *testing.T) { + dir := t.TempDir() + available := `local util = require("util") +function PLUGIN:Available(ctx) return {{version=util.value}} end` + if layout == "main" { + writePluginFile(t, dir, "main.lua", minimalPlugin+available) + writePluginFile(t, dir, "metadata.lua", `error("main.lua must take precedence")`) + writePluginFile(t, dir, "util.lua", `return {value="main"}`) + } else { + writePluginFile(t, dir, "metadata.lua", `PLUGIN={name="example"}`) + writePluginFile(t, dir, "hooks/available.lua", available) + writePluginFile(t, dir, "hooks/pre_install.lua", `function PLUGIN:PreInstall(ctx) return {version=ctx.version} end`) + writePluginFile(t, dir, "hooks/env_keys.lua", `function PLUGIN:EnvKeys(ctx) return {} end`) + writePluginFile(t, dir, "hooks/pre_use.lua", `function PLUGIN:PreUse(ctx) return {version="optional"} end`) + writePluginFile(t, dir, "hooks/util.lua", `return {value="metadata"}`) + writePluginFile(t, dir, "lib/util.lua", `error("hooks must take precedence")`) + } + // Both development and normal loading use the same module paths. + p, err := CreatePlugin(dir, &env.RuntimeEnvContext{UserConfig: config.DefaultConfig}) + if err != nil { + t.Fatal(err) + } + defer p.Close() + versions, err := p.Available(&AvailableHookCtx{}) + if err != nil || versions[0].Version != layout { + t.Fatalf("normal loading = %v, %v", versions, err) + } + result, err := RunDevelopmentHook(context.Background(), dir, "Available", nil, developmentOptions()) + if err != nil || result.([]*AvailableHookResultItem)[0].Version != layout { + t.Fatalf("development loading = %v, %v", result, err) + } + if layout == "metadata" { + result, err := RunDevelopmentHook(context.Background(), dir, "PreUse", nil, developmentOptions()) + if err != nil || result.(*PreUseHookResult).Version != "optional" { + t.Fatalf("optional hook = %v, %v", result, err) + } + } + }) + } +} + +func TestDevelopmentErrors(t *testing.T) { + for _, tc := range []struct{ name, source, script, want string }{ + {"assert location", "", `local p=require("vfox.test").load()` + "\n" + `assert(false, "first failure")`, "case_test.lua:2: first failure"}, + {"exit", "", `require("vfox.test").load(); os.exit(0)`, "os.exit(0)"}, + {"execute", "", `require("vfox.test").load(); os.execute("echo forbidden")`, "os.execute is disabled"}, + {"popen", "", `require("vfox.test").load(); io.popen("echo forbidden")`, "io.popen is disabled"}, + {"second instance", "", `local t=require("vfox.test"); t.load(); t.load()`, "once per test file"}, + {"missing load", "", `assert(true)`, "did not load"}, + {"missing optional", "", `require("vfox.test").load():PreUse({})`, "PreUse"}, + {"bad input", "", `require("vfox.test").load():PreInstall({version={}})`, "version"}, + {"bad output", `function PLUGIN:Available(ctx) return "wrong" end`, `require("vfox.test").load():Available({})`, "cannot unmarshal string"}, + {"bad nested output", `function PLUGIN:PreInstall(ctx) return {addition={{headers="wrong"}}} end`, `require("vfox.test").load():PreInstall({})`, "headers"}, + {"bad option", "", `require("vfox.test").load({network=true})`, "unknown test.load option"}, + {"bad env", "", `require("vfox.test").load({env={TOKEN=true}})`, "string or false"}, + {"offline", `local http=require("http"); function PLUGIN:Available(ctx) local r,e=http.get({url="https://example.com"}); assert(r,e) end`, `require("vfox.test").load():Available({})`, "HTTP is disabled offline"}, + {"handler assertion", `local http=require("http"); function PLUGIN:Available(ctx) http.get({url="https://example.com"}); return {} end`, `require("vfox.test").load({http=function() assert(false,"wrong request") end}):Available({})`, "wrong request"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writePluginFile(t, dir, "main.lua", minimalPlugin+tc.source) + file := writePluginFile(t, dir, "tests/case_test.lua", tc.script) + err := TestDevelopmentFile(context.Background(), dir, file, developmentOptions()) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestDevelopmentHTTPFailures(t *testing.T) { + dir := t.TempDir() + writePluginFile(t, dir, "main.lua", minimalPlugin+` +local http = require("http") +function PLUGIN:Available(ctx) + local response, err = http.get({url="https://example.com/releases"}) + if err then error(err) end + assert(response.status_code == 200, "HTTP "..response.status_code) + return {{version=response.body}} +end +`) + file := writePluginFile(t, dir, "tests/http_test.lua", ` +local mode = "status" +local p = require("vfox.test").load({http=function() + if mode == "status" then return {status_code=503,body="unavailable"} end + if mode == "transport" then return nil, "connection reset" end + return {status_code=200,body="1.2.3"} +end}) +local ok, err = pcall(function() p:Available({}) end) +assert(not ok and tostring(err):find("HTTP 503", 1, true), tostring(err)) +mode = "transport" +ok, err = pcall(function() p:Available({}) end) +assert(not ok and tostring(err):find("connection reset", 1, true), tostring(err)) +mode = "success" +assert(p:Available({})[1].version == "1.2.3") +`) + if err := TestDevelopmentFile(context.Background(), dir, file, developmentOptions()); err != nil { + t.Fatal(err) + } +} + +func TestDevelopmentVMIsolationAndStubs(t *testing.T) { + dir := t.TempDir() + t.Setenv("VFOX_PLUGIN_TEST_SECRET", "host") + writePluginFile(t, dir, "main.lua", minimalPlugin+` +local http = require("http") +local counter = 0 +function PLUGIN:Available(ctx) + counter = counter + 1 + return {{version=tostring(counter)}} +end +function PLUGIN:PostInstall(ctx) assert(os.execute("pretend-install") == 0) end +`) + first := writePluginFile(t, dir, "tests/first_test.lua", ` +local calls = 0 +os.execute = function(command) assert(command == "pretend-install"); calls=calls+1; return 0 end +local p=require("vfox.test").load({env={VFOX_PLUGIN_TEST_SECRET="override"}, http=function() return {status_code=200} end}) +assert(os.getenv("VFOX_PLUGIN_TEST_SECRET") == "override") +assert(p:Available({})[1].version == "1") +assert(p:Available({})[1].version == "2") +p:PostInstall({}) +assert(calls == 1) +GLOBAL_MARKER = true +package.loaded["marker"] = true +`) + second := writePluginFile(t, dir, "tests/second_test.lua", ` +assert(GLOBAL_MARKER == nil and package.loaded["marker"] == nil and PLUGIN == nil and RUNTIME == nil) +assert(os.getenv("VFOX_PLUGIN_TEST_SECRET") == nil) +local p=require("vfox.test").load() +assert(p:Available({})[1].version == "1") +local r,e=require("http").get({url="https://example.com"}) +assert(r == nil and e:find("offline")) +local ok,err=pcall(function() p:PostInstall({}) end) +assert(not ok and tostring(err):find("disabled")) +`) + for _, file := range []string{first, second} { + if err := TestDevelopmentFile(context.Background(), dir, file, developmentOptions()); err != nil { + t.Fatal(err) + } + } + if os.Getenv("VFOX_PLUGIN_TEST_SECRET") != "host" { + t.Fatal("host environment changed") + } +} + +func TestDevelopmentCoroutine(t *testing.T) { + dir := t.TempDir() + writePluginFile(t, dir, "main.lua", minimalPlugin+`local http=require("http") +function PLUGIN:Available(ctx) return {{version=assert(http.get({url="https://example.com"})).body}} end`) + file := writePluginFile(t, dir, "tests/coroutine_test.lua", ` +local p=require("vfox.test").load({http=function() return {status_code=200,body="1.2.3"} end}) +local thread=coroutine.create(function() assert(p:Available({})[1].version == "1.2.3") end) +local ok,err=coroutine.resume(thread) +assert(ok,err) +`) + if err := TestDevelopmentFile(context.Background(), dir, file, developmentOptions()); err != nil { + t.Fatal(err) + } +} + +func TestDevelopmentLuaTimeouts(t *testing.T) { + for _, tc := range []struct{ name, source, script string }{ + {"test", "", `require("vfox.test").load(); while true do end`}, + {"load", `while true do end`, `require("vfox.test").load()`}, + {"hook", `function PLUGIN:Available(ctx) while true do end end`, `require("vfox.test").load():Available({})`}, + {"handler", `local http=require("http"); function PLUGIN:Available(ctx) http.get({url="https://example.com"}) end`, `require("vfox.test").load({http=function() while true do end end}):Available({})`}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writePluginFile(t, dir, "main.lua", minimalPlugin+tc.source) + file := writePluginFile(t, dir, "tests/timeout_test.lua", tc.script) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + err := TestDevelopmentFile(ctx, dir, file, developmentOptions()) + if err == nil || !strings.Contains(err.Error(), "deadline exceeded") { + t.Fatalf("timeout error = %v", err) + } + }) + } +} + +func TestDevelopmentHTTPDeadline(t *testing.T) { + for _, method := range []string{"get", "head", "download_file"} { + for _, stallBody := range []bool{false, true} { + if method == "head" && stallBody { + continue + } + t.Run(fmt.Sprintf("%s/body=%t", method, stallBody), func(t *testing.T) { + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if stallBody { + w.WriteHeader(200) + w.(http.Flusher).Flush() + } + <-release + })) + defer server.Close() + defer close(release) + dir := t.TempDir() + writePluginFile(t, dir, "main.lua", minimalPlugin+fmt.Sprintf(` +local http=require("http") +function PLUGIN:Available(ctx) + local r,e=http.%s({url=%q}, RUNTIME.pluginDirPath.."/download") + error(e or r or "unexpected success") +end`, method, server.URL)) + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + options := developmentOptions() + options.Online = true + start := time.Now() + _, err := RunDevelopmentHook(ctx, dir, "Available", nil, options) + if err == nil || !strings.Contains(err.Error(), "deadline exceeded") { + t.Fatalf("timeout error = %v", err) + } + if time.Since(start) > 3*time.Second { + t.Fatal("HTTP did not respect Lua deadline") + } + }) + } + } +} + +func TestDevelopmentRunOutputAndEnvironment(t *testing.T) { + dir := t.TempDir() + t.Setenv("VFOX_PLUGIN_TEST_SECRET", "host") + writePluginFile(t, dir, "main.lua", minimalPlugin+` +print("load-output") +function PLUGIN:PostInstall(ctx) + assert(os.getenv("VFOX_PLUGIN_TEST_SECRET") == "host") + print("print-output") + io.write("write-output\\n") + io.stdout:write("stdout-output\\n") + io.stderr:write("stderr-output\\n") + io.output(io.stdout) + io.output():write("default-output\\n") + assert(io.type(io.output()) == "file") + assert(io.flush()) + assert(io.stdout:setvbuf("no")) + assert(os.execute("echo child-output") == 0) + local old = io.output() + io.output(RUNTIME.pluginDirPath.."/file-output") + io.write("file-content") + assert(io.flush()) + assert(io.close()) + io.output(old) +end`) + var output bytes.Buffer + options := developmentOptions() + options.Output = &output + result, err := RunDevelopmentHook(context.Background(), dir, "PostInstall", []byte(`{}`), options) + if err != nil { + t.Fatal(err) + } + if result != nil { + t.Fatalf("void hook = %v", result) + } + for _, want := range []string{"load-output", "print-output", "write-output", "stdout-output", "stderr-output", "default-output", "child-output"} { + if !strings.Contains(output.String(), want) { + t.Errorf("diagnostics missing %q: %s", want, &output) + } + } + content, err := os.ReadFile(filepath.Join(dir, "file-output")) + if err != nil || string(content) != "file-content" { + t.Fatalf("normal file IO = %q, %v", content, err) + } +} diff --git a/internal/plugin/loading_regression_test.go b/internal/plugin/loading_regression_test.go new file mode 100644 index 00000000..4c842cc1 --- /dev/null +++ b/internal/plugin/loading_regression_test.go @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package plugin + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/version-fox/vfox/internal/config" + "github.com/version-fox/vfox/internal/env" +) + +func writePluginFile(t *testing.T, dir, name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatal(err) + } + return path +} + +const minimalPlugin = `PLUGIN = {name = "example", version = "0.0.1"} +function PLUGIN:Available(ctx) return {{version = "1.0.0"}} end +function PLUGIN:PreInstall(ctx) return {version = ctx.version, url = "example.zip"} end +function PLUGIN:EnvKeys(ctx) return {{key = "PATH", value = ctx.path}} end +` + +func TestLoadingRejectsMalformedPlugin(t *testing.T) { + for _, tc := range []struct{ name, source, want string }{ + {"object", `PLUGIN = 42`, "PLUGIN must be a table"}, + {"metadata", minimalPlugin + `PLUGIN.name = {}`, "name"}, + {"required hook", minimalPlugin + `PLUGIN.Available = true`, "Available"}, + {"optional hook", minimalPlugin + `PLUGIN.PreUse = true`, "PreUse"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writePluginFile(t, dir, "main.lua", tc.source) + p, err := CreatePlugin(dir, &env.RuntimeEnvContext{UserConfig: config.DefaultConfig}) + if p != nil { + defer p.Close() + } + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestPreUseHookRegistration(t *testing.T) { + hook, ok := HookFuncMap["PreUse"] + if !ok || hook.Name != "PreUse" || hook.Filename != "pre_use" { + t.Fatalf("PreUse registration = %+v, found = %t", hook, ok) + } +} + +func TestHookRejectsNonTableResult(t *testing.T) { + dir := t.TempDir() + writePluginFile(t, dir, "main.lua", minimalPlugin+`function PLUGIN:Available(ctx) return "wrong" end`) + p, err := CreatePlugin(dir, &env.RuntimeEnvContext{UserConfig: config.DefaultConfig}) + if err != nil { + t.Fatal(err) + } + defer p.Close() + _, err = p.Available(&AvailableHookCtx{}) + if err == nil || err == ErrNoResultProvide { + t.Fatalf("wrong result type must be a conversion error, got %v", err) + } +} diff --git a/internal/plugin/lua_plugin.go b/internal/plugin/lua_plugin.go index 70a1f234..522fe23d 100644 --- a/internal/plugin/lua_plugin.go +++ b/internal/plugin/lua_plugin.go @@ -19,26 +19,28 @@ package plugin import ( - "errors" "fmt" "path/filepath" + lua "github.com/yuin/gopher-lua" + "github.com/version-fox/vfox/internal/env" "github.com/version-fox/vfox/internal/plugin/luai" "github.com/version-fox/vfox/internal/plugin/luai/codec" "github.com/version-fox/vfox/internal/plugin/luai/module" "github.com/version-fox/vfox/internal/shared/logger" "github.com/version-fox/vfox/internal/shared/util" - lua "github.com/yuin/gopher-lua" ) type LuaPlugin struct { - vm *luai.LuaVM - pluginObj *lua.LTable + vm *luai.LuaVM + pluginObj *lua.LTable + development bool } func (l *LuaPlugin) HasFunction(name string) bool { - return l.pluginObj.RawGetString(name) != lua.LNil + _, ok := l.pluginObj.RawGetString(name).(*lua.LFunction) + return ok } func (l *LuaPlugin) Close() { @@ -62,7 +64,7 @@ func (l *LuaPlugin) Available(ctx *AvailableHookCtx) ([]*AvailableHookResultItem var hookResult []*AvailableHookResultItem err = codec.Unmarshal(table, &hookResult) if err != nil { - return nil, errors.New("failed to unmarshal the return value: " + err.Error()) + return nil, fmt.Errorf("failed to unmarshal the return value: %w", err) } return hookResult, nil @@ -83,7 +85,7 @@ func (l *LuaPlugin) PreInstall(ctx *PreInstallHookCtx) (*PreInstallHookResult, e hookResult := PreInstallHookResult{} err = codec.Unmarshal(table, &hookResult) if err != nil { - return nil, errors.New("failed to unmarshal the return value: " + err.Error()) + return nil, fmt.Errorf("failed to unmarshal the return value: %w", err) } return &hookResult, nil } @@ -98,14 +100,17 @@ func (l *LuaPlugin) EnvKeys(ctx *EnvKeysHookCtx) ([]*EnvKeysHookResultItem, erro if err != nil { return nil, err } - if table == nil || table.Type() == lua.LTNil || table.Len() == 0 { + if table == nil || table.Type() == lua.LTNil { return nil, ErrNoResultProvide } var hookResult []*EnvKeysHookResultItem err = codec.Unmarshal(table, &hookResult) if err != nil { - return nil, errors.New("failed to unmarshal the return value: " + err.Error()) + return nil, fmt.Errorf("failed to unmarshal the return value: %w", err) + } + if len(hookResult) == 0 { + return nil, ErrNoResultProvide } return hookResult, nil } @@ -126,7 +131,7 @@ func (l *LuaPlugin) PreUse(ctx *PreUseHookCtx) (*PreUseHookResult, error) { hookResult := PreUseHookResult{} err = codec.Unmarshal(table, &hookResult) if err != nil { - return nil, errors.New("failed to unmarshal the return value: " + err.Error()) + return nil, fmt.Errorf("failed to unmarshal the return value: %w", err) } return &hookResult, nil } @@ -167,13 +172,15 @@ func (l *LuaPlugin) ParseLegacyFile(ctx *ParseLegacyFileHookCtx) (*ParseLegacyFi hookResult := ParseLegacyFileResult{} err = codec.Unmarshal(table, &hookResult) if err != nil { - return nil, errors.New("failed to unmarshal the return value: " + err.Error()) + return nil, fmt.Errorf("failed to unmarshal the return value: %w", err) } return &hookResult, nil } -func (l *LuaPlugin) CallFunction(funcName string, args ...lua.LValue) (*lua.LTable, error) { - logger.Debugf("CallFunction: %s\n", funcName) +func (l *LuaPlugin) CallFunction(funcName string, args ...lua.LValue) (lua.LValue, error) { + if !l.development { + logger.Debugf("CallFunction: %s\n", funcName) + } table, err := l.vm.CallFunction(l.pluginObj, funcName, args...) @@ -185,9 +192,21 @@ func CreateLuaPlugin(pluginDirPath string, envCtx *env.RuntimeEnvContext) (*LuaP if err := vm.Prepare(&module.PreloadOptions{ Config: envCtx.UserConfig, }); err != nil { + vm.Close() return nil, nil, err } + p, metadata, err := loadLuaPlugin(vm, pluginDirPath, RuntimeInfo{ + OsType: string(util.GetOSType()), ArchType: string(util.GetArchType()), + Version: envCtx.RuntimeVersion, PluginDirPath: pluginDirPath, + }) + if err != nil { + vm.Close() + } + return p, metadata, err +} +// loadLuaPlugin loads into a prepared VM. Its caller owns the VM on failure. +func loadLuaPlugin(vm *luai.LuaVM, pluginDirPath string, runtime RuntimeInfo) (*LuaPlugin, *Metadata, error) { mainPath := filepath.Join(pluginDirPath, "main.lua") // main.lua first if util.FileExists(mainPath) { @@ -219,22 +238,17 @@ func CreateLuaPlugin(pluginDirPath string, envCtx *env.RuntimeEnvContext) (*LuaP continue } if err := vm.Instance.DoFile(hp); err != nil { - return nil, nil, fmt.Errorf("failed to load [%s] hook function: %s", hf.Name, err.Error()) + return nil, nil, fmt.Errorf("failed to load [%s] hook function: %w", hf.Name, err) } } } // !!!! Must be set after loading the script to prevent overwriting! // set OS_TYPE and ARCH_TYPE - vm.Instance.SetGlobal(luai.OsType, lua.LString(util.GetOSType())) - vm.Instance.SetGlobal(luai.ArchType, lua.LString(util.GetArchType())) + vm.Instance.SetGlobal(luai.OsType, lua.LString(runtime.OsType)) + vm.Instance.SetGlobal(luai.ArchType, lua.LString(runtime.ArchType)) - r, err := codec.Marshal(vm.Instance, RuntimeInfo{ - OsType: string(util.GetOSType()), - ArchType: string(util.GetArchType()), - Version: envCtx.RuntimeVersion, - PluginDirPath: pluginDirPath, - }) + r, err := codec.Marshal(vm.Instance, runtime) if err != nil { return nil, nil, err } @@ -244,14 +258,23 @@ func CreateLuaPlugin(pluginDirPath string, envCtx *env.RuntimeEnvContext) (*LuaP if pluginObj.Type() == lua.LTNil { return nil, nil, fmt.Errorf("plugin object not found") } - PLUGIN := pluginObj.(*lua.LTable) + PLUGIN, ok := pluginObj.(*lua.LTable) + if !ok { + return nil, nil, fmt.Errorf("PLUGIN must be a table, got %s", pluginObj.Type()) + } + for _, hook := range HookFuncMap { + value := PLUGIN.RawGetString(hook.Name) + if value != lua.LNil && value.Type() != lua.LTFunction { + return nil, nil, fmt.Errorf("[%s] must be a function, got %s", hook.Name, value.Type()) + } + } pluginInfo := &Metadata{} if err = codec.Unmarshal(PLUGIN, pluginInfo); err != nil { return nil, nil, err } navigator, err := codec.Marshal(vm.Instance, codec.Navigator{ - UserAgent: luai.ComputeUserAgent(envCtx.RuntimeVersion, pluginInfo.Name, pluginInfo.Version), + UserAgent: luai.ComputeUserAgent(runtime.Version, pluginInfo.Name, pluginInfo.Version), }) if err != nil { return nil, nil, err diff --git a/internal/plugin/luai/codec/decode.go b/internal/plugin/luai/codec/decode.go index 70490ffa..ec5ed4c7 100644 --- a/internal/plugin/luai/codec/decode.go +++ b/internal/plugin/luai/codec/decode.go @@ -18,6 +18,7 @@ package codec import ( "errors" + "fmt" "reflect" "strconv" @@ -86,21 +87,46 @@ func indirect(v reflect.Value) reflect.Value { return v } -func storeLiteral(value reflect.Value, lvalue lua.LValue) { +func storeLiteral(value reflect.Value, lvalue lua.LValue) error { value = indirect(value) - + mismatch := func() error { + return fmt.Errorf("cannot unmarshal %s into %s", lvalue.Type(), value.Type()) + } switch value.Kind() { case reflect.String: + // Retain the existing Lua scalar-to-string conversion, including numbers. + if lvalue.Type() != lua.LTString && lvalue.Type() != lua.LTNumber && lvalue.Type() != lua.LTBool { + return mismatch() + } value.SetString(lvalue.String()) case reflect.Bool: - value.SetBool(bool(lvalue.(lua.LBool))) + v, ok := lvalue.(lua.LBool) + if !ok { + return mismatch() + } + value.SetBool(bool(v)) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - value.SetInt(int64(lvalue.(lua.LNumber))) + v, ok := lvalue.(lua.LNumber) + if !ok { + return mismatch() + } + value.SetInt(int64(v)) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - value.SetUint(uint64(lvalue.(lua.LNumber))) + v, ok := lvalue.(lua.LNumber) + if !ok { + return mismatch() + } + value.SetUint(uint64(v)) case reflect.Float32, reflect.Float64: - value.SetFloat(float64(lvalue.(lua.LNumber))) + v, ok := lvalue.(lua.LNumber) + if !ok { + return mismatch() + } + value.SetFloat(float64(v)) + default: + return mismatch() } + return nil } func objectInterface(lvalue *lua.LTable) any { @@ -148,143 +174,121 @@ func arrayInterface(lvalue *lua.LTable) any { } func unmarshalWorker(value lua.LValue, reflected reflect.Value) error { + if !reflected.IsValid() || !reflected.CanSet() { + return errors.New("unmarshal: destination cannot be set") + } + if value == nil || value == lua.LNil { + switch reflected.Kind() { + case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice: + reflected.SetZero() + return nil + default: + return fmt.Errorf("cannot unmarshal nil into %s", reflected.Type()) + } + } reflected = indirect(reflected) + if reflected.Kind() == reflect.Interface && reflected.NumMethod() == 0 { + result := valueInterface(value) + if result == nil { + return fmt.Errorf("cannot unmarshal %s into interface", value.Type()) + } + reflected.Set(reflect.ValueOf(result)) + return nil + } + table, ok := value.(*lua.LTable) + if !ok { + return storeLiteral(reflected, value) + } - switch value.Type() { - case lua.LTTable: - - switch reflected.Kind() { - case reflect.Interface: - // Decoding into nil interface? Switch to non-reflect code. - if reflected.NumMethod() == 0 { - result := valueInterface(value) - reflected.Set(reflect.ValueOf(result)) + var decodeErr error + switch reflected.Kind() { + case reflect.Map: + t := reflected.Type() + keyType := t.Key() + if reflected.IsNil() { + reflected.Set(reflect.MakeMap(t)) + } + table.ForEach(func(key, value lua.LValue) { + if decodeErr != nil { + return } - // map[T1]T2 where T1 is string or an integer type - case reflect.Map: - t := reflected.Type() - keyType := t.Key() - // Map key must either have string kind, have an integer kind + kv := reflect.New(keyType).Elem() switch keyType.Kind() { - case reflect.String, - reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + case reflect.String: + kv.SetString(key.String()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n, err := strconv.ParseInt(key.String(), 10, keyType.Bits()) + if err != nil { + decodeErr = err + return + } + kv.SetInt(n) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + n, err := strconv.ParseUint(key.String(), 10, keyType.Bits()) + if err != nil { + decodeErr = err + return + } + kv.SetUint(n) default: - return errors.New("unmarshal: unsupported map key type " + keyType.String()) + decodeErr = fmt.Errorf("unmarshal: unsupported map key type %s", keyType) + return } - - if reflected.IsNil() { - reflected.Set(reflect.MakeMap(t)) + elem := reflect.New(t.Elem()).Elem() + if err := unmarshalWorker(value, elem); err != nil { + decodeErr = fmt.Errorf("[%s]: %w", key, err) + return } - - var mapElem reflect.Value - - value.(*lua.LTable).ForEach(func(key, value lua.LValue) { - // Figure out field corresponding to key. - var subv reflect.Value - - elemType := t.Elem() - if !mapElem.IsValid() { - mapElem = reflect.New(elemType).Elem() - } else { - mapElem.SetZero() - } - - subv = mapElem - - unmarshalWorker(value, subv) - - var kv reflect.Value - switch keyType.Kind() { - case reflect.String: - kv = reflect.New(keyType).Elem() - kv.SetString(key.String()) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - s := key.String() - n, err := strconv.ParseInt(s, 10, 64) - if err != nil { - break - } - kv = reflect.New(keyType).Elem() - kv.SetInt(n) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - s := key.String() - n, err := strconv.ParseUint(s, 10, 64) - if err != nil { - break - } - kv = reflect.New(keyType).Elem() - kv.SetUint(n) - default: - panic("unmarshal: Unexpected key type") // should never occur - } - if kv.IsValid() { - reflected.SetMapIndex(kv, subv) - } - - }) - case reflect.Slice: - i := 0 - - value.(*lua.LTable).ForEach(func(key, value lua.LValue) { - // Expand slice length, growing the slice if necessary. - if i >= reflected.Cap() { - reflected.Grow(1) - } - if i >= reflected.Len() { - reflected.SetLen(i + 1) - } - if i < reflected.Len() { - // Decode into element. - unmarshalWorker(value, reflected.Index(i)) - } else { - unmarshalWorker(value, reflect.Value{}) - } - i++ - }) - - // Truncate slice if necessary. - if i < reflected.Len() { - reflected.SetLen(i) + reflected.SetMapIndex(kv, elem) + }) + case reflect.Slice: + length := table.Len() + reflected.Set(reflect.MakeSlice(reflected.Type(), length, length)) + table.ForEach(func(key, value lua.LValue) { + if decodeErr != nil { + return } - - if i == 0 { - reflected.Set(reflect.MakeSlice(reflected.Type(), 0, 0)) + n, ok := key.(lua.LNumber) + if !ok || n < 1 || n > lua.LNumber(length) || n != lua.LNumber(int(n)) { + decodeErr = fmt.Errorf("unmarshal: expected array index, got %s", key) + return } - case reflect.Struct: - // Initialize nil embedded pointers - for i := 0; i < reflected.NumField(); i++ { - f := reflected.Type().Field(i) - if f.Anonymous && reflected.Field(i).Kind() == reflect.Ptr && reflected.Field(i).IsNil() { - reflected.Field(i).Set(reflect.New(reflected.Field(i).Type().Elem())) - } + if err := unmarshalWorker(value, reflected.Index(int(n)-1)); err != nil { + decodeErr = fmt.Errorf("[%d]: %w", int(n), err) } - - (value.(*lua.LTable)).ForEach(func(key, value lua.LValue) { - fieldName := key.String() - - field := findField(reflected, fieldName) - - if !field.IsValid() { - return + }) + if decodeErr == nil { + for i := 1; i <= length; i++ { + if table.RawGetInt(i) == lua.LNil { + return fmt.Errorf("unmarshal: missing array item [%d]", i) } - - unmarshalWorker(value, field) - }) + } } - default: - switch reflected.Kind() { - case reflect.Interface: - // Decoding into nil interface? Switch to non-reflect code. - if reflected.NumMethod() == 0 { - result := valueInterface(value) - reflected.Set(reflect.ValueOf(result)) + case reflect.Struct: + // Keep the flat protocol for embedded result and checksum structs. + for i := 0; i < reflected.NumField(); i++ { + f := reflected.Type().Field(i) + field := reflected.Field(i) + if f.Anonymous && field.CanSet() && field.Kind() == reflect.Ptr && field.IsNil() { + field.Set(reflect.New(field.Type().Elem())) } - default: - storeLiteral(reflected, value) } + table.ForEach(func(key, value lua.LValue) { + if decodeErr != nil { + return + } + field := findField(reflected, key.String()) + if !field.IsValid() || !field.CanSet() { + return + } + if err := unmarshalWorker(value, field); err != nil { + decodeErr = fmt.Errorf("%s: %w", key, err) + } + }) + default: + return fmt.Errorf("cannot unmarshal table into %s", reflected.Type()) } - return nil + return decodeErr } // findField finds a field in the struct, including embedded fields recursively @@ -336,5 +340,5 @@ func Unmarshal(value lua.LValue, v any) error { return errors.New("unmarshal: value must be a pointer") } - return unmarshalWorker(value, reflected) + return unmarshalWorker(value, reflected.Elem()) } diff --git a/internal/plugin/luai/codec/decode_test.go b/internal/plugin/luai/codec/decode_test.go index 96c29506..453b0aa8 100644 --- a/internal/plugin/luai/codec/decode_test.go +++ b/internal/plugin/luai/codec/decode_test.go @@ -17,11 +17,51 @@ package codec import ( + "strings" "testing" lua "github.com/yuin/gopher-lua" ) +func TestUnmarshalRejectsNestedTypeErrors(t *testing.T) { + for _, source := range []string{ + `return {items = {{enabled = "yes"}}}`, + `return {items = {{enabled = {}}}}`, + `return {items = "wrong"}`, + `return {items = {"wrong"}}`, + } { + t.Run(source, func(t *testing.T) { + L := lua.NewState() + defer L.Close() + if err := L.DoString(source); err != nil { + t.Fatal(err) + } + var result struct { + Items []struct { + Enabled bool `json:"enabled"` + } `json:"items"` + } + err := Unmarshal(L.Get(-1), &result) + if err == nil || !strings.Contains(err.Error(), "items") { + t.Fatalf("error = %v, want field context", err) + } + }) + } +} + +func TestMarshalFlattensEmbeddedFields(t *testing.T) { + L := lua.NewState() + defer L.Close() + value, err := Marshal(L, OuterPtr{EmbeddedPtr: &EmbeddedPtr{D: "value", E: 42}, F: "outer"}) + if err != nil { + t.Fatal(err) + } + table := value.(*lua.LTable) + if table.RawGetString("D") != lua.LString("value") || table.RawGetString("E") != lua.LNumber(42) { + t.Fatal("embedded fields must retain the flat hook protocol") + } +} + type Embedded struct { A string B int diff --git a/internal/plugin/luai/codec/encode.go b/internal/plugin/luai/codec/encode.go index 8c427e2f..dc5ee565 100644 --- a/internal/plugin/luai/codec/encode.go +++ b/internal/plugin/luai/codec/encode.go @@ -57,6 +57,12 @@ func Marshal(state *lua.LState, v any) (lua.LValue, error) { if err != nil { return nil, err } + if fieldType.Anonymous && fieldType.Tag.Get("json") == "" && field.Kind() == reflect.Struct { + sub.(*lua.LTable).ForEach(func(key, value lua.LValue) { + table.RawSet(key, value) + }) + continue + } if lf, ok := sub.(*lua.LFunction); ok { state.SetField(table, tag, lf) } else { diff --git a/internal/plugin/luai/module/http/http.go b/internal/plugin/luai/module/http/http.go index 65dacd6f..250166f3 100644 --- a/internal/plugin/luai/module/http/http.go +++ b/internal/plugin/luai/module/http/http.go @@ -17,6 +17,7 @@ package http import ( + "context" "fmt" "io" "net" @@ -27,15 +28,41 @@ import ( "time" "github.com/schollz/progressbar/v3" + lua "github.com/yuin/gopher-lua" + "github.com/version-fox/vfox/internal/config" "github.com/version-fox/vfox/internal/plugin/luai/codec" - lua "github.com/yuin/gopher-lua" ) type Module struct { proxy *config.Proxy client *http.Client downloadClient *http.Client + output io.Writer +} + +// Options customize a single VM's HTTP module without replacing its Lua API. +type Options struct { + // WrapTransport runs synchronously on the invoking Lua thread. + WrapTransport func(http.RoundTripper) http.RoundTripper + Output io.Writer +} + +type requestStateKey struct{} + +// RequestState identifies the invoking Lua thread for synchronous transports, +// including requests made inside a coroutine. +func RequestState(req *http.Request) *lua.LState { + state, _ := req.Context().Value(requestStateKey{}).(*lua.LState) + return state +} + +func requestContext(L *lua.LState) context.Context { + ctx := L.Context() + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, requestStateKey{}, L) } // Get performs a http get request @@ -63,7 +90,7 @@ func (m *Module) Get(L *lua.LState) int { return 2 } - req, err := http.NewRequest("GET", urlStr.String(), nil) + req, err := http.NewRequestWithContext(requestContext(L), "GET", urlStr.String(), nil) if err != nil { L.Push(lua.LNil) L.Push(lua.LString(err.Error())) @@ -117,7 +144,7 @@ func (m *Module) Head(L *lua.LState) int { return 2 } - req, err := http.NewRequest("HEAD", urlStr.String(), nil) + req, err := http.NewRequestWithContext(requestContext(L), "HEAD", urlStr.String(), nil) if err != nil { L.Push(lua.LNil) L.Push(lua.LString(err.Error())) @@ -178,7 +205,7 @@ func (m *Module) DownloadFile(L *lua.LState) int { return 1 } - req, err := http.NewRequest("GET", urlStr.String(), nil) + req, err := http.NewRequestWithContext(requestContext(L), "GET", urlStr.String(), nil) if err != nil { L.Push(lua.LString(err.Error())) return 1 @@ -216,12 +243,12 @@ func (m *Module) DownloadFile(L *lua.LState) int { bar := progressbar.NewOptions64( resp.ContentLength, - progressbar.OptionSetWriter(os.Stderr), + progressbar.OptionSetWriter(m.output), progressbar.OptionEnableColorCodes(true), progressbar.OptionShowBytes(true), progressbar.OptionFullWidth(), progressbar.OptionOnCompletion(func() { - fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintln(m.output) }), progressbar.OptionSetDescription(desc), progressbar.OptionSetTheme(progressbar.Theme{ @@ -259,12 +286,13 @@ func newModule(proxy *config.Proxy, settings *config.HTTP) *Module { KeepAlive: 30 * time.Second, }).DialContext transport.ResponseHeaderTimeout = requestTimeout - if proxy.Enable { + if proxy != nil && proxy.Enable { if uri, err := url.Parse(proxy.Url); err == nil { transport.Proxy = http.ProxyURL(uri) } } return &Module{ + output: os.Stderr, proxy: proxy, client: &http.Client{Transport: transport, Timeout: requestTimeout}, // Downloads need a larger total budget, while retaining the connection @@ -273,9 +301,17 @@ func newModule(proxy *config.Proxy, settings *config.HTTP) *Module { } } -func createModule(proxy *config.Proxy, settings *config.HTTP) lua.LGFunction { +func createModule(proxy *config.Proxy, settings *config.HTTP, options Options) lua.LGFunction { return func(L *lua.LState) int { m := newModule(proxy, settings) + if options.WrapTransport != nil { + transport := options.WrapTransport(m.client.Transport) + m.client.Transport = transport + m.downloadClient.Transport = transport + } + if options.Output != nil { + m.output = options.Output + } t := L.NewTable() L.SetFuncs(t, m.luaMap()) L.Push(t) @@ -297,5 +333,9 @@ func (m *Module) ensureUserAgent(L *lua.LState, req *http.Request) { } func Preload(L *lua.LState, proxy *config.Proxy, settings *config.HTTP) { - L.PreloadModule("http", createModule(proxy, settings)) + PreloadWithOptions(L, proxy, settings, Options{}) +} + +func PreloadWithOptions(L *lua.LState, proxy *config.Proxy, settings *config.HTTP, options Options) { + L.PreloadModule("http", createModule(proxy, settings, options)) } diff --git a/internal/plugin/luai/module/module.go b/internal/plugin/luai/module/module.go index 0d289a54..93fd344b 100644 --- a/internal/plugin/luai/module/module.go +++ b/internal/plugin/luai/module/module.go @@ -17,6 +17,11 @@ package module import ( + "io" + nethttp "net/http" + + lua "github.com/yuin/gopher-lua" + "github.com/version-fox/vfox/internal/config" "github.com/version-fox/vfox/internal/plugin/luai/module/archiver" "github.com/version-fox/vfox/internal/plugin/luai/module/fs" @@ -24,15 +29,23 @@ import ( "github.com/version-fox/vfox/internal/plugin/luai/module/http" "github.com/version-fox/vfox/internal/plugin/luai/module/json" "github.com/version-fox/vfox/internal/plugin/luai/module/string" - lua "github.com/yuin/gopher-lua" ) type PreloadOptions struct { - Config *config.Config + Config *config.Config + HTTPTransport func(nethttp.RoundTripper) nethttp.RoundTripper + Output io.Writer } func Preload(L *lua.LState, options *PreloadOptions) { - http.Preload(L, options.Config.Proxy, options.Config.Plugin.HTTP) + cfg := options.Config + if cfg == nil { + cfg = config.DefaultConfig + } + http.PreloadWithOptions(L, cfg.Proxy, cfg.Plugin.HTTP, http.Options{ + WrapTransport: options.HTTPTransport, + Output: options.Output, + }) json.Preload(L) html.Preload(L) string.Preload(L) diff --git a/internal/plugin/luai/vm.go b/internal/plugin/luai/vm.go index 31dcdba4..f1a99bd0 100644 --- a/internal/plugin/luai/vm.go +++ b/internal/plugin/luai/vm.go @@ -18,10 +18,12 @@ package luai import ( _ "embed" + "fmt" "strings" - "github.com/version-fox/vfox/internal/plugin/luai/module" lua "github.com/yuin/gopher-lua" + + "github.com/version-fox/vfox/internal/plugin/luai/module" ) //go:embed fixtures/preload.lua @@ -63,8 +65,12 @@ func (vm *LuaVM) ReturnedValue() *lua.LTable { return table } -func (vm *LuaVM) CallFunction(pluginObj *lua.LTable, funcName string, _args ...lua.LValue) (*lua.LTable, error) { +func (vm *LuaVM) CallFunction(pluginObj *lua.LTable, funcName string, _args ...lua.LValue) (lua.LValue, error) { function := pluginObj.RawGetString(funcName) + fn, ok := function.(*lua.LFunction) + if !ok { + return nil, fmt.Errorf("[%s] must be a function, got %s", funcName, function.Type()) + } // In Lua, when a function is called with colon syntax (object:method()), // the object itself is implicitly passed as the first argument. @@ -73,14 +79,16 @@ func (vm *LuaVM) CallFunction(pluginObj *lua.LTable, funcName string, _args ...l args := append([]lua.LValue{pluginObj}, _args...) if err := vm.Instance.CallByParam(lua.P{ - Fn: function.(*lua.LFunction), + Fn: fn, NRet: 1, Protect: true, }, args...); err != nil { return nil, err } - return vm.ReturnedValue(), nil + value := vm.Instance.Get(-1) + vm.Instance.Pop(1) + return value, nil } func (vm *LuaVM) Close() { diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index e1bcff29..60bae0a2 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -69,7 +69,7 @@ var ( "PreInstall": {Name: "PreInstall", Required: true, Filename: "pre_install"}, "EnvKeys": {Name: "EnvKeys", Required: true, Filename: "env_keys"}, "PostInstall": {Name: "PostInstall", Required: false, Filename: "post_install"}, - "preUse": {Name: "preUse", Required: false, Filename: "pre_use"}, + "PreUse": {Name: "PreUse", Required: false, Filename: "pre_use"}, "ParseLegacyFile": {Name: "ParseLegacyFile", Required: false, Filename: "parse_legacy_file"}, "PreUninstall": {Name: "PreUninstall", Required: false, Filename: "pre_uninstall"}, } @@ -95,6 +95,7 @@ func CreatePlugin(tempInstallPath string, runtimeEnvCtx *env.RuntimeEnvContext) } if err = luaPlugin.validate(); err != nil { + luaPlugin.Close() return nil, err } diff --git a/internal/plugin_development.go b/internal/plugin_development.go new file mode 100644 index 00000000..f39be48a --- /dev/null +++ b/internal/plugin_development.go @@ -0,0 +1,165 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package internal + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/version-fox/vfox/internal/plugin" +) + +type PluginRunOptions struct { + OS, Arch string + Offline bool + Timeout time.Duration + Diagnostics io.Writer +} + +type PluginTestOptions struct { + File string + Online bool + Timeout time.Duration + Output, Diagnostics io.Writer +} + +func pluginDevelopmentDirectory(directory string) (string, error) { + path, err := filepath.Abs(directory) + if err != nil { + return "", err + } + info, err := os.Stat(path) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("plugin path must be a directory: %s", path) + } + return path, nil +} + +// RunPluginHook deliberately bypasses NewSdkManager and all user/project state. +func RunPluginHook(ctx context.Context, directory, hook string, input []byte, options PluginRunOptions) (any, error) { + directory, err := pluginDevelopmentDirectory(directory) + if err != nil { + return nil, err + } + if options.Timeout == 0 { + options.Timeout = 60 * time.Second + } + if options.Timeout < 0 { + return nil, fmt.Errorf("timeout must be positive") + } + ctx, cancel := context.WithTimeout(ctx, options.Timeout) + defer cancel() + return plugin.RunDevelopmentHook(ctx, directory, hook, input, plugin.DevelopmentOptions{ + RuntimeVersion: RuntimeVersion, OS: options.OS, Arch: options.Arch, + Online: !options.Offline, Output: options.Diagnostics, + }) +} + +// TestPlugin discovers files and reports each result immediately. Each file gets +// a fresh VM and deadline, including when a previous file failed or timed out. +func TestPlugin(ctx context.Context, directory string, options PluginTestOptions) error { + directory, err := pluginDevelopmentDirectory(directory) + if err != nil { + return err + } + if options.Timeout == 0 { + options.Timeout = 30 * time.Second + } + if options.Timeout < 0 { + return fmt.Errorf("timeout must be positive") + } + if options.Output == nil { + options.Output = os.Stdout + } + if options.Diagnostics == nil { + options.Diagnostics = os.Stderr + } + var files []string + if options.File != "" { + file := options.File + if !filepath.IsAbs(file) { + file = filepath.Join(directory, file) + } + info, err := os.Stat(file) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("test path must be a file: %s", file) + } + files = append(files, file) + } else { + tests := filepath.Join(directory, "tests") + err := filepath.WalkDir(tests, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type().IsRegular() && strings.HasSuffix(entry.Name(), "_test.lua") { + files = append(files, path) + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + return err + } + } + if len(files) == 0 { + return fmt.Errorf("no test files found in %s", filepath.Join(directory, "tests")) + } + sort.Strings(files) + failed := 0 + for _, file := range files { + if err := ctx.Err(); err != nil { + return err + } + fileCtx, cancel := context.WithTimeout(ctx, options.Timeout) + err := plugin.TestDevelopmentFile(fileCtx, directory, file, plugin.DevelopmentOptions{ + RuntimeVersion: RuntimeVersion, Online: options.Online, Output: options.Diagnostics, + }) + cancel() + label, relErr := filepath.Rel(directory, file) + if relErr != nil { + label = file + } + label = filepath.ToSlash(label) + if err != nil { + failed++ + if _, writeErr := fmt.Fprintf(options.Output, "FAIL %s\n", label); writeErr != nil { + return writeErr + } + if _, writeErr := fmt.Fprintf(options.Diagnostics, "%s: %s\n", label, err); writeErr != nil { + return writeErr + } + } else if _, err := fmt.Fprintf(options.Output, "PASS %s\n", label); err != nil { + return err + } + } + if failed != 0 { + return fmt.Errorf("%d of %d test files failed", failed, len(files)) + } + return nil +}