From 62c5ee5418dc5f7849f20bc7058bef20847ce205 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:14:44 +0000 Subject: [PATCH] Add named Playwright executor support to browsers playwright commands --- README.md | 13 + cmd/browsers.go | 21 +- cmd/browsers_playwright_executors.go | 182 +++++++++++++ cmd/browsers_playwright_executors_test.go | 316 ++++++++++++++++++++++ go.mod | 2 + go.sum | 4 +- 6 files changed, 527 insertions(+), 11 deletions(-) create mode 100644 cmd/browsers_playwright_executors.go create mode 100644 cmd/browsers_playwright_executors_test.go diff --git a/README.md b/README.md index eb863c84..a9dd8d0b 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 1e79b836..246beb5c 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -525,6 +525,7 @@ type BrowsersCmd struct { logs BrowserLogService computer BrowserComputerService playwright BrowserPlaywrightService + executors BrowserPlaywrightExecutorService telemetry BrowserTelemetryService webmcp BrowserWebMCPService } @@ -1840,6 +1841,7 @@ type BrowsersFSWatchEventsInput struct { type BrowsersPlaywrightExecuteInput struct { Identifier string Code string + Executor string Timeout int64 Output string } @@ -1858,12 +1860,15 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh 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 util.CleanedUpSdkError{Err: err} + return playwrightExecuteError(err) } if in.Output == "json" { @@ -1871,6 +1876,9 @@ func (b BrowsersCmd) PlaywrightExecute(ctx context.Context, in BrowsersPlaywrigh } 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 != "" { @@ -3201,13 +3209,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{ @@ -3849,10 +3851,11 @@ func runBrowsersPlaywrightExecute(cmd *cobra.Command, args []string) error { } 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), Timeout: timeout, Output: output}) + return b.PlaywrightExecute(cmd.Context(), BrowsersPlaywrightExecuteInput{Identifier: args[0], Code: strings.TrimSpace(code), Executor: executor, Timeout: timeout, Output: output}) } func runBrowsersRepl(cmd *cobra.Command, args []string) error { diff --git a/cmd/browsers_playwright_executors.go b/cmd/browsers_playwright_executors.go new file mode 100644 index 00000000..ae44f6a8 --- /dev/null +++ b/cmd/browsers_playwright_executors.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "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" +) + +// 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 BrowsersPlaywrightExecutorsListInput struct { + Identifier string + Output string +} + +type BrowsersPlaywrightExecutorsDeleteInput struct { + Identifier string + Name string + CloseTab param.Opt[bool] +} + +func (b BrowsersCmd) PlaywrightExecutorsList(ctx context.Context, in BrowsersPlaywrightExecutorsListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + res, err := b.executors.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.executors.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 +} + +// playwrightExecuteError turns the 409 returned when a call would exceed the +// named executor limit into an error that names the current executors. +func playwrightExecuteError(err error) error { + var apiErr *kernel.Error + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusConflict { + return util.CleanedUpSdkError{Err: err} + } + var body struct { + Message string `json:"message"` + Executors []kernel.Executor `json:"executors"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) != nil || body.Message == "" { + return util.CleanedUpSdkError{Err: err} + } + var sb strings.Builder + sb.WriteString(body.Message) + if len(body.Executors) > 0 { + sb.WriteString("\nCurrent executors:") + for _, e := range body.Executors { + fmt.Fprintf(&sb, "\n %s", e.Name) + if e.Busy { + sb.WriteString(" (busy)") + } + if e.URL != "" { + fmt.Fprintf(&sb, " %s", e.URL) + } + } + } + sb.WriteString("\nDelete one with 'kernel browsers playwright executors delete '") + return errors.New(sb.String()) +} + +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", "", "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)") + 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 runBrowsersPlaywrightExecutorsList(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("output") + client := getKernelClient(cmd) + b := BrowsersCmd{executors: &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{executors: &client.Browsers.Playwright.Executors} + return b.PlaywrightExecutorsDelete(cmd.Context(), BrowsersPlaywrightExecutorsDeleteInput{Identifier: args[0], Name: args[1], CloseTab: closeTab}) +} diff --git a/cmd/browsers_playwright_executors_test.go b/cmd/browsers_playwright_executors_test.go new file mode 100644 index 00000000..9e37772a --- /dev/null +++ b/cmd/browsers_playwright_executors_test.go @@ -0,0 +1,316 @@ +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, executor.Usage, "default") + assert.Contains(t, executor.Usage, "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 + }{ + {"without tab", `{"success":false,"error":"boom"}`, false}, + {"with tab", `{"success":true,"tab":{"target_id":"ABCDEF0123456789","created":false}}`, false}, + {"json", `{"success":true,"result":42,"tab":{"target_id":"ABCDEF0123456789","created":true}}`, true}, + } { + 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 strings.Contains(tc.response, `"tab"`) { + 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"},`+ + `{"name":"checkout","busy":true,"created_at":"2026-01-02T03:04:05Z","last_used_at":"2026-01-02T03:04:05Z","target_id":"T1","url":"https://example.com/cart"}]}`) + }, "execute", "session123", "return 1", "--executor", "ninth") + require.Error(t, err) + msg := err.Error() + assert.Contains(t, msg, "Browser already has 8 named Playwright executors") + assert.Contains(t, msg, "Current executors:") + assert.Contains(t, msg, "default") + assert.Contains(t, msg, "checkout (busy) https://example.com/cart") + assert.Contains(t, msg, "kernel browsers playwright executors delete") + // The root error handler wraps command errors again before printing them. + assert.Equal(t, msg, util.CleanedUpSdkError{Err: err}.Error()) +} + +func TestPlaywrightExecuteOtherErrors(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + want string + }{ + {"not conflict", http.StatusBadRequest, `{"code":"invalid_request","message":"Invalid executor name"}`, "invalid_request: Invalid executor name"}, + {"conflict without message", http.StatusConflict, `{"code":"conflict","message":""}`, "conflict: "}, + } { + 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) + }, "execute", "session123", "return 1", "--executor", "bad name") + 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.Equal(t, 2, strings.Count(rows[1], " - "), rows[1]) + }) + } + } +} + +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) + }) + } +} diff --git a/go.mod b/go.mod index 2696f88c..d4d34c50 100644 --- a/go.mod +++ b/go.mod @@ -61,3 +61,5 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect ) + +replace github.com/kernel/kernel-go-sdk => github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20261005185731-8af651f4046b diff --git a/go.sum b/go.sum index 080d8770..d14d1831 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.117.0 h1:b6/am7RkJyhadi/98pMHAyuiwyEoDD6smZ4V92pUJ40= -github.com/kernel/kernel-go-sdk v0.117.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20261005185731-8af651f4046b h1:I8MS90SZV9lPHXmMbZTeZySwbd5+9CX7p0KK0B1k2OE= +github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20261005185731-8af651f4046b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=