返回 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 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "testing"
13 "time"
14
15 "reasonix/internal/secrets"
16 "reasonix/internal/tool"
17 )
18
19 // TestChromeDevtoolsMCPLive is an opt-in release smoke test for the real npm
20 // package and local Chrome. It stays skipped in normal CI because it requires
21 // network access, npx, and a graphical Chrome installation.
22 //
23 // Run with:
24 //
25 // REASONIX_LIVE_CHROME_MCP=1 go test ./internal/plugin \
26 // -run '^TestChromeDevtoolsMCPLive$' -v -count=1 -timeout=3m
27 func TestChromeDevtoolsMCPLive(t *testing.T) {
28 if os.Getenv("REASONIX_LIVE_CHROME_MCP") != "1" {
29 t.Skip("set REASONIX_LIVE_CHROME_MCP=1 to run the real Chrome MCP smoke test")
30 }
31 // The package TestMain redirects HOME to keep normal tests isolated. A login
32 // shell under that empty home cannot load the user's Node manager and would
33 // incorrectly prepend /usr/local/bin over the invoking shell's PATH. The live
34 // test deliberately uses the caller's already-resolved PATH, matching a real
35 // desktop process after its user-home shell probe.
36 oldShellPATH := stdioShellPATH
37 stdioShellPATH = func(context.Context) string { return os.Getenv("PATH") }
38 t.Cleanup(func() { stdioShellPATH = oldShellPATH })
39
40 lifeCtx := t.Context()
41 callCtx, callCancel := context.WithTimeout(lifeCtx, 2*time.Minute)
42 defer callCancel()
43
44 workspaceRoot := t.TempDir()
45 spec := Spec{
46 Name: "chrome-devtools-live",
47 Command: "npx",
48 Args: []string{"-y", "chrome-devtools-mcp@latest", "--isolated=true"},
49 Authorized: true,
50 ProcessMode: MCPProcessHost,
51 StateDir: t.TempDir(),
52 WorkspaceRoot: workspaceRoot,
53 }
54 host := NewHost()
55 defer host.Close()
56 resolvedNPX, resolvedEnv, resolveErr := resolveStdioExecutable(callCtx, spec, mergeEnv(secrets.ProcessEnv(), spec.Env))
57 if resolveErr != nil {
58 t.Fatalf("resolve npx: %v", resolveErr)
59 }
60 resolvedNode, _ := lookPathInEnv("node", resolvedEnv)
61 version := exec.Command(resolvedNode, "--version")
62 version.Env = resolvedEnv
63 versionOut, versionErr := version.CombinedOutput()
64 if versionErr != nil {
65 t.Fatalf("resolved node %q: %v: %s", resolvedNode, versionErr, strings.TrimSpace(string(versionOut)))
66 }
67 t.Logf("step 01/12 runtime: npx=%s node=%s version=%s", resolvedNPX, resolvedNode, strings.TrimSpace(string(versionOut)))
68 if spec.ResolvedProcessMode() != MCPProcessHost {
69 t.Fatalf("step 02/12 process mode = %q, want host", spec.ResolvedProcessMode())
70 }
71 t.Log("step 02/12 trusted host process mode selected")
72
73 result, err := host.InstallAndConnect(callCtx, spec)
74 if err != nil {
75 t.Fatalf("InstallAndConnect: state=%s action=%s err=%v", result.State, result.Action, err)
76 }
77 if result.State != "ready" || result.ToolCount == 0 {
78 t.Fatalf("install result = %+v, want ready with tools", result)
79 }
80 t.Logf("step 03/12 initialize + tools/list ready: tools=%d", result.ToolCount)
81
82 tools, err := host.ToolsFor(callCtx, spec.Name)
83 if err != nil {
84 t.Fatalf("ToolsFor: %v", err)
85 }
86 requiredTools := []string{"list_pages", "new_page", "navigate_page", "wait_for", "take_snapshot", "evaluate_script", "list_console_messages", "list_network_requests", "take_screenshot"}
87 for _, rawName := range requiredTools {
88 if findLiveMCPTool(tools, rawName) == nil {
89 t.Fatalf("step 04/12 required tool %q missing from %v", rawName, toolNames(tools))
90 }
91 }
92 t.Logf("step 04/12 catalog contains all %d required browser tools", len(requiredTools))
93 listPages := findLiveMCPTool(tools, "list_pages")
94 if listPages == nil {
95 t.Fatalf("list_pages missing from %v", toolNames(tools))
96 }
97 out, err := listPages.Execute(callCtx, json.RawMessage(`{}`))
98 if err != nil {
99 t.Fatalf("list_pages: %v", err)
100 }
101 if strings.TrimSpace(out) == "" {
102 t.Fatal("list_pages returned empty output")
103 }
104 t.Logf("step 05/12 list_pages=%s", strings.TrimSpace(out))
105
106 newPage := findLiveMCPTool(tools, "new_page")
107 if newPage == nil {
108 t.Fatalf("new_page missing from %v", toolNames(tools))
109 }
110 out, err = newPage.Execute(callCtx, json.RawMessage(`{"url":"about:blank"}`))
111 if err != nil {
112 t.Fatalf("new_page: %v", err)
113 }
114 if strings.TrimSpace(out) == "" {
115 t.Fatal("new_page returned empty output")
116 }
117 t.Logf("step 06/12 new_page=%s", strings.TrimSpace(out))
118 pageID, err := parseSelectedChromePageID(out)
119 if err != nil {
120 t.Fatalf("step 06/12 selected page ID: %v; output=%q", err, out)
121 }
122
123 pageHTML := `data:text/html,<title>Reasonix%20MCP</title><h1>Reasonix%20MCP%20Ready</h1>`
124 out = executeLiveChromeTool(t, callCtx, tools, "navigate_page", map[string]any{"pageId": pageID, "type": "url", "url": pageHTML})
125 t.Logf("step 07/12 navigate_page=%s", strings.TrimSpace(out))
126 out = executeLiveChromeTool(t, callCtx, tools, "wait_for", map[string]any{"pageId": pageID, "text": []string{"Reasonix MCP Ready"}, "timeout": 10_000})
127 if !strings.Contains(out, "Reasonix MCP Ready") {
128 t.Fatalf("step 08/12 wait_for output = %q", out)
129 }
130 t.Log("step 08/12 page content became observable")
131 out = executeLiveChromeTool(t, callCtx, tools, "take_snapshot", map[string]any{"pageId": pageID})
132 if !strings.Contains(out, "Reasonix MCP Ready") {
133 t.Fatalf("step 09/12 snapshot output = %q", out)
134 }
135 t.Log("step 09/12 accessibility snapshot captured")
136 out = executeLiveChromeTool(t, callCtx, tools, "evaluate_script", map[string]any{
137 "pageId": pageID,
138 "function": `() => { console.log("reasonix-mcp-console"); return document.title; }`,
139 })
140 if !strings.Contains(out, "Reasonix MCP") {
141 t.Fatalf("step 10/12 evaluate_script output = %q", out)
142 }
143 t.Log("step 10/12 evaluate_script returned the document title")
144 consoleOut := executeLiveChromeTool(t, callCtx, tools, "list_console_messages", map[string]any{"pageId": pageID})
145 networkOut := executeLiveChromeTool(t, callCtx, tools, "list_network_requests", map[string]any{"pageId": pageID})
146 if !strings.Contains(consoleOut, "reasonix-mcp-console") || strings.TrimSpace(networkOut) == "" {
147 t.Fatalf("step 11/12 diagnostics console=%q network=%q", consoleOut, networkOut)
148 }
149 t.Log("step 11/12 console and network diagnostics are readable")
150 screenshotPath := filepath.Join(workspaceRoot, "chrome-devtools-live.png")
151 _ = executeLiveChromeTool(t, callCtx, tools, "take_screenshot", map[string]any{"pageId": pageID, "filePath": screenshotPath, "format": "png"})
152 info, statErr := os.Stat(screenshotPath)
153 if statErr != nil || info.Size() == 0 {
154 t.Fatalf("step 12/12 screenshot file: info=%v err=%v", info, statErr)
155 }
156 if host.client(spec.Name) == nil {
157 t.Fatal("step 12/12 shared Chrome MCP client disappeared before host close")
158 }
159 t.Logf("step 12/12 screenshot persisted (%d bytes); shared client healthy before graceful close", info.Size())
160 }
161
162 func parseSelectedChromePageID(output string) (int, error) {
163 selected := 0
164 for rawLine := range strings.SplitSeq(output, "\n") {
165 line := strings.TrimSpace(rawLine)
166 if !strings.HasSuffix(line, "[selected]") {
167 continue
168 }
169 rawID, _, ok := strings.Cut(line, ":")
170 if !ok {
171 return 0, fmt.Errorf("malformed selected page line %q", line)
172 }
173 pageID, err := strconv.Atoi(strings.TrimSpace(rawID))
174 if err != nil || pageID <= 0 {
175 return 0, fmt.Errorf("invalid selected page ID in %q", line)
176 }
177 if selected != 0 {
178 return 0, fmt.Errorf("multiple selected pages in output")
179 }
180 selected = pageID
181 }
182 if selected == 0 {
183 return 0, fmt.Errorf("selected page not found")
184 }
185 return selected, nil
186 }
187
188 func TestParseSelectedChromePageID(t *testing.T) {
189 t.Parallel()
190 tests := []struct {
191 name string
192 output string
193 want int
194 wantErr bool
195 }{
196 {name: "new page", output: "## Pages\n1: about:blank\n2: about:blank [selected]", want: 2},
197 {name: "URL with colon", output: "## Pages\r\n17: https://example.com:8443/docs [selected]\r\n", want: 17},
198 {name: "missing selection", output: "## Pages\n1: about:blank", wantErr: true},
199 {name: "invalid ID", output: "## Pages\npage-2: about:blank [selected]", wantErr: true},
200 {name: "multiple selections", output: "## Pages\n1: about:blank [selected]\n2: about:blank [selected]", wantErr: true},
201 }
202 for _, test := range tests {
203 t.Run(test.name, func(t *testing.T) {
204 t.Parallel()
205 got, err := parseSelectedChromePageID(test.output)
206 if test.wantErr {
207 if err == nil {
208 t.Fatalf("parseSelectedChromePageID(%q) = %d, want error", test.output, got)
209 }
210 return
211 }
212 if err != nil {
213 t.Fatalf("parseSelectedChromePageID(%q): %v", test.output, err)
214 }
215 if got != test.want {
216 t.Fatalf("parseSelectedChromePageID(%q) = %d, want %d", test.output, got, test.want)
217 }
218 })
219 }
220 }
221
222 func executeLiveChromeTool(t *testing.T, ctx context.Context, tools []tool.Tool, rawName string, args any) string {
223 t.Helper()
224 candidate := findLiveMCPTool(tools, rawName)
225 if candidate == nil {
226 t.Fatalf("%s missing from %v", rawName, toolNames(tools))
227 }
228 raw, err := json.Marshal(args)
229 if err != nil {
230 t.Fatalf("marshal %s args: %v", rawName, err)
231 }
232 out, err := candidate.Execute(ctx, raw)
233 if err != nil {
234 t.Fatalf("%s: %v", rawName, err)
235 }
236 return out
237 }
238
239 func findLiveMCPTool(tools []tool.Tool, rawName string) tool.Tool {
240 for _, candidate := range tools {
241 meta, ok := candidate.(tool.MCPMetadata)
242 if ok && meta.MCPRawToolName() == rawName {
243 return candidate
244 }
245 }
246 return nil
247 }
248
248 lines GO