Skip to content

Commit 6d144ca

Browse files
rgarciatembo[bot]
andauthored
ad hoc playwright code exec API (#84)
- add node, playwright, and tsx to the images - add an endpoint that takes in code and executes it within a shim that provides a `page` Playwright object <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds POST /playwright/execute to run user TypeScript/Playwright code via tsx against Chromium, updates images with Node.js 22 + Playwright, and includes an e2e test. > > - **API**: > - **New Endpoint**: `POST /playwright/execute` to run Playwright/TypeScript code with timeout; returns `{success,result,error,stdout,stderr}` (`server/cmd/api/api/playwright.go`). > - **OpenAPI + Generated Client/Server**: Spec and codegen updated to expose request/response types and routing (`server/openapi.yaml`, `server/lib/oapi/oapi.go`). > - **Runtime**: > - **Executor**: Add `server/runtime/playwright-executor.ts` invoked via `tsx`, connecting to `ws://127.0.0.1:9222` and evaluating provided code. > - **Containers**: > - Install Node.js 22, `typescript`, `playwright-core`, and `tsx`; copy executor into image (`images/chromium-headful/Dockerfile`, `images/chromium-headless/image/Dockerfile`). > - **Tests**: > - Add e2e test executing a simple Playwright script and asserting result (`server/e2e/e2e_chromium_test.go`). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8251b07. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com>
1 parent a653a87 commit 6d144ca

File tree

8 files changed

+775
-109
lines changed

8 files changed

+775
-109
lines changed

images/chromium-headful/Dockerfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ RUN set -eux; \
5555

5656
FROM ghcr.io/onkernel/neko/base:3.0.8-v1.3.0 AS neko
5757
# ^--- now has event.SYSTEM_PONG with legacy support to keepalive
58+
FROM node:22-bullseye-slim AS node-22
5859
FROM docker.io/ubuntu:22.04
5960

6061
ENV DEBIAN_FRONTEND=noninteractive
@@ -161,6 +162,20 @@ RUN set -eux; \
161162
RUN add-apt-repository -y ppa:xtradeb/apps
162163
RUN apt update -y && apt install -y chromium sqlite3
163164

165+
# install Node.js 22.x by copying from the node:22-bullseye-slim stage
166+
COPY --from=node-22 /usr/local/bin/node /usr/local/bin/node
167+
COPY --from=node-22 /usr/local/lib/node_modules /usr/local/lib/node_modules
168+
# Recreate symlinks for npm/npx/corepack to point into node_modules
169+
RUN set -eux; \
170+
ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm; \
171+
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx; \
172+
if [ -e /usr/local/lib/node_modules/corepack/dist/corepack.js ]; then \
173+
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack; \
174+
fi
175+
176+
# Install TypeScript and Playwright globally
177+
RUN npm install -g typescript playwright-core tsx
178+
164179
# setup desktop env & app
165180
ENV DISPLAY_NUM=1
166181
ENV HEIGHT=768
@@ -185,6 +200,9 @@ COPY images/chromium-headful/supervisor/services/ /etc/supervisor/conf.d/service
185200
COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api
186201
COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher
187202

203+
# Copy the Playwright executor runtime
204+
COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts
205+
188206
RUN useradd -m -s /bin/bash kernel
189207

190208
ENTRYPOINT [ "/wrapper.sh" ]

images/chromium-headless/image/Dockerfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ RUN GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
2121
RUN GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
2222
go build -ldflags="-s -w" -o /out/chromium-launcher ./cmd/chromium-launcher
2323

24+
FROM node:22-bullseye-slim AS node-22
2425
FROM docker.io/ubuntu:22.04
2526

2627
RUN set -xe; \
@@ -73,6 +74,20 @@ RUN set -eux; \
7374
# Remove upower to prevent spurious D-Bus activations and logs
7475
RUN apt-get -yqq purge upower || true && rm -rf /var/lib/apt/lists/*
7576

77+
# install Node.js 22.x by copying from the node:22-bullseye-slim stage
78+
COPY --from=node-22 /usr/local/bin/node /usr/local/bin/node
79+
COPY --from=node-22 /usr/local/lib/node_modules /usr/local/lib/node_modules
80+
# Recreate symlinks for npm/npx/corepack to point into node_modules
81+
RUN set -eux; \
82+
ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm; \
83+
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx; \
84+
if [ -e /usr/local/lib/node_modules/corepack/dist/corepack.js ]; then \
85+
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack; \
86+
fi
87+
88+
# Install TypeScript and Playwright globally
89+
RUN npm install -g typescript playwright-core tsx
90+
7691
ENV WITHDOCKER=true
7792

7893
# Create a non-root user with a home directory
@@ -93,4 +108,7 @@ COPY images/chromium-headless/image/supervisor/services/ /etc/supervisor/conf.d/
93108
COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api
94109
COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher
95110

111+
# Copy the Playwright executor runtime
112+
COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts
113+
96114
ENTRYPOINT [ "/usr/bin/wrapper.sh" ]

server/cmd/api/api/api.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ type ApiService struct {
3939

4040
// inputMu serializes input-related operations (mouse, keyboard, screenshot)
4141
inputMu sync.Mutex
42+
43+
// playwrightMu serializes Playwright code execution (only one execution at a time)
44+
playwrightMu sync.Mutex
4245
}
4346

4447
var _ oapi.StrictServerInterface = (*ApiService)(nil)

server/cmd/api/api/playwright.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"time"
10+
11+
"github.com/onkernel/kernel-images/server/lib/logger"
12+
"github.com/onkernel/kernel-images/server/lib/oapi"
13+
)
14+
15+
// ExecutePlaywrightCode implements the Playwright code execution endpoint
16+
func (s *ApiService) ExecutePlaywrightCode(ctx context.Context, request oapi.ExecutePlaywrightCodeRequestObject) (oapi.ExecutePlaywrightCodeResponseObject, error) {
17+
// Serialize Playwright execution - only one execution at a time
18+
s.playwrightMu.Lock()
19+
defer s.playwrightMu.Unlock()
20+
21+
log := logger.FromContext(ctx)
22+
23+
// Validate request
24+
if request.Body == nil || request.Body.Code == "" {
25+
return oapi.ExecutePlaywrightCode400JSONResponse{
26+
BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{
27+
Message: "code is required",
28+
},
29+
}, nil
30+
}
31+
32+
// Determine timeout (default to 60 seconds)
33+
timeout := 60 * time.Second
34+
if request.Body.TimeoutSec != nil && *request.Body.TimeoutSec > 0 {
35+
timeout = time.Duration(*request.Body.TimeoutSec) * time.Second
36+
}
37+
38+
// Create a temporary file for the user code
39+
tmpFile, err := os.CreateTemp("", "playwright-code-*.ts")
40+
if err != nil {
41+
log.Error("failed to create temp file", "error", err)
42+
return oapi.ExecutePlaywrightCode500JSONResponse{
43+
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
44+
Message: fmt.Sprintf("failed to create temp file: %v", err),
45+
},
46+
}, nil
47+
}
48+
tmpFilePath := tmpFile.Name()
49+
defer os.Remove(tmpFilePath) // Clean up the temp file
50+
51+
// Write the user code to the temp file
52+
if _, err := tmpFile.WriteString(request.Body.Code); err != nil {
53+
tmpFile.Close()
54+
log.Error("failed to write code to temp file", "error", err)
55+
return oapi.ExecutePlaywrightCode500JSONResponse{
56+
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
57+
Message: fmt.Sprintf("failed to write code to temp file: %v", err),
58+
},
59+
}, nil
60+
}
61+
tmpFile.Close()
62+
63+
// Create context with timeout
64+
execCtx, cancel := context.WithTimeout(ctx, timeout)
65+
defer cancel()
66+
67+
// Execute the Playwright code via the executor script
68+
cmd := exec.CommandContext(execCtx, "tsx", "/usr/local/lib/playwright-executor.ts", tmpFilePath)
69+
70+
output, err := cmd.CombinedOutput()
71+
72+
if err != nil {
73+
if execCtx.Err() == context.DeadlineExceeded {
74+
log.Error("playwright execution timed out", "timeout", timeout)
75+
success := false
76+
errorMsg := fmt.Sprintf("execution timed out after %v", timeout)
77+
return oapi.ExecutePlaywrightCode200JSONResponse{
78+
Success: success,
79+
Error: &errorMsg,
80+
}, nil
81+
}
82+
83+
log.Error("playwright execution failed", "error", err, "output", string(output))
84+
85+
// Try to parse the error output as JSON
86+
var result struct {
87+
Success bool `json:"success"`
88+
Result interface{} `json:"result,omitempty"`
89+
Error string `json:"error,omitempty"`
90+
Stack string `json:"stack,omitempty"`
91+
}
92+
if jsonErr := json.Unmarshal(output, &result); jsonErr == nil {
93+
success := result.Success
94+
errorMsg := result.Error
95+
stderr := string(output)
96+
return oapi.ExecutePlaywrightCode200JSONResponse{
97+
Success: success,
98+
Error: &errorMsg,
99+
Stderr: &stderr,
100+
}, nil
101+
}
102+
103+
// If we can't parse the output, return a generic error
104+
success := false
105+
errorMsg := fmt.Sprintf("execution failed: %v", err)
106+
stderr := string(output)
107+
return oapi.ExecutePlaywrightCode200JSONResponse{
108+
Success: success,
109+
Error: &errorMsg,
110+
Stderr: &stderr,
111+
}, nil
112+
}
113+
114+
// Parse successful output
115+
var result struct {
116+
Success bool `json:"success"`
117+
Result interface{} `json:"result,omitempty"`
118+
}
119+
if err := json.Unmarshal(output, &result); err != nil {
120+
log.Error("failed to parse playwright output", "error", err, "output", string(output))
121+
success := false
122+
errorMsg := fmt.Sprintf("failed to parse output: %v", err)
123+
stdout := string(output)
124+
return oapi.ExecutePlaywrightCode200JSONResponse{
125+
Success: success,
126+
Error: &errorMsg,
127+
Stdout: &stdout,
128+
}, nil
129+
}
130+
131+
return oapi.ExecutePlaywrightCode200JSONResponse{
132+
Success: result.Success,
133+
Result: &result.Result,
134+
}, nil
135+
}

server/e2e/e2e_chromium_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"bytes"
66
"context"
77
"encoding/base64"
8+
"encoding/json"
89
"fmt"
910
"io"
1011
"mime/multipart"
@@ -729,3 +730,83 @@ func getXvfbResolution(ctx context.Context) (width, height int, err error) {
729730

730731
return 0, 0, fmt.Errorf("Xvfb process not found in ps aux output")
731732
}
733+
734+
func TestPlaywrightExecuteAPI(t *testing.T) {
735+
image := headlessImage
736+
name := containerName + "-playwright-api"
737+
738+
logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo}))
739+
baseCtx := logctx.AddToContext(context.Background(), logger)
740+
741+
if _, err := exec.LookPath("docker"); err != nil {
742+
require.NoError(t, err, "docker not available: %v", err)
743+
}
744+
745+
// Clean slate
746+
_ = stopContainer(baseCtx, name)
747+
748+
env := map[string]string{}
749+
750+
// Start container
751+
_, exitCh, err := runContainer(baseCtx, image, name, env)
752+
require.NoError(t, err, "failed to start container: %v", err)
753+
defer stopContainer(baseCtx, name)
754+
755+
ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute)
756+
defer cancel()
757+
758+
require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err)
759+
760+
client, err := apiClient()
761+
require.NoError(t, err)
762+
763+
// Test simple Playwright script that navigates to a page and returns the title
764+
playwrightCode := `
765+
await page.goto('https://example.com');
766+
const title = await page.title();
767+
return title;
768+
`
769+
770+
logger.Info("[test]", "action", "executing playwright code")
771+
req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{
772+
Code: playwrightCode,
773+
}
774+
775+
rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req)
776+
require.NoError(t, err, "playwright execute request error: %v", err)
777+
require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for playwright execute: %s body=%s", rsp.Status(), string(rsp.Body))
778+
require.NotNil(t, rsp.JSON200, "expected JSON200 response, got nil")
779+
780+
// Log the full response for debugging
781+
if !rsp.JSON200.Success {
782+
var errorMsg string
783+
if rsp.JSON200.Error != nil {
784+
errorMsg = *rsp.JSON200.Error
785+
}
786+
var stdout, stderr string
787+
if rsp.JSON200.Stdout != nil {
788+
stdout = *rsp.JSON200.Stdout
789+
}
790+
if rsp.JSON200.Stderr != nil {
791+
stderr = *rsp.JSON200.Stderr
792+
}
793+
logger.Error("[test]", "error", errorMsg, "stdout", stdout, "stderr", stderr)
794+
}
795+
796+
require.True(t, rsp.JSON200.Success, "expected success=true, got success=false. Error: %s", func() string {
797+
if rsp.JSON200.Error != nil {
798+
return *rsp.JSON200.Error
799+
}
800+
return "nil"
801+
}())
802+
require.NotNil(t, rsp.JSON200.Result, "expected result to be non-nil")
803+
804+
// Verify the result contains "Example Domain" (the title of example.com)
805+
resultBytes, err := json.Marshal(rsp.JSON200.Result)
806+
require.NoError(t, err, "failed to marshal result: %v", err)
807+
resultStr := string(resultBytes)
808+
logger.Info("[test]", "result", resultStr)
809+
require.Contains(t, resultStr, "Example Domain", "expected result to contain 'Example Domain'")
810+
811+
logger.Info("[test]", "result", "playwright execute API test passed")
812+
}

0 commit comments

Comments
 (0)