diff --git a/README.md b/README.md index 2c874dbd..545772e1 100644 --- a/README.md +++ b/README.md @@ -1009,8 +1009,12 @@ Destinations are the OTLP/HTTP endpoints sessions export to. They belong to the ### Browser Playwright - `kernel browsers playwright execute [code]` - Execute Playwright/TypeScript code against the browser + - `--executor ` - Executor to run the call in. Calls on different executors run concurrently in separate tabs of the same browser; calls on one executor run one at a time. Omit to use the always-present `default` executor bound to the active tab. Any other name creates a named executor on first use that owns a background tab `page` is bound to. At most 8 named executors per browser (409 when exceeded) - `--timeout ` - Maximum execution time in seconds (defaults server-side) - If `[code]` is omitted, code is read from stdin +- `kernel browsers playwright executors list ` - List the browser's Playwright executors (default first) with busy state, timestamps, and the tab each named executor owns +- `kernel browsers playwright executors delete ` - Delete a Playwright executor. Deleting `default` restarts it instead of removing it + - `--close-tab` - Close the tab owned by the executor (default: true) ### Browser REPL @@ -1524,6 +1528,15 @@ TS # With a timeout in seconds kernel browsers playwright execute my-browser --timeout 30 'await (await context.newPage()).goto("https://example.com")' +# Drive two tabs in parallel with named executors (each owns its own tab) +kernel browsers playwright execute my-browser --executor docs 'await page.goto("https://example.com/docs"); return await page.title();' & +kernel browsers playwright execute my-browser --executor pricing 'await page.goto("https://example.com/pricing"); return await page.title();' & +wait + +# List executors and delete one (closes its tab unless --close-tab=false) +kernel browsers playwright executors list my-browser +kernel browsers playwright executors delete my-browser docs + # Mini CDP connection load test (10s) cat <<'TS' | kernel browsers playwright execute my-browser const start = Date.now(); diff --git a/cmd/browsers.go b/cmd/browsers.go index d8f8edb5..be08d098 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -90,11 +90,6 @@ type BrowserLogService interface { StreamStreaming(ctx context.Context, idOrName string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) } -// BrowserPlaywrightService defines the subset we use for Playwright execution. -type BrowserPlaywrightService interface { - Execute(ctx context.Context, idOrName string, body kernel.BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *kernel.BrowserPlaywrightExecuteResponse, err error) -} - // BrowserComputerService defines the subset we use for OS-level mouse & screen. type BrowserComputerService interface { Batch(ctx context.Context, idOrName string, body kernel.BrowserComputerBatchParams, opts ...option.RequestOption) (err error) @@ -517,16 +512,17 @@ type BrowsersUpdateInput struct { // BrowsersCmd is a cobra-independent command handler for browsers operations. type BrowsersCmd struct { - browsers BrowsersService - replays BrowserReplaysService - fs BrowserFSService - fsWatch BrowserFWatchService - process BrowserProcessService - logs BrowserLogService - computer BrowserComputerService - playwright BrowserPlaywrightService - telemetry BrowserTelemetryService - webmcp BrowserWebMCPService + browsers BrowsersService + replays BrowserReplaysService + fs BrowserFSService + fsWatch BrowserFWatchService + process BrowserProcessService + logs BrowserLogService + computer BrowserComputerService + playwright BrowserPlaywrightService + playwrightExecutors BrowserPlaywrightExecutorService + telemetry BrowserTelemetryService + webmcp BrowserWebMCPService } type BrowsersListInput struct { @@ -1836,64 +1832,6 @@ type BrowsersFSWatchEventsInput struct { WatchID string } -// Playwright -type BrowsersPlaywrightExecuteInput struct { - Identifier string - Code string - Timeout int64 - Output string -} - -func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrightExecuteInput) error { - if err := validateJSONOutput(in.Output); err != nil { - return err - } - - if b.playwright == nil { - pterm.Error.Println("playwright service not available") - return nil - } - br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - params := kernel.BrowserPlaywrightExecuteParams{Code: in.Code} - if in.Timeout > 0 { - params.TimeoutSec = kernel.Opt(in.Timeout) - } - res, err := b.playwright.Execute(ctx, br.SessionID, params) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - if in.Output == "json" { - return util.PrintPrettyJSON(res) - } - - rows := pterm.TableData{{"Property", "Value"}, {"Success", fmt.Sprintf("%t", res.Success)}} - PrintTableNoPad(rows, true) - - if res.Stdout != "" { - pterm.Info.Println("stdout:") - fmt.Println(res.Stdout) - } - if res.Stderr != "" { - pterm.Info.Println("stderr:") - fmt.Fprintln(os.Stderr, res.Stderr) - } - if res.Result != nil { - bs, err := json.MarshalIndent(res.Result, "", " ") - if err == nil { - pterm.Info.Println("result:") - fmt.Println(string(bs)) - } - } - if !res.Success && res.Error != "" { - pterm.Error.Printf("error: %s\n", res.Error) - } - return nil -} - // REPL type BrowsersReplInput struct { Identifier string @@ -3201,13 +3139,7 @@ func init() { computerRoot.AddCommand(computerClick, computerMove, computerScreenshot, computerType, computerPressKey, computerScroll, computerDrag, computerSetCursor, computerGetMousePosition, computerBatch, computerReadClipboard, computerWriteClipboard) browsersCmd.AddCommand(computerRoot) - // playwright - playwrightRoot := &cobra.Command{Use: "playwright", Short: "Playwright operations"} - playwrightExecute := &cobra.Command{Use: "execute [code]", Short: "Execute Playwright/TypeScript code against the browser", Args: cobra.MinimumNArgs(1), RunE: runBrowsersPlaywrightExecute} - playwrightExecute.Flags().Int64("timeout", 0, "Maximum execution time in seconds (default per server)") - addJSONOutputFlag(playwrightExecute) - playwrightRoot.AddCommand(playwrightExecute) - browsersCmd.AddCommand(playwrightRoot) + browsersCmd.AddCommand(newBrowsersPlaywrightCommand()) // repl replCmd := &cobra.Command{ @@ -3828,33 +3760,6 @@ func runBrowsersFSWatchEvents(cmd *cobra.Command, args []string) error { return b.FSWatchEvents(cmd.Context(), BrowsersFSWatchEventsInput{Identifier: args[0], WatchID: args[1]}) } -func runBrowsersPlaywrightExecute(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - - var code string - if len(args) >= 2 { - code = strings.Join(args[1:], " ") - } else { - // Read code from stdin - stat, _ := os.Stdin.Stat() - if (stat.Mode() & os.ModeCharDevice) != 0 { - pterm.Error.Println("no code provided. Provide code as an argument or pipe via stdin") - return nil - } - data, err := io.ReadAll(os.Stdin) - if err != nil { - pterm.Error.Printf("failed to read stdin: %v\n", err) - return nil - } - code = string(data) - } - timeout, _ := cmd.Flags().GetInt64("timeout") - output, _ := cmd.Flags().GetString("output") - b := BrowsersCmd{browsers: &svc, playwright: &svc.Playwright} - return b.PlaywrightExecute(cmd.Context(), BrowsersPlaywrightExecuteInput{Identifier: args[0], Code: strings.TrimSpace(code), Timeout: timeout, Output: output}) -} - func runBrowsersRepl(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers diff --git a/cmd/browsers_playwright.go b/cmd/browsers_playwright.go new file mode 100644 index 00000000..1911bc91 --- /dev/null +++ b/cmd/browsers_playwright.go @@ -0,0 +1,267 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/param" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// BrowserPlaywrightService defines the subset we use for Playwright execution. +type BrowserPlaywrightService interface { + Execute(ctx context.Context, idOrName string, body kernel.BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *kernel.BrowserPlaywrightExecuteResponse, err error) +} + +// BrowserPlaywrightExecutorService defines the subset we use for Playwright executors. +type BrowserPlaywrightExecutorService interface { + List(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.ExecutorList, error) + Delete(ctx context.Context, name string, params kernel.BrowserPlaywrightExecutorDeleteParams, opts ...option.RequestOption) error +} + +const playwrightExecutorsLong = `Every Playwright call runs in an executor: a dedicated process with its own +browser connection. Calls on different executors run concurrently; calls on +one executor run one at a time. A timeout or crash in one executor does not +affect the others. + +The 'default' executor always exists and binds 'page' to the active tab. Any +other name is a named executor: the first call with a new name creates it, and +it owns a background tab in the default browser context that 'page' is bound +to on every later call. Executor code can still reach other tabs through +'context' and 'browser'. Use named executors to drive several tabs of one +browser in parallel. + +A browser can have at most 8 named executors (the default executor does not +count); a call that would create another fails with HTTP 409. Named executors +are not removed automatically while the browser runs, so delete the ones you no +longer need.` + +type BrowsersPlaywrightExecuteInput struct { + Identifier string + Code string + Executor string + Timeout int64 + Output string +} + +type BrowsersPlaywrightExecutorsListInput struct { + Identifier string + Output string +} + +type BrowsersPlaywrightExecutorsDeleteInput struct { + Identifier string + Name string + CloseTab param.Opt[bool] +} + +func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrightExecuteInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + if b.playwright == nil { + pterm.Error.Println("playwright service not available") + return nil + } + br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + params := kernel.BrowserPlaywrightExecuteParams{Code: in.Code} + if in.Executor != "" { + params.Executor = kernel.Opt(in.Executor) + } + if in.Timeout > 0 { + params.TimeoutSec = kernel.Opt(in.Timeout) + } + res, err := b.playwright.Execute(ctx, br.SessionID, params) + if err != nil { + return playwrightExecuteError(err, in) + } + + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + + rows := pterm.TableData{{"Property", "Value"}, {"Success", fmt.Sprintf("%t", res.Success)}} + if res.JSON.Tab.Valid() { + rows = append(rows, []string{"Tab Target ID", res.Tab.TargetID}, []string{"Tab Created", fmt.Sprintf("%t", res.Tab.Created)}) + } + PrintTableNoPad(rows, true) + + if res.Stdout != "" { + pterm.Info.Println("stdout:") + fmt.Println(res.Stdout) + } + if res.Stderr != "" { + pterm.Info.Println("stderr:") + fmt.Fprintln(os.Stderr, res.Stderr) + } + if res.Result != nil { + bs, err := json.MarshalIndent(res.Result, "", " ") + if err == nil { + pterm.Info.Println("result:") + fmt.Println(string(bs)) + } + } + if !res.Success && res.Error != "" { + pterm.Error.Printf("error: %s\n", res.Error) + } + return nil +} + +// playwrightExecuteError points at the executors commands when a named +// executor call is rejected with 409 because the browser is at its executor limit. +func playwrightExecuteError(err error, in BrowsersPlaywrightExecuteInput) error { + var apiErr *kernel.Error + if in.Executor == "" || !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusConflict { + return util.CleanedUpSdkError{Err: err} + } + var body struct { + Message string `json:"message"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) != nil || body.Message == "" { + return util.CleanedUpSdkError{Err: err} + } + // Not %w: the root handler re-renders any wrapped *kernel.Error as "code: message", dropping the hint. + return fmt.Errorf("%s. See them with 'kernel browsers playwright executors list %s' and free one with 'kernel browsers playwright executors delete %s '", + strings.TrimSuffix(body.Message, "."), in.Identifier, in.Identifier) +} + +func (b BrowsersCmd) PlaywrightExecutorsList(ctx context.Context, in BrowsersPlaywrightExecutorsListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + res, err := b.playwrightExecutors.List(ctx, in.Identifier) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + if len(res.Executors) == 0 { + pterm.Info.Println("No Playwright executors found") + return nil + } + rows := pterm.TableData{{"Name", "Busy", "Created At", "Last Used At", "Target ID", "URL"}} + for _, e := range res.Executors { + rows = append(rows, []string{e.Name, strconv.FormatBool(e.Busy), util.FormatLocal(e.CreatedAt), util.FormatLocal(e.LastUsedAt), util.OrDash(e.TargetID), util.OrDash(e.URL)}) + } + PrintTableNoPad(rows, true) + return nil +} + +func (b BrowsersCmd) PlaywrightExecutorsDelete(ctx context.Context, in BrowsersPlaywrightExecutorsDeleteInput) error { + params := kernel.BrowserPlaywrightExecutorDeleteParams{IDOrName: in.Identifier, CloseTab: in.CloseTab} + if err := b.playwrightExecutors.Delete(ctx, in.Name, params); err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Name == "default" { + pterm.Success.Println("Restarted the default Playwright executor") + } else { + pterm.Success.Printf("Deleted Playwright executor %q\n", in.Name) + } + return nil +} + +func newBrowsersPlaywrightCommand() *cobra.Command { + root := &cobra.Command{Use: "playwright", Short: "Playwright operations"} + execute := &cobra.Command{ + Use: "execute [code]", + Short: "Execute Playwright/TypeScript code against the browser", + Long: "Execute Playwright/TypeScript code against the browser.\n\n" + + "Code may be passed as an argument or piped via stdin. It has access to 'page', " + + "'context', and 'browser', and may return a value.\n\n" + playwrightExecutorsLong, + Args: cobra.MinimumNArgs(1), + RunE: runBrowsersPlaywrightExecute, + } + execute.Flags().String("executor", "", "Named executor to run the call in; each owns its own tab (default: the active tab)") + execute.Flags().Int64("timeout", 0, "Maximum execution time in seconds (default per server)") + addJSONOutputFlag(execute) + root.AddCommand(execute, newBrowsersPlaywrightExecutorsCommand()) + return root +} + +func newBrowsersPlaywrightExecutorsCommand() *cobra.Command { + root := &cobra.Command{Use: "executors", Short: "List and delete the Playwright executors of a browser", Long: playwrightExecutorsLong} + list := &cobra.Command{ + Use: "list ", + Short: "List the browser's Playwright executors", + Long: "List the browser's Playwright executors, the default executor first. Each entry reports " + + "whether a call is running on it and, for named executors, the target ID and URL of the tab it owns.", + Args: cobra.ExactArgs(1), + RunE: runBrowsersPlaywrightExecutorsList, + } + addJSONOutputFlag(list) + del := &cobra.Command{ + Use: "delete ", + Short: "Delete a Playwright executor and, by default, close its tab", + Long: "Stop a Playwright executor's process and, by default, close the tab it owns. A call running " + + "on it fails with an error saying the executor was deleted; the name can be reused afterwards.\n\n" + + "Deleting 'default' restarts it instead of removing it: queued and later calls run on a new " + + "process. It owns no tab, so --close-tab has no effect on it.", + Args: cobra.ExactArgs(2), + RunE: runBrowsersPlaywrightExecutorsDelete, + } + del.Flags().Bool("close-tab", true, "Close the tab owned by the executor") + root.AddCommand(list, del) + return root +} + +func runBrowsersPlaywrightExecute(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + + var code string + if len(args) >= 2 { + code = strings.Join(args[1:], " ") + } else { + // Read code from stdin + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + pterm.Error.Println("no code provided. Provide code as an argument or pipe via stdin") + return nil + } + data, err := io.ReadAll(os.Stdin) + if err != nil { + pterm.Error.Printf("failed to read stdin: %v\n", err) + return nil + } + code = string(data) + } + executor, _ := cmd.Flags().GetString("executor") + timeout, _ := cmd.Flags().GetInt64("timeout") + output, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc, playwright: &svc.Playwright} + return b.PlaywrightExecute(cmd.Context(), BrowsersPlaywrightExecuteInput{Identifier: args[0], Code: strings.TrimSpace(code), Executor: executor, Timeout: timeout, Output: output}) +} + +func runBrowsersPlaywrightExecutorsList(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + client := getKernelClient(cmd) + b := BrowsersCmd{playwrightExecutors: &client.Browsers.Playwright.Executors} + return b.PlaywrightExecutorsList(cmd.Context(), BrowsersPlaywrightExecutorsListInput{Identifier: args[0], Output: output}) +} + +func runBrowsersPlaywrightExecutorsDelete(cmd *cobra.Command, args []string) error { + var closeTab param.Opt[bool] + if cmd.Flags().Changed("close-tab") { + value, _ := cmd.Flags().GetBool("close-tab") + closeTab = kernel.Opt(value) + } + client := getKernelClient(cmd) + b := BrowsersCmd{playwrightExecutors: &client.Browsers.Playwright.Executors} + return b.PlaywrightExecutorsDelete(cmd.Context(), BrowsersPlaywrightExecutorsDeleteInput{Identifier: args[0], Name: args[1], CloseTab: closeTab}) +} diff --git a/cmd/browsers_playwright_test.go b/cmd/browsers_playwright_test.go new file mode 100644 index 00000000..4efedd98 --- /dev/null +++ b/cmd/browsers_playwright_test.go @@ -0,0 +1,315 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func executePlaywrightCommand(t *testing.T, handler http.HandlerFunc, args ...string) (string, string, error) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test"), option.WithMaxRetries(0)) + root := &cobra.Command{Use: "kernel", SilenceErrors: true, SilenceUsage: true} + root.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client)) + root.AddCommand(newBrowsersPlaywrightCommand()) + root.SetArgs(append([]string{"playwright"}, args...)) + buf := capturePtermOutput(t) + var err error + stdout := captureStdout(t, func() { err = root.Execute() }) + return stdout, buf.String(), err +} + +const playwrightExecutorsFixture = `{"executors":[{"name":"default","busy":false,"created_at":"2026-01-02T03:04:05Z","last_used_at":"2026-01-02T03:05:05Z"},{"name":"checkout","busy":true,"created_at":"2026-01-02T03:06:05Z","last_used_at":"2026-01-02T03:07:05Z","target_id":"ABCDEF0123456789","url":"https://example.com/cart"}]}` + +func TestPlaywrightCommandWiring(t *testing.T) { + for _, path := range [][]string{{"execute"}, {"executors", "list"}, {"executors", "delete"}} { + name := path[len(path)-1] + cmd, remaining, err := rootCmd.Find(append([]string{"browsers", "playwright"}, path...)) + require.NoError(t, err) + require.Empty(t, remaining) + assert.Equal(t, name, cmd.Name()) + assert.NotNil(t, cmd.RunE) + assert.False(t, isAuthExempt(cmd)) + } + + execute, _, err := rootCmd.Find([]string{"browsers", "playwright", "execute"}) + require.NoError(t, err) + executor := execute.Flags().Lookup("executor") + require.NotNil(t, executor) + assert.Equal(t, "", executor.DefValue) + assert.Contains(t, execute.Long, "8 named executors") + assert.NotNil(t, execute.Flags().Lookup("timeout")) + assert.NotNil(t, execute.Flags().Lookup("output")) + + del, _, err := rootCmd.Find([]string{"browsers", "playwright", "executors", "delete"}) + require.NoError(t, err) + closeTab := del.Flags().Lookup("close-tab") + require.NotNil(t, closeTab) + assert.Equal(t, "true", closeTab.DefValue) + assert.Contains(t, del.Long, "default") + + list, _, err := rootCmd.Find([]string{"browsers", "playwright", "executors", "list"}) + require.NoError(t, err) + assert.NotNil(t, list.Flags().Lookup("output")) +} + +func TestPlaywrightExecuteExecutorParam(t *testing.T) { + for _, tc := range []struct { + name string + flags []string + executor string + }{ + {"omitted", nil, ""}, + {"named", []string{"--executor", "checkout"}, "checkout"}, + } { + t.Run(tc.name, func(t *testing.T) { + var body struct { + Code string `json:"code"` + Executor *string `json:"executor"` + Timeout *int64 `json:"timeout_sec"` + } + calls := 0 + stdout, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/browsers/my-browser": + fmt.Fprint(w, `{"session_id":"session123"}`) + case r.Method == http.MethodPost && r.URL.Path == "/browsers/session123/playwright/execute": + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + fmt.Fprint(w, `{"success":true,"result":{"title":"Example"},"tab":{"target_id":"ABCDEF0123456789","created":true}}`) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + }, append([]string{"execute", "my-browser", "return await page.title()", "--timeout", "30"}, tc.flags...)...) + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.Equal(t, "return await page.title()", body.Code) + if tc.executor == "" { + assert.Nil(t, body.Executor) + } else { + require.NotNil(t, body.Executor) + assert.Equal(t, tc.executor, *body.Executor) + } + require.NotNil(t, body.Timeout) + assert.Equal(t, int64(30), *body.Timeout) + for _, value := range []string{"Success", "true", "Tab Target ID", "ABCDEF0123456789", "Tab Created"} { + assert.Contains(t, table, value) + } + assert.Contains(t, stdout, `"title": "Example"`) + }) + } +} + +func TestPlaywrightExecuteOutput(t *testing.T) { + for _, tc := range []struct { + name string + response string + json bool + wantTab bool + }{ + {"without tab", `{"success":false,"error":"boom"}`, false, false}, + {"with tab", `{"success":true,"tab":{"target_id":"ABCDEF0123456789","created":false}}`, false, true}, + {"json", `{"success":true,"result":42,"tab":{"target_id":"ABCDEF0123456789","created":true}}`, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + args := []string{"execute", "session123", "return 1"} + if tc.json { + args = append(args, "-o", "json") + } + stdout, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"session_id":"session123"}`) + return + } + fmt.Fprint(w, tc.response) + }, args...) + require.NoError(t, err) + if tc.json { + assert.JSONEq(t, tc.response, stdout) + assert.Empty(t, table) + return + } + if tc.wantTab { + assert.Contains(t, table, "Tab Target ID") + assert.Contains(t, table, "ABCDEF0123456789") + assert.Contains(t, table, "Tab Created") + } else { + assert.NotContains(t, table, "Tab Target ID") + assert.Contains(t, table, "boom") + } + }) + } +} + +func TestPlaywrightExecuteExecutorLimit(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"session_id":"session123"}`) + return + } + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"message":"Browser already has 8 named Playwright executors.",`+ + `"executors":[{"name":"default","busy":false,"created_at":"2026-01-02T03:04:05Z","last_used_at":"2026-01-02T03:04:05Z"}]}`) + }, "execute", "my-browser", "return 1", "--executor", "ninth") + require.EqualError(t, err, "Browser already has 8 named Playwright executors. "+ + "See them with 'kernel browsers playwright executors list my-browser' and "+ + "free one with 'kernel browsers playwright executors delete my-browser '") + assert.NotContains(t, err.Error(), "\n") + // The root error handler wraps command errors again before printing them. + assert.Equal(t, err.Error(), util.CleanedUpSdkError{Err: err}.Error()) +} + +func TestPlaywrightExecuteOtherErrors(t *testing.T) { + for _, tc := range []struct { + name string + flags []string + status int + body string + want string + }{ + {"not conflict", []string{"--executor", "bad name"}, http.StatusBadRequest, `{"code":"invalid_request","message":"Invalid executor name"}`, "invalid_request: Invalid executor name"}, + {"conflict without message", []string{"--executor", "ninth"}, http.StatusConflict, `{"code":"conflict","message":""}`, "conflict: "}, + {"conflict without executor", nil, http.StatusConflict, `{"code":"conflict","message":"Browser is busy"}`, "conflict: Browser is busy"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"session_id":"session123"}`) + return + } + w.WriteHeader(tc.status) + fmt.Fprint(w, tc.body) + }, append([]string{"execute", "session123", "return 1"}, tc.flags...)...) + require.EqualError(t, err, tc.want) + }) + } +} + +func TestPlaywrightExecutorsList(t *testing.T) { + for _, identifier := range []string{"my-browser", "session123"} { + for _, flags := range [][]string{nil, {"-o", "json"}, {"--output", "json"}} { + t.Run(identifier+strings.Join(flags, ""), func(t *testing.T) { + calls := 0 + stdout, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/browsers/"+identifier+"/playwright/executors", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, playwrightExecutorsFixture) + }, append([]string{"executors", "list", identifier}, flags...)...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + if len(flags) > 0 { + assert.JSONEq(t, playwrightExecutorsFixture, stdout) + assert.Empty(t, table) + return + } + for _, value := range []string{"Name", "Busy", "Created At", "Last Used At", "Target ID", "URL", "default", "false", "checkout", "true", "ABCDEF0123456789", "https://example.com/cart"} { + assert.Contains(t, table, value) + } + rows := strings.Split(strings.TrimSpace(table), "\n") + require.Len(t, rows, 3) + assert.Contains(t, rows[1], "default") + assert.NotContains(t, rows[1], "ABCDEF0123456789") + assert.Contains(t, rows[2], "checkout") + }) + } + } +} + +func TestPlaywrightExecutorsListEmpty(t *testing.T) { + _, table, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"executors":[]}`) + }, "executors", "list", "my-browser") + require.NoError(t, err) + assert.Contains(t, table, "No Playwright executors found") +} + +func TestPlaywrightExecutorsDelete(t *testing.T) { + for _, tc := range []struct { + name string + executor string + flags []string + closeTab string + want string + }{ + {"default close", "checkout", nil, "", `Deleted Playwright executor "checkout"`}, + {"keep tab", "checkout", []string{"--close-tab=false"}, "false", `Deleted Playwright executor "checkout"`}, + {"explicit close", "checkout", []string{"--close-tab"}, "true", `Deleted Playwright executor "checkout"`}, + {"default executor", "default", nil, "", "Restarted the default Playwright executor"}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + stdout, out, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/browsers/my-browser/playwright/executors/"+tc.executor, r.URL.Path) + assert.Equal(t, tc.closeTab, r.URL.Query().Get("close_tab")) + w.WriteHeader(http.StatusNoContent) + }, append([]string{"executors", "delete", "my-browser", tc.executor}, tc.flags...)...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.Empty(t, stdout) + assert.Contains(t, out, tc.want) + }) + } +} + +func TestPlaywrightExecutorsErrors(t *testing.T) { + for _, tc := range []struct { + args []string + status int + body string + want string + }{ + {[]string{"executors", "list", "missing"}, http.StatusNotFound, `{"code":"not_found","message":"Browser not found"}`, "not_found: Browser not found"}, + {[]string{"executors", "delete", "my-browser", "missing"}, http.StatusNotFound, `{"code":"not_found","message":"Executor not found"}`, "not_found: Executor not found"}, + {[]string{"executors", "delete", "my-browser", "bad name"}, http.StatusBadRequest, `{"code":"invalid_request","message":"Invalid executor name"}`, "invalid_request: Invalid executor name"}, + } { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + fmt.Fprint(w, tc.body) + }, tc.args...) + require.EqualError(t, err, tc.want) + }) + } +} + +func TestPlaywrightExecutorsInvalidInput(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"executors", "list"}, "accepts 1 arg"}, + {[]string{"executors", "list", "browser", "-o", "yaml"}, "unsupported --output"}, + {[]string{"executors", "delete", "browser"}, "accepts 2 arg"}, + {[]string{"executors", "delete", "browser", "a", "b"}, "accepts 2 arg"}, + } { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + _, _, err := executePlaywrightCommand(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("invalid input reached API") + }, tc.args...) + require.ErrorContains(t, err, tc.want) + }) + } +}