From e30483671aed1e04acf461a19a236bc6967e3a36 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 14 Nov 2026 19:51:24 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=AE=BF=E4=B8=BB=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF=E6=B5=8B=E8=AF=95=E5=92=8C=E5=8A=9F=E8=83=BD=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=E7=A7=BB=E5=88=B0=E6=8F=92=E4=BB=B6=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主仓库按 1lck/Lithe-IDEA#1915 不再保存插件的测试和功能矩阵条目,这里接收 Go Support 的部分: - e2e/host/ 放 TestOfficialGoPluginBuildsAndServesAModule 和它用到的辅助函数, scripts/e2e-host.mjs 把 lithe-host.json 固定的宿主版本检出到 .artifacts/lithe-host 后运行 - docs/feature-matrix.md 迁移 go-support-plugin 条目,按当前插件重写 - README 说明运行方式,.gitignore 忽略 .artifacts/ --- .gitignore | 1 + README.md | 24 ++ docs/feature-matrix.md | 69 ++++ .../workbench/official_go_plugin_e2e_test.go | 380 ++++++++++++++++++ e2e/host/lithe-host.json | 10 + scripts/e2e-host.mjs | 87 ++++ 6 files changed, 571 insertions(+) create mode 100644 docs/feature-matrix.md create mode 100644 e2e/host/desktop/internal/workbench/official_go_plugin_e2e_test.go create mode 100644 e2e/host/lithe-host.json create mode 100644 scripts/e2e-host.mjs diff --git a/.gitignore b/.gitignore index 8fe2c37..f15e086 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ .cache/ .DS_Store +.artifacts/ diff --git a/README.md b/README.md index 792c222..3c76bd5 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,30 @@ node scripts/package.mjs [构建工具接入指南](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/plugins/guides/build-tool-integration.md) 和[语言服务器插件生命周期](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/plugins/guides/lsp-plugin-lifecycle.md)。 +## 宿主端到端测试 + +`e2e/host/` 里的测试通过 Lithe 桌面端自己的测试工具(宿主的 `desktop/internal/workbench` 包)安装真实插件包, +像用户一样操作构建窗口和编辑器。这些测试原来放在 Lithe 主仓库,按“宿主不依赖插件”的要求 +([1lck/Lithe-IDEA#1915](https://github.com/1lck/Lithe-IDEA/pull/1915))移到这里:主仓库不读取本仓库, +`e2e/host/lithe-host.json` 记录测试最后一次通过时的宿主版本。 + +需要 Node.js 22+、Go、Rust 工具链(构建宿主固定的 Rust Core)、`git`,以及测试要用的 `go` 和 `gopls`: + +```bash +node scripts/package.mjs +LITHE_TEST_GO_PLUGIN_ARCHIVE="$PWD/dist/go-support-0.0.1-darwin.zip" node scripts/e2e-host.mjs +``` + +插件包路径要写绝对路径。脚本把固定版本的宿主(只取 `desktop/` 和 `scripts/`)检出到 `.artifacts/lithe-host`, +复制 `e2e/host/desktop/` 下的测试,再在那里运行宿主的 `scripts/desktop/desktop-go.sh test`;第一次运行会克隆并构建 +Rust Core,`LITHE_CORE_RUST_DIR` 可以指向本地的 Core 检出。`--ref <分支或 commit>` 换一个宿主版本试跑, +`--run <正则>` 只跑部分测试。测试用的是宿主内部的测试工具,升级 `lithe-host.json` 的 `revision` 时可能要同步修改测试。 + +## 功能矩阵 + +插件各项能力在 macOS 和 Windows 上的实现与验收状态见 [docs/feature-matrix.md](docs/feature-matrix.md)。 +Lithe 主仓库的功能矩阵不收录插件能力,改动插件行为时在同一个 PR 里更新这一页。 + ## 发布新版本 1. 按语义化版本修改 `io.github.lithe-idea.go-support/plugin.json` 的 `version`:只修 Bug 升修订号, diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md new file mode 100644 index 0000000..ab90753 --- /dev/null +++ b/docs/feature-matrix.md @@ -0,0 +1,69 @@ +# 功能矩阵 + +本页记录 Go Support 插件自己的跨平台功能状态。Lithe 主仓库的功能矩阵(`shared/platform-feature-matrix/`) +只记录宿主能力,不再收录插件能力([1lck/Lithe-IDEA#1915](https://github.com/1lck/Lithe-IDEA/pull/1915))。 +插件能力的状态、证据和验证方法在这里维护,改动插件行为的 PR 同时更新本页。 + +## 状态取值 + +沿用主仓库功能矩阵的取值: + +| 字段 | 取值 | +| --- | --- | +| 实现 | `implemented` 已实现;`partial` 部分实现;`missing` 未实现;`not-applicable` 该平台不适用 | +| 验证 | `verified` 已按“验证方法”完整验收;`pending` 尚未完整验收;`not-applicable` 无需验收 | + +只有按“验证方法”完整走过一遍,并写明日期、插件版本和宿主版本,才能标 `verified`。 +自动化测试的结果写在“最近验证”里,不能代替验收。 + +## 总览 + +| ID | 能力 | macOS | Windows | 最近验证 | +| --- | --- | --- | --- | --- | +| `go-support-plugin` | gopls 代码智能、Go Modules 构建、运行与 `go test` | implemented / pending | missing / not-applicable | 2026-11-14 macOS 宿主端到端测试通过 | + +## go-support-plugin + +**能力**:用用户自己的 `gopls` 提供代码智能;用声明式 `buildSystems` 识别 Go 模块,在构建窗口里列出包, +运行 `go build`、`go vet`、`go test` 和 `go run`,列出模块依赖;插件设置调整 `go` 路径和 gopls 选项。 + +| 平台 | 实现 | 验证 | 说明 | +| --- | --- | --- | --- | +| macOS | implemented | pending | 发布包同时支持 Apple Silicon 和 Intel Mac | +| Windows | missing | not-applicable | 没有 Windows 包:`scripts/package.mjs` 只打 `darwin` 包 | + +**证据**: + +- `io.github.lithe-idea.go-support/plugin.json`:`languageServers`、`buildSystems`、`settings` 的声明。 +- `e2e/host/desktop/internal/workbench/official_go_plugin_e2e_test.go`:宿主端到端测试。 + +**依赖的宿主能力**:`languageServers`(宿主启动 gopls 并转发 `workspace/configuration`)、`buildSystems` +(构建窗口识别实例、读取模型、运行命令任务和运行入口、运行测试)、`settings`(插件设置页和设置生效方式)。 +宿主 [1lck/Lithe-IDEA@059d878d4](https://github.com/1lck/Lithe-IDEA/blob/059d878d4284e1a1fd218aeaa56c29d9753d4dfa/desktop/internal/pluginhost/pluginhost.go) +的 `pluginhost.go` 开头列出了这些能力的服务范围;语言服务器的 code action、signature help 和 inlay hints +宿主还没有界面,插件声明了也不会显示。 + +**最近验证**:2026-11-14,macOS(Apple Silicon),插件 0.0.1 发布包,宿主 `059d878d4`。 +`node scripts/e2e-host.mjs` 通过 `TestOfficialGoPluginBuildsAndServesAModule`,覆盖识别 `go.mod`、`go list` 模型和 +`application` 目标、Build、Vet、运行入口、Go executable 设置重新读取模型并用于后续构建、gopls 诊断、 +gofumpt 设置立即生效,以及工作流结束后安装目录逐字节不变。没有覆盖 `go test`、依赖列表、`go.mod` 变化后重新读取、 +构建选项(Build Tags、Race Detector)、缺少 gopls 时的提示,也没有做下面的手工验收。 + +**验证方法**(macOS): + +1. 用“设置 → 插件 → 从文件安装…”安装 `dist/go-support-<版本>-darwin.zip`,启用后不需要重启。 +2. 打开含 `go.mod` 的项目:构建窗口出现 Go Modules,列出模块和包,每个 `main` 包有“运行 <包名>”按钮。 +3. 运行 Build、Vet、Test(整个模块、单个包、用 `-run` 只跑一个测试)和运行入口,输出显示在构建输出里; + `文件:行:列: 消息` 形式的编译错误显示为问题。 +4. 打开 `.go` 文件:gopls 来自用户的 `PATH`,补全、跳转定义、悬停、诊断和格式化可用;修改 gofumpt、 + Staticcheck、补全占位符、悬停文档设置后立即生效,不需要重启语言服务器。 +5. 修改 Go executable 设置后模块重新读取,之后的构建使用新路径;Build Tags 和 Race Detector 构建选项出现在命令里。 +6. 查看依赖列表(含版本和间接依赖);修改 `go.mod` 后模块重新读取。 +7. `PATH` 里没有 gopls 时给出缺失提示,不崩溃。 +8. 禁用插件后 gopls 和运行中的进程退出;插件安装目录在整个过程中不被写入。 + +**迁移来源**:主仓库 `shared/platform-feature-matrix/features/go-support-plugin.json` +([1lck/Lithe-IDEA@b582e206a](https://github.com/1lck/Lithe-IDEA/blob/b582e206a/shared/platform-feature-matrix/features/go-support-plugin.json)), +在 #1915 中删除。原条目写的是旧 Swift 宿主上官方包 `dev.lithe.plugin.go-support` 迁移到社区版 0.1.0 的流程, +以及 Viva 宿主当时还没有运行和测试入口的状态。插件已按契约 v0 重写并从 0.0.1 重新发布,宿主也已经支持 +`buildSystems` 的运行和测试,这两部分不再适用,本条按当前插件重写。 diff --git a/e2e/host/desktop/internal/workbench/official_go_plugin_e2e_test.go b/e2e/host/desktop/internal/workbench/official_go_plugin_e2e_test.go new file mode 100644 index 0000000..781d962 --- /dev/null +++ b/e2e/host/desktop/internal/workbench/official_go_plugin_e2e_test.go @@ -0,0 +1,380 @@ +package workbench + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/1lck/Lithe-IDEA/desktop/internal/core" + "github.com/1lck/Lithe-IDEA/desktop/internal/plugininstall" + "github.com/1lck/Lithe-IDEA/desktop/internal/pluginservice" + "github.com/1lck/Lithe-IDEA/desktop/internal/pluginsettings" + "github.com/1lck/Lithe-IDEA/desktop/internal/plugintrust" + "github.com/vivaui/viva/app" +) + +// This opt-in integration test installs the released Go Support archive through the real +// installer, enables it and drives the workbench as a user does: detection, the model the +// plugin's command prints, a real build, vet and run, the Go executable setting, live gopls +// diagnostics and gofumpt formatting. It runs the user's own go and gopls, so it skips unless an +// archive is named, and fails rather than skips once a named archive cannot do its work. + +// installOfficialPluginArchive installs and enables archive in an isolated +// plugin root and returns the installed package directory. +func installOfficialPluginArchive(t *testing.T, h *harness, archive, id string) string { + t.Helper() + service, err := pluginservice.Open(core.NewClient(), h.store, t.TempDir()) + if err != nil { + t.Fatal(err) + } + h.app.plugins = service + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + plan, err := service.Installer.Prepare(ctx, plugininstall.Request{ArchivePath: archive, Root: service.Root, Source: plugintrust.Disk}) + if err != nil { + t.Fatalf("prepare %s: %v", archive, err) + } + if plan.ID != id { + _ = service.Installer.Discard(plan) + t.Fatalf("archive holds %s, want %s", plan.ID, id) + } + installed, err := service.Installer.Commit(plan) + if err != nil { + _ = service.Installer.Discard(plan) + t.Fatalf("install %s: %v", id, err) + } + if err := service.Manager.SetEnabled(id, true); err != nil { + t.Fatal(err) + } + return installed.Path +} + +// packageDigest hashes every file of an installed package, so a test can prove a +// workflow left the package byte-for-byte as installed. +func packageDigest(t *testing.T, root string) map[string]string { + t.Helper() + digest := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + file, err := os.Open(path) + if err != nil { + return err + } + // JDK module images are large; stream the unchanged-package proof so + // test buffers do not inflate the headless host's memory observations. + sum := sha256.New() + _, readErr := io.Copy(sum, file) + closeErr := file.Close() + if readErr != nil { + return readErr + } + if closeErr != nil { + return closeErr + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + digest[relative] = hex.EncodeToString(sum.Sum(nil)) + return nil + }) + if err != nil { + t.Fatal(err) + } + return digest +} + +func assertPackageUnchanged(t *testing.T, root string, before map[string]string) { + t.Helper() + after := packageDigest(t, root) + if len(after) != len(before) { + t.Fatalf("the installed package now has %d files, it had %d", len(after), len(before)) + } + for name, sum := range before { + if after[name] != sum { + t.Fatalf("the workflow changed the installed package file %s", name) + } + } +} + +// writePluginFixture writes files (relative path to content) below root. +func writePluginFixture(t *testing.T, root string, files map[string]string) { + t.Helper() + for name, content := range files { + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } +} + +// awaitBuildModel waits until detection found an instance of buildSystemID and its +// model is loaded, failing with the model error when the plugin's command failed. +func awaitBuildModel(t *testing.T, h *harness, p *project, buildSystemID string, limit time.Duration) *buildInstance { + t.Helper() + var instance *buildInstance + awaitStateWithin(t, h, limit, buildSystemID+" detection and model", func() bool { + for index := range p.build.instances { + candidate := &p.build.instances[index] + if candidate.BuildSystemID != buildSystemID { + continue + } + if candidate.modelError != "" { + t.Fatalf("the %s model failed: %s", buildSystemID, candidate.modelError) + } + if candidate.modelLoaded { + p.build.selected = index + instance = candidate + return true + } + } + if p.build.failed != "" { + t.Fatalf("build detection failed: %s", p.build.failed) + } + return false + }) + return instance +} + +// runBuildTaskToEnd runs one task of the selected instance and waits for it to end. +func runBuildTaskToEnd(t *testing.T, h *harness, p *project, projectID, taskID string, limit time.Duration) string { + t.Helper() + p.build.projectID, p.build.targetID, p.build.taskID = projectID, "", taskID + if _, err := h.app.runBuildTask(p, projectID, "", taskID, nil); err != nil { + t.Fatalf("start %s: %v", taskID, err) + } + awaitStateWithin(t, h, limit, "the "+taskID+" task", func() bool { return p.build.operation == nil }) + output := strings.Join(p.build.output, "") + if p.build.status != h.app.text.Build.Succeeded { + t.Fatalf("%s did not succeed: status=%q output=%q", taskID, p.build.status, output) + } + return output +} + +// runTargetEntryToEnd runs the target's only command Run entry, as its Run button in the +// Build window does, waits for the program to exit and returns the window output. +func runTargetEntryToEnd(t *testing.T, h *harness, p *project, instance *buildInstance, targetID string, limit time.Duration) string { + t.Helper() + entries := buildTargetRunEntries(instance, targetID) + if len(entries) != 1 { + t.Fatalf("the target has %d Run entries, want 1: %+v", len(entries), instance.Model.Run) + } + entry := entries[0] + h.app.runBuildEntry(p, entry.ID) + if p.build.operation == nil { + t.Fatalf("run %s did not start", entry.Name) + } + awaitStateWithin(t, h, limit, "the "+entry.Name+" run", func() bool { return p.build.operation == nil }) + output := strings.Join(p.build.output, "") + if p.build.status != h.app.text.Build.Succeeded { + t.Fatalf("run %s did not succeed: status=%q output=%q", entry.Name, p.build.status, output) + } + return output +} + +func modelProjectNamed(model core.BuildModel, name string) *core.BuildProject { + for index := range model.Projects { + if model.Projects[index].Name == name { + return &model.Projects[index] + } + } + return nil +} + +// markingWrapper writes an executable script that records each start in a marker file and +// then runs the named tool found on PATH, so a test can tell that a configured path ran. +func markingWrapper(t *testing.T, name string) (wrapper, marker string) { + t.Helper() + target, err := exec.LookPath(name) + if err != nil { + t.Fatalf("%s is not on PATH: %v", name, err) + } + dir := t.TempDir() + marker = filepath.Join(dir, name+".started") + wrapper = filepath.Join(dir, name) + script := "#!/bin/sh\n: > '" + marker + "'\nexec '" + target + "' \"$@\"\n" + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + return wrapper, marker +} + +// saveGlobalPluginSettings saves values to the plugin's global settings layer the way the +// settings window does, then dispatches the effects Core answered for the change. +func saveGlobalPluginSettings(t *testing.T, h *harness, instance *buildInstance, values map[string]any) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + store := pluginsettings.New(h.app.kernel, h.store.Dir()) + snapshot, err := store.Load(ctx, instance.Manifest, h.app.project.root) + if err != nil { + t.Fatalf("load the plugin settings: %v", err) + } + ids := make([]string, 0, len(values)) + for id := range values { + ids = append(ids, id) + } + sort.Strings(ids) + edits := make([]core.PluginSettingEdit, 0, len(ids)) + for _, id := range ids { + value, err := json.Marshal(values[id]) + if err != nil { + t.Fatal(err) + } + edits = append(edits, core.PluginSettingEdit{ID: id, Value: value}) + } + result, err := store.Save(ctx, snapshot, "global", edits, nil) + if err != nil || !result.Committed { + t.Fatalf("save the plugin settings: committed=%v err=%v", result.Committed, err) + } + h.app.applyPluginSettingsEffects(core.PluginSettingsOwner{PluginID: instance.PluginID, Version: instance.Version}, result.Effects) +} + +// TestOfficialGoPluginBuildsAndServesAModule covers the Go Support archive: the +// Go Modules build system detects go.mod, `go list -json` becomes the model with +// the main package as an application target, `go build` and `go vet` succeed, +// the target's Run entry runs the program, and an edit in main.go reaches the +// user's gopls, whose diagnostic is stored. +func TestOfficialGoPluginBuildsAndServesAModule(t *testing.T) { + archive := os.Getenv("LITHE_TEST_GO_PLUGIN_ARCHIVE") + if archive == "" { + t.Skip("set LITHE_TEST_GO_PLUGIN_ARCHIVE to a go-support--darwin.zip") + } + for _, command := range []string{"go", "gopls"} { + if _, err := exec.LookPath(command); err != nil { + t.Fatalf("%s is not on PATH: %v", command, err) + } + } + h := newHarness(t, &testKernel{Client: core.NewClient()}) + installed := installOfficialPluginArchive(t, h, archive, "io.github.lithe-idea.go-support") + before := packageDigest(t, installed) + h.app.reloadPluginLanguages() + root := t.TempDir() + writePluginFixture(t, root, map[string]string{ + "go.mod": "module example.com/hello\n\ngo 1.21\n", + "main.go": "package main\n\nfunc main() {\n\tprintln(\"hello from lithe\")\n}\n", + }) + h.open(root) + p := h.app.project + if p == nil { + t.Fatal("the module did not open") + } + h.app.loadBuildSystems(p) + instance := awaitBuildModel(t, h, p, "go", time.Minute) + if modelProjectNamed(instance.Model, "example.com/hello") == nil { + t.Fatalf("the go list model has no example.com/hello module: %+v", instance.Model.Projects) + } + application := "" + for _, target := range instance.Model.Targets { + for _, tag := range target.Tags { + if tag == "application" { + application = target.ID + } + } + } + if application == "" { + t.Fatalf("the main package is not an application target: %+v", instance.Model.Targets) + } + // Both Go tasks run on a module, so they need the module selected as in the Build window. + module := modelProjectNamed(instance.Model, "example.com/hello") + runBuildTaskToEnd(t, h, p, module.ID, "build", 2*time.Minute) + runBuildTaskToEnd(t, h, p, module.ID, "vet", 2*time.Minute) + // The main package's Run entry is a command run: `go run` builds and starts it. + if output := runTargetEntryToEnd(t, h, p, instance, application, 2*time.Minute); !strings.Contains(output, "hello from lithe") { + t.Fatalf("go run did not print the program's output: %q", output) + } + + // The Go executable setting reloads the model, and later builds run the chosen go. + goWrapper, goMarker := markingWrapper(t, "go") + saveGlobalPluginSettings(t, h, instance, map[string]any{"goExecutable": goWrapper}) + instance = awaitBuildModel(t, h, p, "go", time.Minute) + if _, err := os.Stat(goMarker); err != nil { + t.Fatalf("the model reload did not run the configured go: %v", err) + } + if err := os.Remove(goMarker); err != nil { + t.Fatal(err) + } + module = modelProjectNamed(instance.Model, "example.com/hello") + runBuildTaskToEnd(t, h, p, module.ID, "build", 2*time.Minute) + if _, err := os.Stat(goMarker); err != nil { + t.Fatalf("the build did not run the configured go: %v", err) + } + + path := filepath.Join(root, "main.go") + h.app.openFileAt(p, path, 1) + var d *document + awaitStateWithin(t, h, time.Minute, "gopls to open main.go", func() bool { + d = p.docs.byPath(path) + return d != nil && d.sync.opened + }) + if !d.view.GoToNavigation(2, len("func main() {"), false) { + t.Fatal("could not place the caret in main") + } + input := d.view.TextInput(app.TextInputContext{ID: d.hit}) + if _, err := input.OnEvent(app.TextEvent{Kind: app.TextInsert, Text: "\n\tmissingCall()"}); err != nil { + t.Fatal(err) + } + awaitStateWithin(t, h, time.Minute, "a gopls diagnostic after the edit", func() bool { + return problemMessagesContain(h.app, "missingCall") + }) + t.Logf("gopls diagnostics: %q", problemMessages(h.app)) + if diagnosticsForFile(h.app, path) == 0 { + t.Fatal("gopls's diagnostic was not stored for main.go") + } + + // gopls options take effect at once: gofumpt reaches the running server through + // workspace/configuration, and its formatting drops the blank line that opens main's + // body, which gofmt keeps. The change arrives asynchronously, so formatting repeats + // until the server formats with it. + if !d.view.GoToNavigation(2, len("func main() {"), false) { + t.Fatal("could not place the caret in main") + } + if _, err := d.view.TextInput(app.TextInputContext{ID: d.hit}).OnEvent(app.TextEvent{Kind: app.TextInsert, Text: "\n"}); err != nil { + t.Fatal(err) + } + saveGlobalPluginSettings(t, h, instance, map[string]any{"gofumpt": true}) + gofumpted := func() bool { + text, _ := d.view.Text() + return strings.Contains(string(text), "func main() {\n\tmissingCall()") + } + if gofumpted() { + t.Fatal("the blank line at the start of main's body was not inserted") + } + deadline := time.NewTimer(time.Minute) + defer deadline.Stop() + retry := time.NewTicker(2 * time.Second) + defer retry.Stop() + h.app.startPluginFormatting(p, d) + for !gofumpted() { + select { + case <-h.driver.Wakes(): + if err := h.driver.Pump(); err != nil { + t.Fatal(err) + } + case <-retry.C: + h.app.startPluginFormatting(p, d) + case <-deadline.C: + text, _ := d.view.Text() + t.Fatalf("gopls did not format with gofumpt: %q", text) + } + } + h.app.closeProject() + awaitStateWithin(t, h, 30*time.Second, "the closed project", func() bool { return h.app.project == nil }) + h.settle() + assertPackageUnchanged(t, installed, before) +} diff --git a/e2e/host/lithe-host.json b/e2e/host/lithe-host.json new file mode 100644 index 0000000..7b8d487 --- /dev/null +++ b/e2e/host/lithe-host.json @@ -0,0 +1,10 @@ +{ + "repository": "https://github.com/1lck/Lithe-IDEA.git", + "revision": "059d878d4284e1a1fd218aeaa56c29d9753d4dfa", + "packages": { + "./internal/workbench": "^TestOfficialGoPluginBuildsAndServesAModule$" + }, + "requiredEnvironment": { + "LITHE_TEST_GO_PLUGIN_ARCHIVE": "go-support--darwin.zip to install, for example dist/go-support-0.0.1-darwin.zip" + } +} diff --git a/scripts/e2e-host.mjs b/scripts/e2e-host.mjs new file mode 100644 index 0000000..aaf0a99 --- /dev/null +++ b/scripts/e2e-host.mjs @@ -0,0 +1,87 @@ +// Runs this plugin's host end-to-end tests against a pinned Lithe desktop host. +// +// node scripts/e2e-host.mjs [--source ] [--ref ] [--run ] +// +// The tests in e2e/host/ drive the real plugin package through the desktop host's own test +// harness (an internal Go package), so they cannot be built in this repository. Each run checks +// out the host revision pinned in e2e/host/lithe-host.json into the disposable .artifacts/lithe-host +// (desktop/ and scripts/ only), copies every e2e/host/desktop/ file to the same path in that +// checkout and runs the host's scripts/desktop/desktop-go.sh test there, which builds the Core +// revision that host pins. The host repository never reads this repository: the tests live here +// and the pin says which host they last passed on. Bumping the pin can require adapting the tests +// to changes in the host's internal harness. +// +// Every variable listed in lithe-host.json "requiredEnvironment" must be set, because the tests +// skip without their plugin input and a skipped run proves nothing. "repositoryEnvironment" maps a +// variable to a path in this repository, used when the variable is not set. --source and --ref override +// the pin, for example to try an unmerged host branch; --run overrides the test selection. +import { spawn } from 'node:child_process'; +import { copyFile, mkdir, readFile, readdir } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const tests = join(root, 'e2e', 'host'); +const work = join(root, '.artifacts', 'lithe-host'); + +function run(command, args, options = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { stdio: 'inherit', ...options }); + child.on('error', reject); + child.on('close', (code, signal) => { + if (code === 0) resolvePromise(); + else reject(new Error(`${command} ${args.join(' ')} failed (${signal ?? `exit ${code}`})`)); + }); + }); +} + +/** Lists the files under e2e/host/desktop, relative to e2e/host. */ +async function hostTestFiles(directory = join(tests, 'desktop')) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await hostTestFiles(path)); + else if (entry.isFile()) files.push(relative(tests, path)); + } + return files; +} + +/** Checks out `ref` from `source` into the disposable work directory, desktop/ and scripts/ only. */ +async function checkoutHost(source, ref) { + await mkdir(work, { recursive: true }); + await run('git', ['init', '-q', work]); + await run('git', ['-C', work, 'sparse-checkout', 'set', '--cone', 'desktop', 'scripts']); + // A named remote, because the partial clone fetches missing blobs from it later. + await run('git', ['-C', work, 'config', 'remote.origin.url', source]); + await run('git', ['-C', work, 'fetch', '-q', '--depth', '1', '--filter=blob:none', 'origin', ref]); + await run('git', ['-C', work, 'checkout', '-q', '--force', '--detach', 'FETCH_HEAD']); + // Drops test files copied by an earlier run; ignored build state such as .artifacts/ stays. + await run('git', ['-C', work, 'clean', '-q', '-f', '-d', '--', 'desktop']); +} + +const { values } = parseArgs({ + options: { source: { type: 'string' }, ref: { type: 'string' }, run: { type: 'string' } }, +}); +const pin = JSON.parse(await readFile(join(tests, 'lithe-host.json'), 'utf8')); +for (const [name, path] of Object.entries(pin.repositoryEnvironment ?? {})) { + process.env[name] ||= join(root, path); +} +const missing = Object.entries(pin.requiredEnvironment ?? {}).filter(([name]) => !process.env[name]); +if (missing.length > 0) { + throw new Error(`Set the plugin inputs first:\n${missing.map(([name, what]) => ` ${name}: ${what}`).join('\n')}`); +} + +// git runs inside the checkout, so a local source must be absolute. +const given = values.source ?? pin.repository; +const source = !/^[a-z]+:\/\//.test(given) ? resolve(given) : given; +await checkoutHost(source, values.ref ?? pin.revision); +for (const file of await hostTestFiles()) { + const target = join(work, file); + await mkdir(dirname(target), { recursive: true }); + await copyFile(join(tests, file), target); +} +for (const [pkg, selection] of Object.entries(pin.packages)) { + await run('bash', ['scripts/desktop/desktop-go.sh', 'test', pkg, '-count=1', '-v', + '-timeout', pin.timeout ?? '30m', '-run', values.run ?? selection], { cwd: work }); +}