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 1d3dd38..955e1c2 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ Program 设置按项目保存,修改后从下一次调试开始生效。 ```text io.github.lithe-idea.lldb-debug/plugin.json # 插件描述,目录名必须等于插件 id scripts/package.mjs # 校验清单,打包成 dist/lldb-debug-<版本>-darwin.zip +scripts/e2e-host.mjs # 在固定版本的宿主里运行 e2e/host/ 的测试 +e2e/host/ # 宿主端到端测试和宿主版本固定文件 lithe-host.json .github/workflows/ci.yml # PR 时打包,并用 Lithe Store 的规则检查 .github/workflows/release.yml # 推送 v<版本> 标签时发布 GitHub Release docs/releases/v<版本>.md # 该版本的变化,发布时并入 Release 说明(可选) @@ -70,6 +72,25 @@ node scripts/package.mjs [插件契约](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/plugins/contract/v1/README.md) 和[调试适配器插件指南](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/plugins/guides/debug-adapter-plugin.md)。 +## 宿主端到端测试 + +`e2e/host/` 里的测试通过 Lithe 桌面端自己的测试工具(宿主的 `desktop/internal/workbench` 包)装上插件, +用 `clang -g` 编译一个 C 程序,经真实的 `lldb-dap` 和 Rust Core 启动调试、命中断点。这些测试原来放在 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`、`clang` 和 `lldb-dap`: + +```bash +node scripts/e2e-host.mjs +``` + +测试默认使用本仓库的 `io.github.lithe-idea.lldb-debug/plugin.json`;要测别的清单,把它的绝对路径写进 +`LITHE_TEST_DEBUG_PLUGIN_MANIFEST`。脚本把固定版本的宿主(只取 `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` 时可能要同步修改测试。 + ## 发布新版本 1. 按语义化版本修改 `plugin.json` 的 `version`:只修 Bug 升修订号,加功能升次版本号,有不兼容的变化升主版本号。 diff --git a/e2e/host/desktop/internal/workbench/debug_native_e2e_test.go b/e2e/host/desktop/internal/workbench/debug_native_e2e_test.go new file mode 100644 index 0000000..6bbcce3 --- /dev/null +++ b/e2e/host/desktop/internal/workbench/debug_native_e2e_test.go @@ -0,0 +1,122 @@ +package workbench + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/1lck/Lithe-IDEA/desktop/internal/buildprocess" + "github.com/1lck/Lithe-IDEA/desktop/internal/core" + "github.com/1lck/Lithe-IDEA/desktop/internal/pluginstate" + "github.com/1lck/Lithe-IDEA/desktop/internal/store" +) + +// This integration test uses an explicit, captured official plugin manifest, +// the selected native C compiler, the real adapter and the real Core engine. +func TestDebugRealLLDBLaunchTargetAndBreakpoints(t *testing.T) { + manifestPath := os.Getenv("LITHE_TEST_DEBUG_PLUGIN_MANIFEST") + if manifestPath == "" { + t.Skip("set LITHE_TEST_DEBUG_PLUGIN_MANIFEST to run the real LLDB integration") + } + manifest, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + validationContext, cancelValidation := context.WithTimeout(context.Background(), 5*time.Second) + _, validationErr := core.NewClient().ValidatePluginManifest(validationContext, manifest, map[string][]int{ + "debugAdapters": {0}, "languages": {0}, "settings": {0}, + }) + cancelValidation() + if validationErr != nil { + t.Fatal(validationErr) + } + var identity struct { + ID string `json:"id"` + Version string `json:"version"` + } + if err := json.Unmarshal(manifest, &identity); err != nil { + t.Fatal(err) + } + pluginRoot := t.TempDir() + installed := filepath.Join(pluginRoot, identity.ID, identity.Version) + if err := os.MkdirAll(installed, 0o700); err != nil { + t.Fatal(err) + } + installedManifest := filepath.Join(installed, "plugin.json") + if err := os.WriteFile(installedManifest, manifest, 0o600); err != nil { + t.Fatal(err) + } + preferenceRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(preferenceRoot, settingsFile), []byte(`{"theme":"dark"}`), 0o600); err != nil { + t.Fatal(err) + } + preferences := store.At(preferenceRoot) + if err := pluginstate.New(preferences).SetEnabled(identity.ID, true); err != nil { + t.Fatal(err) + } + h := newHarnessWithOptions(t, Options{Kernel: &testKernel{Client: core.NewClient()}, PluginKernel: &testKernel{Client: core.NewClient()}, PluginRoot: pluginRoot, Store: preferences, Languages: []string{"en"}}) + root := t.TempDir() + source := filepath.Join(root, "main.c") + code := "#include \nint main(void) {\n int value = 41;\n value += 1;\n printf(\"debug-result=%d\\n\", value);\n return value == 42 ? 0 : 1;\n}\n" + if err := os.WriteFile(source, []byte(code), 0o600); err != nil { + t.Fatal(err) + } + compiler, err := exec.LookPath("clang") + if err != nil { + t.Fatal(err) + } + executable := filepath.Join(root, "main") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + compilation, err := buildprocess.Start(ctx, buildprocess.Spec{Executable: compiler, Arguments: []string{"-g", "-O0", source, "-o", executable}}, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { compilation.Stop(); _ = compilation.Wait() }) + if err := compilation.Wait(); err != nil { + t.Fatal(err) + } + p := openDebugPanel(t, h, root) + h.app.openFileAtPosition(p, source, 3, 0) + h.settle() + h.app.toggleBreakpoint(p) + capture := &captureDebugLaunch{Kernel: h.app.kernel, configurations: make(chan core.DebugLaunchConfiguration, 1)} + h.app.kernel = capture + h.app.startDebug(p) + defer func() { + t.Logf("debug state=%s status=%s failure=%s output=%v frames=%v", p.debug.state, p.debug.status, p.debug.failure, p.debug.output, p.debug.frames) + }() + awaitStateWithin(t, h, 15*time.Second, "real LLDB breakpoint and main frame", func() bool { + if p.debug.failure != "" { + t.Fatalf("real LLDB launch failed: %s; output=%v", p.debug.failure, p.debug.output) + } + return p.debug.state == "paused" && len(p.debug.frames) > 0 + }) + if frame := p.debug.frames[0]; !strings.Contains(frame.Name, "main") || frame.SourcePath != source || frame.Line != 4 { + t.Fatalf("real main breakpoint = %+v", frame) + } + select { + case configuration := <-capture.configurations: + if configuration.Arguments["program"] != executable || configuration.Arguments["cwd"] != root || configuration.Arguments["stopOnEntry"] != false { + t.Fatalf("real launch configuration = %+v", configuration) + } + default: + t.Fatal("the real DAP launch was not captured") + } + h.app.continueDebug(p) + awaitStateWithin(t, h, 10*time.Second, "real debuggee exit and native cleanup", func() bool { return p.debug.activeSession() == nil }) + h.settle() + after, err := os.ReadFile(installedManifest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, manifest) { + t.Fatal("debug launch changed installed plugin resources") + } +} diff --git a/e2e/host/lithe-host.json b/e2e/host/lithe-host.json new file mode 100644 index 0000000..b66099e --- /dev/null +++ b/e2e/host/lithe-host.json @@ -0,0 +1,13 @@ +{ + "repository": "https://github.com/1lck/Lithe-IDEA.git", + "revision": "059d878d4284e1a1fd218aeaa56c29d9753d4dfa", + "packages": { + "./internal/workbench": "^TestDebugRealLLDBLaunchTargetAndBreakpoints$" + }, + "requiredEnvironment": { + "LITHE_TEST_DEBUG_PLUGIN_MANIFEST": "plugin.json to debug with; defaults to this repository's" + }, + "repositoryEnvironment": { + "LITHE_TEST_DEBUG_PLUGIN_MANIFEST": "io.github.lithe-idea.lldb-debug/plugin.json" + } +} 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 }); +}