返回 DeepSeek-Reasonix
chrome_devtools_live_test.go
根目录 / internal / plugin / chrome_devtools_live_test.go
1 package plugin
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/secrets"
14 "reasonix/internal/tool"
15 )
16
17 // TestChromeDevtoolsMCPLive is an opt-in release smoke test for the real npm
18 // package and local Chrome. It stays skipped in normal CI because it requires
19 // network access, npx, and a graphical Chrome installation.
20 //
21 // Run with:
22 //
23 // REASONIX_LIVE_CHROME_MCP=1 go test ./internal/plugin \
24 // -run '^TestChromeDevtoolsMCPLive$' -v -count=1 -timeout=3m
25 func TestChromeDevtoolsMCPLive(t *testing.T) {
26 if os.Getenv("REASONIX_LIVE_CHROME_MCP") != "1" {
27 t.Skip("set REASONIX_LIVE_CHROME_MCP=1 to run the real Chrome MCP smoke test")
28 }
29 // The package TestMain redirects HOME to keep normal tests isolated. A login
30 // shell under that empty home cannot load the user's Node manager and would
31 // incorrectly prepend /usr/local/bin over the invoking shell's PATH. The live
32 // test deliberately uses the caller's already-resolved PATH, matching a real
33 // desktop process after its user-home shell probe.
34 oldShellPATH := stdioShellPATH
35 stdioShellPATH = func(context.Context) string { return os.Getenv("PATH") }
36 t.Cleanup(func() { stdioShellPATH = oldShellPATH })
37
38 lifeCtx, lifeCancel := context.WithCancel(context.Background())
39 defer lifeCancel()
40 callCtx, callCancel := context.WithTimeout(lifeCtx, 2*time.Minute)
41 defer callCancel()
42
43 workspaceRoot := t.TempDir()
44 spec := Spec{
45 Name: "chrome-devtools-live",
46 Command: "npx",
47 Args: []string{"-y", "chrome-devtools-mcp@latest", "--isolated=true"},
48 Authorized: true,
49 ProcessMode: MCPProcessHost,
50 StateDir: t.TempDir(),
51 WorkspaceRoot: workspaceRoot,
52 }
53 host := NewHost()
54 defer host.Close()
55 resolvedNPX, resolvedEnv, resolveErr := resolveStdioExecutable(callCtx, spec, mergeEnv(secrets.ProcessEnv(), spec.Env))
56 if resolveErr != nil {
57 t.Fatalf("resolve npx: %v", resolveErr)
58 }
59 resolvedNode, _ := lookPathInEnv("node", resolvedEnv)
60 version := exec.Command(resolvedNode, "--version")
61 version.Env = resolvedEnv
62 versionOut, versionErr := version.CombinedOutput()
63 if versionErr != nil {
64 t.Fatalf("resolved node %q: %v: %s", resolvedNode, versionErr, strings.TrimSpace(string(versionOut)))
65 }
66 t.Logf("step 01/12 runtime: npx=%s node=%s version=%s", resolvedNPX, resolvedNode, strings.TrimSpace(string(versionOut)))
67 if spec.ResolvedProcessMode() != MCPProcessHost {
68 t.Fatalf("step 02/12 process mode = %q, want host", spec.ResolvedProcessMode())
69 }
70 t.Log("step 02/12 trusted host process mode selected")
71
72 result, err := host.InstallAndConnect(callCtx, spec)
73 if err != nil {
74 t.Fatalf("InstallAndConnect: state=%s action=%s err=%v", result.State, result.Action, err)
75 }
76 if result.State != "ready" || result.ToolCount == 0 {
77 t.Fatalf("install result = %+v, want ready with tools", result)
78 }
79 t.Logf("step 03/12 initialize + tools/list ready: tools=%d", result.ToolCount)
80
81 tools, err := host.ToolsFor(callCtx, spec.Name)
82 if err != nil {
83 t.Fatalf("ToolsFor: %v", err)
84 }
85 requiredTools := []string{"list_pages", "new_page", "navigate_page", "wait_for", "take_snapshot", "evaluate_script", "list_console_messages", "list_network_requests", "take_screenshot"}
86 for _, rawName := range requiredTools {
87 if findLiveMCPTool(tools, rawName) == nil {
88 t.Fatalf("step 04/12 required tool %q missing from %v", rawName, toolNames(tools))
89 }
90 }
91 t.Logf("step 04/12 catalog contains all %d required browser tools", len(requiredTools))
92 listPages := findLiveMCPTool(tools, "list_pages")
93 if listPages == nil {
94 t.Fatalf("list_pages missing from %v", toolNames(tools))
95 }
96 out, err := listPages.Execute(callCtx, json.RawMessage(`{}`))
97 if err != nil {
98 t.Fatalf("list_pages: %v", err)
99 }
100 if strings.TrimSpace(out) == "" {
101 t.Fatal("list_pages returned empty output")
102 }
103 t.Logf("step 05/12 list_pages=%s", strings.TrimSpace(out))
104
105 newPage := findLiveMCPTool(tools, "new_page")
106 if newPage == nil {
107 t.Fatalf("new_page missing from %v", toolNames(tools))
108 }
109 out, err = newPage.Execute(callCtx, json.RawMessage(`{"url":"about:blank"}`))
110 if err != nil {
111 t.Fatalf("new_page: %v", err)
112 }
113 if strings.TrimSpace(out) == "" {
114 t.Fatal("new_page returned empty output")
115 }
116 t.Logf("step 06/12 new_page=%s", strings.TrimSpace(out))
117
118 pageHTML := `data:text/html,<title>Reasonix%20MCP</title><h1>Reasonix%20MCP%20Ready</h1>`
119 out = executeLiveChromeTool(t, callCtx, tools, "navigate_page", map[string]any{"type": "url", "url": pageHTML})
120 t.Logf("step 07/12 navigate_page=%s", strings.TrimSpace(out))
121 out = executeLiveChromeTool(t, callCtx, tools, "wait_for", map[string]any{"text": []string{"Reasonix MCP Ready"}, "timeout": 10_000})
122 if !strings.Contains(out, "Reasonix MCP Ready") {
123 t.Fatalf("step 08/12 wait_for output = %q", out)
124 }
125 t.Log("step 08/12 page content became observable")
126 out = executeLiveChromeTool(t, callCtx, tools, "take_snapshot", map[string]any{})
127 if !strings.Contains(out, "Reasonix MCP Ready") {
128 t.Fatalf("step 09/12 snapshot output = %q", out)
129 }
130 t.Log("step 09/12 accessibility snapshot captured")
131 out = executeLiveChromeTool(t, callCtx, tools, "evaluate_script", map[string]any{
132 "function": `() => { console.log("reasonix-mcp-console"); return document.title; }`,
133 })
134 if !strings.Contains(out, "Reasonix MCP") {
135 t.Fatalf("step 10/12 evaluate_script output = %q", out)
136 }
137 t.Log("step 10/12 evaluate_script returned the document title")
138 consoleOut := executeLiveChromeTool(t, callCtx, tools, "list_console_messages", map[string]any{})
139 networkOut := executeLiveChromeTool(t, callCtx, tools, "list_network_requests", map[string]any{})
140 if !strings.Contains(consoleOut, "reasonix-mcp-console") || strings.TrimSpace(networkOut) == "" {
141 t.Fatalf("step 11/12 diagnostics console=%q network=%q", consoleOut, networkOut)
142 }
143 t.Log("step 11/12 console and network diagnostics are readable")
144 screenshotPath := filepath.Join(workspaceRoot, "chrome-devtools-live.png")
145 _ = executeLiveChromeTool(t, callCtx, tools, "take_screenshot", map[string]any{"filePath": screenshotPath, "format": "png"})
146 info, statErr := os.Stat(screenshotPath)
147 if statErr != nil || info.Size() == 0 {
148 t.Fatalf("step 12/12 screenshot file: info=%v err=%v", info, statErr)
149 }
150 if host.client(spec.Name) == nil {
151 t.Fatal("step 12/12 shared Chrome MCP client disappeared before host close")
152 }
153 t.Logf("step 12/12 screenshot persisted (%d bytes); shared client healthy before graceful close", info.Size())
154 }
155
156 func executeLiveChromeTool(t *testing.T, ctx context.Context, tools []tool.Tool, rawName string, args any) string {
157 t.Helper()
158 candidate := findLiveMCPTool(tools, rawName)
159 if candidate == nil {
160 t.Fatalf("%s missing from %v", rawName, toolNames(tools))
161 }
162 raw, err := json.Marshal(args)
163 if err != nil {
164 t.Fatalf("marshal %s args: %v", rawName, err)
165 }
166 out, err := candidate.Execute(ctx, raw)
167 if err != nil {
168 t.Fatalf("%s: %v", rawName, err)
169 }
170 return out
171 }
172
173 func findLiveMCPTool(tools []tool.Tool, rawName string) tool.Tool {
174 for _, candidate := range tools {
175 meta, ok := candidate.(tool.MCPMetadata)
176 if ok && meta.MCPRawToolName() == rawName {
177 return candidate
178 }
179 }
180 return nil
181 }
182
182 lines GO