Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
dist/
.cache/
.DS_Store
.artifacts/
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 说明(可选)
Expand All @@ -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 升修订号,加功能升次版本号,有不兼容的变化升主版本号。
Expand Down
122 changes: 122 additions & 0 deletions e2e/host/desktop/internal/workbench/debug_native_e2e_test.go
Original file line number Diff line number Diff line change
@@ -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 <stdio.h>\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")
}
}
13 changes: 13 additions & 0 deletions e2e/host/lithe-host.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
87 changes: 87 additions & 0 deletions scripts/e2e-host.mjs
Original file line number Diff line number Diff line change
@@ -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 <Lithe git URL or path>] [--ref <commit or branch>] [--run <regex>]
//
// 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/<path> 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 });
}
Loading