Skip to content

Commit 2fc06ea

Browse files
gqcnclaude
andcommitted
fix(ci): fix plugin workspace duplicate module and E2E pnpm dependency chain
Reuse official plugin root module as the lina-plugins aggregate bridge instead of generating a duplicate fallback module. Improve linactl go list error output to preserve stderr diagnostics for CI debugging. Switch E2E workflow from npm ci/npx to pnpm install/exec to align Playwright browser revision with pnpm-lock.yaml. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9a47b39 commit 2fc06ea

7 files changed

Lines changed: 246 additions & 8 deletions

File tree

.github/workflows/reusable-e2e-tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,13 @@ jobs:
151151
# 为共享 E2E 工作区安装 Playwright 测试运行依赖。
152152
- name: Install E2E Dependencies
153153
working-directory: hack/tests
154-
run: npm ci
154+
run: pnpm install --frozen-lockfile
155155

156156
# Install Chromium and required OS packages for Playwright.
157157
# 安装 Playwright 使用的 Chromium 和所需系统包。
158158
- name: Install Playwright Browsers
159159
working-directory: hack/tests
160-
run: npx playwright install --with-deps chromium
160+
run: pnpm exec playwright install --with-deps chromium
161161

162162
# Rebuild the database and seed mock data for browser workflows.
163163
# 重建数据库并为浏览器工作流加载 mock 数据。

apps/lina-core/internal/service/plugin/internal/testutil/testutil_build.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,12 +205,26 @@ func ensureBuildWasmPluginWorkspace(repoRoot string, pluginDir string) error {
205205
for _, use := range parseBuildWasmGoWorkUses(string(rootContent)) {
206206
addUse(use)
207207
}
208+
if aggregateUse, aggregateErr := ensureBuildWasmAggregateModule(repoRoot, officialRoot); aggregateErr != nil {
209+
return aggregateErr
210+
} else if aggregateUse != "" {
211+
normalized := normalizeBuildWasmGoWorkUse(aggregateUse)
212+
if normalized != "" {
213+
if _, ok := seen[normalized]; !ok {
214+
seen[normalized] = struct{}{}
215+
uses = append(uses, normalized)
216+
}
217+
}
218+
}
208219
pluginUses, err := buildWasmPluginGoWorkUses(repoRoot, officialRoot)
209220
if err != nil {
210221
return err
211222
}
212223
for _, use := range pluginUses {
213224
normalized := normalizeBuildWasmGoWorkUse(use)
225+
if normalized == "" || normalized == "apps/lina-plugins" {
226+
continue
227+
}
214228
if _, ok := seen[normalized]; ok {
215229
continue
216230
}
@@ -231,6 +245,49 @@ func ensureBuildWasmPluginWorkspace(repoRoot string, pluginDir string) error {
231245
return nil
232246
}
233247

248+
// ensureBuildWasmAggregateModule resolves the module that satisfies the
249+
// host's official source-plugin import bridge for plugin test builds.
250+
func ensureBuildWasmAggregateModule(repoRoot string, officialRoot string) (string, error) {
251+
if moduleName, err := readBuildWasmGoModuleName(filepath.Join(officialRoot, "go.mod")); err == nil && moduleName == "lina-plugins" {
252+
if err = os.RemoveAll(filepath.Join(repoRoot, "temp", "official-plugins")); err != nil {
253+
return "", fmt.Errorf("clean stale test aggregate module: %w", err)
254+
}
255+
return filepath.ToSlash(filepath.Join("apps", "lina-plugins")), nil
256+
} else if err != nil && !os.IsNotExist(err) {
257+
return "", err
258+
}
259+
260+
moduleDir := filepath.Join(repoRoot, "temp", "official-plugins")
261+
if err := os.RemoveAll(moduleDir); err != nil {
262+
return "", fmt.Errorf("clean test aggregate module: %w", err)
263+
}
264+
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
265+
return "", fmt.Errorf("create test aggregate module: %w", err)
266+
}
267+
if err := os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte("module lina-plugins\n\ngo 1.25.0\n"), 0o644); err != nil {
268+
return "", fmt.Errorf("write test aggregate go.mod: %w", err)
269+
}
270+
if err := os.WriteFile(filepath.Join(moduleDir, "plugins.go"), []byte("package linaplugins\n"), 0o644); err != nil {
271+
return "", fmt.Errorf("write test aggregate package: %w", err)
272+
}
273+
return filepath.ToSlash(filepath.Join("temp", "official-plugins")), nil
274+
}
275+
276+
// readBuildWasmGoModuleName reads the module directive from a go.mod file.
277+
func readBuildWasmGoModuleName(path string) (string, error) {
278+
content, err := os.ReadFile(path)
279+
if err != nil {
280+
return "", err
281+
}
282+
for _, line := range strings.Split(string(content), "\n") {
283+
fields := strings.Fields(stripBuildWasmGoWorkComment(line))
284+
if len(fields) >= 2 && fields[0] == "module" {
285+
return fields[1], nil
286+
}
287+
}
288+
return "", fmt.Errorf("%s is missing a module directive", path)
289+
}
290+
234291
// buildWasmPluginGoWorkUses discovers Go modules under the official plugin workspace.
235292
func buildWasmPluginGoWorkUses(repoRoot string, officialRoot string) ([]string, error) {
236293
var uses []string
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// This file verifies dynamic plugin build workspace helpers.
2+
3+
package testutil
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// TestEnsureBuildWasmPluginWorkspaceReusesOfficialPluginRootModule verifies
13+
// test helpers keep the official plugin root module as the lina-plugins bridge.
14+
func TestEnsureBuildWasmPluginWorkspaceReusesOfficialPluginRootModule(t *testing.T) {
15+
repoRoot := t.TempDir()
16+
writeBuildWorkspaceTestFile(t, filepath.Join(repoRoot, "go.work"), `go 1.25.0
17+
18+
use (
19+
./apps/lina-core
20+
)
21+
`)
22+
pluginRoot := filepath.Join(repoRoot, "apps", "lina-plugins")
23+
writeBuildWorkspaceTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module lina-plugins\n")
24+
writeBuildWorkspaceTestFile(t, filepath.Join(pluginRoot, "plugin-demo-dynamic", "go.mod"), "module plugin-demo-dynamic\n")
25+
writeBuildWorkspaceTestFile(t, filepath.Join(pluginRoot, "plugin-demo-dynamic", "plugin.yaml"), "id: plugin-demo-dynamic\n")
26+
27+
if err := ensureBuildWasmPluginWorkspace(repoRoot, filepath.Join(pluginRoot, "plugin-demo-dynamic")); err != nil {
28+
t.Fatalf("ensureBuildWasmPluginWorkspace returned error: %v", err)
29+
}
30+
31+
content, err := os.ReadFile(filepath.Join(repoRoot, "temp", "go.work.plugins"))
32+
if err != nil {
33+
t.Fatalf("read generated plugin workspace: %v", err)
34+
}
35+
text := string(content)
36+
if strings.Contains(text, "./official-plugins") {
37+
t.Fatalf("expected official plugin root module to replace fallback aggregate, got:\n%s", text)
38+
}
39+
if !strings.Contains(text, "../apps/lina-plugins\n") {
40+
t.Fatalf("expected generated workspace to include official plugin root module, got:\n%s", text)
41+
}
42+
if !strings.Contains(text, "../apps/lina-plugins/plugin-demo-dynamic\n") {
43+
t.Fatalf("expected generated workspace to include dynamic plugin module, got:\n%s", text)
44+
}
45+
}
46+
47+
// writeBuildWorkspaceTestFile writes one fixture file for workspace helper tests.
48+
func writeBuildWorkspaceTestFile(t *testing.T, path string, content string) {
49+
t.Helper()
50+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
51+
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
52+
}
53+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
54+
t.Fatalf("write %s: %v", path, err)
55+
}
56+
}

hack/tools/linactl/command_ops.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,12 @@ func goWorkspaceModules(ctx context.Context, a *app) ([]string, error) {
232232
cmd := a.execCommand(ctx, "go", "list", "-m", "-f", "{{.Dir}}")
233233
cmd.Dir = a.root
234234
cmd.Env = a.env
235-
output, err := cmd.Output()
235+
output, err := cmd.CombinedOutput()
236236
if err != nil {
237+
message := strings.TrimSpace(string(output))
238+
if message != "" {
239+
return nil, fmt.Errorf("list Go workspace modules: %w: %s", err, message)
240+
}
237241
return nil, fmt.Errorf("list Go workspace modules: %w", err)
238242
}
239243
lines := strings.Split(strings.TrimSpace(string(output)), "\n")

hack/tools/linactl/command_plugin_gowork.go

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,13 @@ func writeOfficialPluginWorkspace(root string, workspace officialPluginWorkspace
102102
addUse(use.Raw)
103103
}
104104
if aggregateUse != "" {
105-
addUse(aggregateUse)
105+
normalized := normalizeGoWorkUse(root, aggregateUse)
106+
if normalized != "" {
107+
if _, ok := seen[normalized]; !ok {
108+
seen[normalized] = struct{}{}
109+
normalizedUses = append(normalizedUses, normalized)
110+
}
111+
}
106112
}
107113
for _, use := range pluginUses {
108114
normalized := normalizeGoWorkUse(root, use)
@@ -129,10 +135,22 @@ func writeOfficialPluginWorkspace(root string, workspace officialPluginWorkspace
129135
return workspacePath, nil
130136
}
131137

132-
// writeOfficialPluginAggregateModule generates a tiny module that satisfies the
133-
// host's `import _ "lina-plugins"` bridge and imports official source-plugin
134-
// backend packages for their init registrations.
138+
// writeOfficialPluginAggregateModule resolves the module that satisfies the
139+
// host's `import _ "lina-plugins"` bridge. Official plugin workspaces can
140+
// provide this aggregate module at their root; older local fixtures without a
141+
// root module still receive an ignored generated fallback module.
135142
func writeOfficialPluginAggregateModule(root string, workspace officialPluginWorkspace) (string, error) {
143+
existingUse, err := existingOfficialPluginAggregateModule(root, workspace)
144+
if err != nil {
145+
return "", err
146+
}
147+
if existingUse != "" {
148+
if err = os.RemoveAll(officialPluginAggregateModuleDir(root)); err != nil {
149+
return "", fmt.Errorf("clean stale official plugin aggregate module: %w", err)
150+
}
151+
return existingUse, nil
152+
}
153+
136154
imports, err := officialPluginBackendImports(workspace)
137155
if err != nil {
138156
return "", err
@@ -157,6 +175,27 @@ func writeOfficialPluginAggregateModule(root string, workspace officialPluginWor
157175
return "./" + filepath.ToSlash(relativePath), nil
158176
}
159177

178+
// existingOfficialPluginAggregateModule returns the official plugin root module
179+
// when it already owns the host bridge import path.
180+
func existingOfficialPluginAggregateModule(root string, workspace officialPluginWorkspace) (string, error) {
181+
goModPath := filepath.Join(workspace.Root, "go.mod")
182+
if !fileExists(goModPath) {
183+
return "", nil
184+
}
185+
moduleName, err := readGoModuleName(goModPath)
186+
if err != nil {
187+
return "", err
188+
}
189+
if moduleName != officialPluginAggregateModuleName {
190+
return "", nil
191+
}
192+
relativePath, err := filepath.Rel(root, workspace.Root)
193+
if err != nil {
194+
return "", fmt.Errorf("resolve existing official plugin aggregate module path: %w", err)
195+
}
196+
return "./" + filepath.ToSlash(relativePath), nil
197+
}
198+
160199
// officialPluginBackendImports discovers source-plugin backend packages that
161200
// must be imported by the generated aggregate module.
162201
func officialPluginBackendImports(workspace officialPluginWorkspace) ([]officialPluginBackendImport, error) {

hack/tools/linactl/main_test.go

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,27 @@ func TestGoWorkspaceModulesSkipsGeneratedOfficialPluginAggregate(t *testing.T) {
773773
}
774774
}
775775

776+
// TestGoWorkspaceModulesIncludesGoListOutputInErrors verifies CI failures keep
777+
// the Go command's actionable workspace diagnostic instead of only exit status.
778+
func TestGoWorkspaceModulesIncludesGoListOutputInErrors(t *testing.T) {
779+
application := newApp(ioDiscard{}, ioDiscard{}, strings.NewReader(""))
780+
application.root = t.TempDir()
781+
application.execCommand = func(_ context.Context, name string, args ...string) *exec.Cmd {
782+
if name != "go" || strings.Join(args, " ") != "list -m -f {{.Dir}}" {
783+
t.Fatalf("unexpected module list command: %s %s", name, strings.Join(args, " "))
784+
}
785+
return exec.Command(os.Args[0], "-test.run=TestHelperPrintAndFail", "--")
786+
}
787+
788+
_, err := goWorkspaceModules(context.Background(), application)
789+
if err == nil {
790+
t.Fatalf("expected goWorkspaceModules to return an error")
791+
}
792+
if !strings.Contains(err.Error(), "workspace diagnostic from go list") {
793+
t.Fatalf("expected go list output in error, got %v", err)
794+
}
795+
}
796+
776797
// TestDiscoverGoModuleDirsSkipsGeneratedAndDependencyDirs verifies tidy scans
777798
// maintained source modules without entering generated or dependency trees.
778799
func TestDiscoverGoModuleDirsSkipsGeneratedAndDependencyDirs(t *testing.T) {
@@ -892,7 +913,6 @@ use (
892913
use (
893914
../apps/lina-core
894915
../hack/tools/build-wasm
895-
./official-plugins
896916
../apps/lina-plugins
897917
../apps/lina-plugins/plugin-a
898918
../apps/lina-plugins/plugin-b
@@ -901,6 +921,52 @@ use (
901921
if string(pluginContent) != expected {
902922
t.Fatalf("unexpected temporary plugin go.work:\n%s", string(pluginContent))
903923
}
924+
if dirExists(filepath.Join(root, "temp", "official-plugins")) {
925+
t.Fatalf("expected existing official plugin root module to be reused without generated fallback")
926+
}
927+
}
928+
929+
func TestPrepareOfficialPluginWorkspaceGeneratesFallbackAggregateModule(t *testing.T) {
930+
root := t.TempDir()
931+
content := `go 1.25.0
932+
933+
use (
934+
./apps/lina-core
935+
./hack/tools/build-wasm
936+
)
937+
`
938+
writeFile(t, filepath.Join(root, "go.work"), content)
939+
pluginRoot := filepath.Join(root, "apps", "lina-plugins")
940+
writeFile(t, filepath.Join(pluginRoot, "plugin-b", "go.mod"), "module plugin-b\n")
941+
writeFile(t, filepath.Join(pluginRoot, "plugin-b", "plugin.yaml"), "id: plugin-b\n")
942+
writeFile(t, filepath.Join(pluginRoot, "plugin-a", "go.mod"), "module plugin-a\n")
943+
writeFile(t, filepath.Join(pluginRoot, "plugin-a", "plugin.yaml"), "id: plugin-a\n")
944+
945+
workspace, err := inspectOfficialPluginWorkspace(root)
946+
if err != nil {
947+
t.Fatalf("inspectOfficialPluginWorkspace returned error: %v", err)
948+
}
949+
workspacePath, err := prepareOfficialPluginWorkspace(root, true, workspace)
950+
if err != nil {
951+
t.Fatalf("prepareOfficialPluginWorkspace returned error: %v", err)
952+
}
953+
pluginContent, err := os.ReadFile(workspacePath)
954+
if err != nil {
955+
t.Fatalf("read temporary plugin go.work: %v", err)
956+
}
957+
expected := `go 1.25.0
958+
959+
use (
960+
../apps/lina-core
961+
../hack/tools/build-wasm
962+
./official-plugins
963+
../apps/lina-plugins/plugin-a
964+
../apps/lina-plugins/plugin-b
965+
)
966+
`
967+
if string(pluginContent) != expected {
968+
t.Fatalf("unexpected fallback temporary plugin go.work:\n%s", string(pluginContent))
969+
}
904970
aggregateGoMod, err := os.ReadFile(filepath.Join(root, "temp", "official-plugins", "go.mod"))
905971
if err != nil {
906972
t.Fatalf("read aggregate go.mod: %v", err)
@@ -965,6 +1031,16 @@ func TestHelperCommandFailure(t *testing.T) {
9651031
os.Exit(1)
9661032
}
9671033

1034+
// TestHelperPrintAndFail prints a deterministic diagnostic and exits with
1035+
// failure for command-output error tests.
1036+
func TestHelperPrintAndFail(t *testing.T) {
1037+
if len(os.Args) < 2 || os.Args[len(os.Args)-1] != "--" {
1038+
return
1039+
}
1040+
fmt.Fprintln(os.Stderr, "workspace diagnostic from go list")
1041+
os.Exit(1)
1042+
}
1043+
9681044
// TestHelperRecordWorkingDirectory records the child process working directory
9691045
// for command execution tests.
9701046
func TestHelperRecordWorkingDirectory(t *testing.T) {

0 commit comments

Comments
 (0)