Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1009,8 +1009,12 @@ Destinations are the OTLP/HTTP endpoints sessions export to. They belong to the
### Browser Playwright

- `kernel browsers playwright execute <id> [code]` - Execute Playwright/TypeScript code against the browser
- `--executor <name>` - 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 <seconds>` - Maximum execution time in seconds (defaults server-side)
- If `[code]` is omitted, code is read from stdin
- `kernel browsers playwright executors list <id>` - List the browser's Playwright executors (default first) with busy state, timestamps, and the tab each named executor owns
- `kernel browsers playwright executors delete <id> <executor>` - 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

Expand Down Expand Up @@ -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();
Expand Down
21 changes: 12 additions & 9 deletions cmd/browsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"github.com/kernel/kernel-go-sdk/option"
"github.com/kernel/kernel-go-sdk/packages/pagination"
"github.com/kernel/kernel-go-sdk/packages/ssestream"
"github.com/kernel/kernel-go-sdk/shared"

Check failure on line 27 in cmd/browsers.go

View workflow job for this annotation

GitHub Actions / test

github.com/kernel/kernel-go-sdk-staging@v0.86.1-0.20261005185731-8af651f4046b: invalid version: git ls-remote -q https://github.com/kernel/kernel-go-sdk-staging in /home/runner/go/pkg/mod/cache/vcs/ca6c0a60ab0322261a0d7c28ab4c61836e3c5294f28b48a3ced2a75898796851: exit status 128:
"github.com/pterm/pterm"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
Expand Down Expand Up @@ -525,6 +525,7 @@
logs BrowserLogService
computer BrowserComputerService
playwright BrowserPlaywrightService
executors BrowserPlaywrightExecutorService
telemetry BrowserTelemetryService
webmcp BrowserWebMCPService
}
Expand Down Expand Up @@ -1840,6 +1841,7 @@
type BrowsersPlaywrightExecuteInput struct {
Identifier string
Code string
Executor string
Timeout int64
Output string
}
Expand All @@ -1858,19 +1860,25 @@
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" {
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 != "" {
Expand Down Expand Up @@ -3201,13 +3209,7 @@
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 <id> [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{
Expand Down Expand Up @@ -3849,10 +3851,11 @@
}
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 {
Expand Down
182 changes: 182 additions & 0 deletions cmd/browsers_playwright_executors.go
Original file line number Diff line number Diff line change
@@ -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"

Check failure on line 15 in cmd/browsers_playwright_executors.go

View workflow job for this annotation

GitHub Actions / test

github.com/kernel/kernel-go-sdk-staging@v0.86.1-0.20261005185731-8af651f4046b: invalid version: git ls-remote -q https://github.com/kernel/kernel-go-sdk-staging in /home/runner/go/pkg/mod/cache/vcs/ca6c0a60ab0322261a0d7c28ab4c61836e3c5294f28b48a3ced2a75898796851: exit status 128:
"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 <id-or-name> <executor>'")
return errors.New(sb.String())
}

func newBrowsersPlaywrightCommand() *cobra.Command {
root := &cobra.Command{Use: "playwright", Short: "Playwright operations"}
execute := &cobra.Command{
Use: "execute <id> [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 <id-or-name>",
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 <id-or-name> <executor>",
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})
}
Loading
Loading