Skip to content

Commit 5e8c82f

Browse files
committed
feat: Add dynamic Playwright code execution API
Co-authored-by: null <>
1 parent 90cae41 commit 5e8c82f

File tree

7 files changed

+331
-0
lines changed

7 files changed

+331
-0
lines changed

images/chromium-headful/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,16 @@ RUN set -eux; \
161161
RUN add-apt-repository -y ppa:xtradeb/apps
162162
RUN apt update -y && apt install -y chromium sqlite3
163163

164+
# Install Node.js 20.x
165+
RUN set -eux; \
166+
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -; \
167+
apt-get install -y nodejs; \
168+
rm -rf /var/lib/apt/lists/*
169+
170+
# Install TypeScript and Playwright globally
171+
RUN npm install -g typescript tsx playwright && \
172+
npx playwright install-deps chromium
173+
164174
# setup desktop env & app
165175
ENV DISPLAY_NUM=1
166176
ENV HEIGHT=768
@@ -183,6 +193,9 @@ COPY images/chromium-headful/supervisor/services/ /etc/supervisor/conf.d/service
183193
COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api
184194
COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher
185195

196+
# Copy the Playwright executor runtime
197+
COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts
198+
186199
RUN useradd -m -s /bin/bash kernel
187200

188201
ENTRYPOINT [ "/wrapper.sh" ]

images/chromium-headless/image/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ RUN set -eux; \
7373
# Remove upower to prevent spurious D-Bus activations and logs
7474
RUN apt-get -yqq purge upower || true && rm -rf /var/lib/apt/lists/*
7575

76+
# Install Node.js 20.x
77+
RUN set -eux; \
78+
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -; \
79+
apt-get install -y nodejs; \
80+
rm -rf /var/lib/apt/lists/*
81+
82+
# Install TypeScript and Playwright globally
83+
RUN npm install -g typescript tsx playwright && \
84+
npx playwright install-deps chromium
85+
7686
ENV WITHDOCKER=true
7787

7888
# Create a non-root user with a home directory
@@ -93,4 +103,7 @@ COPY images/chromium-headless/image/supervisor/services/ /etc/supervisor/conf.d/
93103
COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api
94104
COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher
95105

106+
# Copy the Playwright executor runtime
107+
COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts
108+
96109
ENTRYPOINT [ "/usr/bin/wrapper.sh" ]

server/cmd/api/api/playwright.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"os/exec"
9+
"time"
10+
11+
"github.com/onkernel/kernel-images/server/lib/logger"
12+
)
13+
14+
type ExecutePlaywrightRequest struct {
15+
Code string `json:"code"`
16+
TimeoutSec *int `json:"timeout_sec,omitempty"`
17+
}
18+
19+
type ExecutePlaywrightResult struct {
20+
Success bool `json:"success"`
21+
Result interface{} `json:"result,omitempty"`
22+
Error string `json:"error,omitempty"`
23+
Stdout string `json:"stdout,omitempty"`
24+
Stderr string `json:"stderr,omitempty"`
25+
}
26+
27+
func (s *Service) ExecutePlaywrightCode(w http.ResponseWriter, r *http.Request) {
28+
log := logger.FromContext(r.Context())
29+
30+
var req ExecutePlaywrightRequest
31+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
32+
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
33+
return
34+
}
35+
36+
if req.Code == "" {
37+
http.Error(w, "code is required", http.StatusBadRequest)
38+
return
39+
}
40+
41+
timeout := 30 * time.Second
42+
if req.TimeoutSec != nil && *req.TimeoutSec > 0 {
43+
timeout = time.Duration(*req.TimeoutSec) * time.Second
44+
}
45+
46+
ctx, cancel := context.WithTimeout(r.Context(), timeout)
47+
defer cancel()
48+
49+
cmd := exec.CommandContext(ctx, "tsx", "/usr/local/lib/playwright-executor.ts", req.Code)
50+
51+
output, err := cmd.CombinedOutput()
52+
53+
if err != nil {
54+
if ctx.Err() == context.DeadlineExceeded {
55+
log.Error("playwright execution timed out", "timeout", timeout)
56+
w.Header().Set("Content-Type", "application/json")
57+
w.WriteHeader(http.StatusOK)
58+
json.NewEncoder(w).Encode(ExecutePlaywrightResult{
59+
Success: false,
60+
Error: fmt.Sprintf("execution timed out after %v", timeout),
61+
})
62+
return
63+
}
64+
65+
log.Error("playwright execution failed", "error", err, "output", string(output))
66+
w.Header().Set("Content-Type", "application/json")
67+
w.WriteHeader(http.StatusOK)
68+
69+
var result ExecutePlaywrightResult
70+
if jsonErr := json.Unmarshal(output, &result); jsonErr == nil {
71+
json.NewEncoder(w).Encode(result)
72+
} else {
73+
json.NewEncoder(w).Encode(ExecutePlaywrightResult{
74+
Success: false,
75+
Error: fmt.Sprintf("execution failed: %v", err),
76+
Stderr: string(output),
77+
})
78+
}
79+
return
80+
}
81+
82+
var result ExecutePlaywrightResult
83+
if err := json.Unmarshal(output, &result); err != nil {
84+
log.Error("failed to parse playwright output", "error", err, "output", string(output))
85+
w.Header().Set("Content-Type", "application/json")
86+
w.WriteHeader(http.StatusOK)
87+
json.NewEncoder(w).Encode(ExecutePlaywrightResult{
88+
Success: false,
89+
Error: fmt.Sprintf("failed to parse output: %v", err),
90+
Stdout: string(output),
91+
})
92+
return
93+
}
94+
95+
w.Header().Set("Content-Type", "application/json")
96+
w.WriteHeader(http.StatusOK)
97+
json.NewEncoder(w).Encode(result)
98+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"log/slog"
8+
"net/http"
9+
"net/http/httptest"
10+
"os"
11+
"testing"
12+
13+
"github.com/onkernel/kernel-images/server/lib/logger"
14+
)
15+
16+
func TestExecutePlaywrightRequest_Validation(t *testing.T) {
17+
s := &Service{}
18+
19+
tests := []struct {
20+
name string
21+
requestBody string
22+
expectedStatus int
23+
checkError bool
24+
}{
25+
{
26+
name: "empty code",
27+
requestBody: `{"code": ""}`,
28+
expectedStatus: http.StatusBadRequest,
29+
checkError: true,
30+
},
31+
{
32+
name: "missing code field",
33+
requestBody: `{}`,
34+
expectedStatus: http.StatusBadRequest,
35+
checkError: true,
36+
},
37+
{
38+
name: "invalid json",
39+
requestBody: `{invalid}`,
40+
expectedStatus: http.StatusBadRequest,
41+
checkError: true,
42+
},
43+
}
44+
45+
for _, tt := range tests {
46+
t.Run(tt.name, func(t *testing.T) {
47+
req := httptest.NewRequest(http.MethodPost, "/playwright/execute", bytes.NewBufferString(tt.requestBody))
48+
req.Header.Set("Content-Type", "application/json")
49+
50+
testLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
51+
ctx := logger.AddToContext(context.Background(), testLogger)
52+
req = req.WithContext(ctx)
53+
54+
w := httptest.NewRecorder()
55+
56+
s.ExecutePlaywrightCode(w, req)
57+
58+
if w.Code != tt.expectedStatus {
59+
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
60+
}
61+
})
62+
}
63+
}
64+
65+
func TestExecutePlaywrightRequest_ValidCode(t *testing.T) {
66+
t.Skip("Skipping integration test that requires Playwright to be installed")
67+
68+
s := &Service{}
69+
70+
reqBody := ExecutePlaywrightRequest{
71+
Code: "return 'hello world';",
72+
}
73+
74+
body, _ := json.Marshal(reqBody)
75+
req := httptest.NewRequest(http.MethodPost, "/playwright/execute", bytes.NewBuffer(body))
76+
req.Header.Set("Content-Type", "application/json")
77+
78+
testLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
79+
ctx := logger.AddToContext(context.Background(), testLogger)
80+
req = req.WithContext(ctx)
81+
82+
w := httptest.NewRecorder()
83+
84+
s.ExecutePlaywrightCode(w, req)
85+
86+
if w.Code != http.StatusOK {
87+
t.Logf("Response body: %s", w.Body.String())
88+
}
89+
90+
var result ExecutePlaywrightResult
91+
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
92+
t.Logf("Could not parse response as ExecutePlaywrightResult, this is expected if Playwright is not available")
93+
}
94+
}

server/cmd/api/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ func main() {
102102
strictHandler := oapi.NewStrictHandler(apiService, nil)
103103
oapi.HandlerFromMux(strictHandler, r)
104104

105+
// Add custom Playwright execution endpoint
106+
r.Post("/playwright/execute", apiService.ExecutePlaywrightCode)
107+
105108
// endpoints to expose the spec
106109
r.Get("/spec.yaml", func(w http.ResponseWriter, r *http.Request) {
107110
w.Header().Set("Content-Type", "application/vnd.oai.openapi")

server/openapi.yaml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,31 @@ paths:
930930
$ref: "#/components/responses/BadRequestError"
931931
"500":
932932
$ref: "#/components/responses/InternalError"
933+
/playwright/execute:
934+
post:
935+
summary: Execute Playwright/TypeScript code against the browser
936+
description: |
937+
Execute arbitrary Playwright code in a fresh execution context against the browser running
938+
on localhost:9222. The code has access to 'page', 'context', and 'browser' variables.
939+
The result of the code execution is returned in the response.
940+
operationId: executePlaywrightCode
941+
requestBody:
942+
required: true
943+
content:
944+
application/json:
945+
schema:
946+
$ref: "#/components/schemas/ExecutePlaywrightRequest"
947+
responses:
948+
"200":
949+
description: Code executed successfully
950+
content:
951+
application/json:
952+
schema:
953+
$ref: "#/components/schemas/ExecutePlaywrightResult"
954+
"400":
955+
$ref: "#/components/responses/BadRequestError"
956+
"500":
957+
$ref: "#/components/responses/InternalError"
933958
components:
934959
schemas:
935960
StartRecordingRequest:
@@ -1487,6 +1512,43 @@ components:
14871512
description: Indicates success.
14881513
default: true
14891514
additionalProperties: false
1515+
ExecutePlaywrightRequest:
1516+
type: object
1517+
description: Request to execute Playwright code
1518+
required: [code]
1519+
properties:
1520+
code:
1521+
type: string
1522+
description: |
1523+
TypeScript/JavaScript code to execute. The code has access to 'page', 'context', and 'browser' variables.
1524+
Example: "await page.goto('https://example.com'); return await page.title();"
1525+
timeout_sec:
1526+
type: integer
1527+
description: Maximum execution time in seconds. Default is 30.
1528+
default: 30
1529+
minimum: 1
1530+
maximum: 300
1531+
additionalProperties: false
1532+
ExecutePlaywrightResult:
1533+
type: object
1534+
description: Result of Playwright code execution
1535+
required: [success]
1536+
properties:
1537+
success:
1538+
type: boolean
1539+
description: Whether the code executed successfully
1540+
result:
1541+
description: The value returned by the code (if any)
1542+
error:
1543+
type: string
1544+
description: Error message if execution failed
1545+
stdout:
1546+
type: string
1547+
description: Standard output from the execution
1548+
stderr:
1549+
type: string
1550+
description: Standard error from the execution
1551+
additionalProperties: false
14901552
responses:
14911553
BadRequestError:
14921554
description: Bad Request
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { chromium } from 'playwright';
2+
3+
async function main() {
4+
const userCode = process.argv[2];
5+
6+
if (!userCode) {
7+
console.error('Usage: tsx playwright-executor.ts <code>');
8+
process.exit(1);
9+
}
10+
11+
let browser;
12+
let result;
13+
14+
try {
15+
browser = await chromium.connectOverCDP('http://localhost:9222');
16+
const contexts = browser.contexts();
17+
const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
18+
const pages = context.pages();
19+
const page = pages.length > 0 ? pages[0] : await context.newPage();
20+
21+
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
22+
const userFunction = new AsyncFunction('page', 'context', 'browser', userCode);
23+
result = await userFunction(page, context, browser);
24+
25+
if (result !== undefined) {
26+
console.log(JSON.stringify({ success: true, result: result }));
27+
} else {
28+
console.log(JSON.stringify({ success: true, result: null }));
29+
}
30+
} catch (error: any) {
31+
console.error(JSON.stringify({
32+
success: false,
33+
error: error.message,
34+
stack: error.stack
35+
}));
36+
process.exit(1);
37+
} finally {
38+
if (browser) {
39+
try {
40+
await browser.close();
41+
} catch (e) {
42+
// Ignore errors when closing CDP connection
43+
}
44+
}
45+
}
46+
}
47+
48+
main();

0 commit comments

Comments
 (0)