-
Notifications
You must be signed in to change notification settings - Fork 36
feat: Dynamic Playwright API #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
5e8c82f
cd243de
4ffd35b
2acc2b4
7cb361f
bb770b7
b850e0f
f062258
41833cd
e0a36b1
2a6faf7
662247d
6f9e959
42b1499
ecd8f14
e809a85
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -161,6 +161,16 @@ RUN set -eux; \ | |
| RUN add-apt-repository -y ppa:xtradeb/apps | ||
| RUN apt update -y && apt install -y chromium sqlite3 | ||
|
|
||
| # Install Node.js 20.x | ||
| RUN set -eux; \ | ||
| curl -fsSL https://deb.nodesource.com/setup_20.x | bash -; \ | ||
| apt-get install -y nodejs; \ | ||
| rm -rf /var/lib/apt/lists/* | ||
|
|
||
| # Install TypeScript and Playwright globally | ||
| RUN npm install -g typescript tsx playwright && \ | ||
| npx playwright install-deps chromium | ||
|
||
|
|
||
| # setup desktop env & app | ||
| ENV DISPLAY_NUM=1 | ||
| ENV HEIGHT=768 | ||
|
|
@@ -183,6 +193,9 @@ COPY images/chromium-headful/supervisor/services/ /etc/supervisor/conf.d/service | |
| COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api | ||
| COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher | ||
|
|
||
| # Copy the Playwright executor runtime | ||
| COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts | ||
|
|
||
| RUN useradd -m -s /bin/bash kernel | ||
|
|
||
| ENTRYPOINT [ "/wrapper.sh" ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -73,6 +73,16 @@ RUN set -eux; \ | |
| # Remove upower to prevent spurious D-Bus activations and logs | ||
| RUN apt-get -yqq purge upower || true && rm -rf /var/lib/apt/lists/* | ||
|
|
||
| # Install Node.js 20.x | ||
| RUN set -eux; \ | ||
| curl -fsSL https://deb.nodesource.com/setup_20.x | bash -; \ | ||
|
||
| apt-get install -y nodejs; \ | ||
| rm -rf /var/lib/apt/lists/* | ||
|
|
||
| # Install TypeScript and Playwright globally | ||
| RUN npm install -g typescript tsx playwright && \ | ||
| npx playwright install-deps chromium | ||
|
|
||
rgarcia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ENV WITHDOCKER=true | ||
|
|
||
| # Create a non-root user with a home directory | ||
|
|
@@ -93,4 +103,7 @@ COPY images/chromium-headless/image/supervisor/services/ /etc/supervisor/conf.d/ | |
| COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api | ||
| COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher | ||
|
|
||
| # Copy the Playwright executor runtime | ||
| COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts | ||
|
|
||
| ENTRYPOINT [ "/usr/bin/wrapper.sh" ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| package api | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "net/http" | ||
| "os/exec" | ||
| "time" | ||
|
|
||
| "github.com/onkernel/kernel-images/server/lib/logger" | ||
| ) | ||
|
|
||
| type ExecutePlaywrightRequest struct { | ||
| Code string `json:"code"` | ||
| TimeoutSec *int `json:"timeout_sec,omitempty"` | ||
| } | ||
|
|
||
| type ExecutePlaywrightResult struct { | ||
| Success bool `json:"success"` | ||
| Result interface{} `json:"result,omitempty"` | ||
| Error string `json:"error,omitempty"` | ||
| Stdout string `json:"stdout,omitempty"` | ||
| Stderr string `json:"stderr,omitempty"` | ||
| } | ||
|
||
|
|
||
| func (s *Service) ExecutePlaywrightCode(w http.ResponseWriter, r *http.Request) { | ||
|
||
| log := logger.FromContext(r.Context()) | ||
|
|
||
| var req ExecutePlaywrightRequest | ||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) | ||
| return | ||
| } | ||
|
|
||
| if req.Code == "" { | ||
| http.Error(w, "code is required", http.StatusBadRequest) | ||
| return | ||
| } | ||
|
|
||
| timeout := 30 * time.Second | ||
|
||
| if req.TimeoutSec != nil && *req.TimeoutSec > 0 { | ||
| timeout = time.Duration(*req.TimeoutSec) * time.Second | ||
| } | ||
|
|
||
| ctx, cancel := context.WithTimeout(r.Context(), timeout) | ||
| defer cancel() | ||
|
|
||
| cmd := exec.CommandContext(ctx, "tsx", "/usr/local/lib/playwright-executor.ts", req.Code) | ||
|
||
|
|
||
| output, err := cmd.CombinedOutput() | ||
|
|
||
| if err != nil { | ||
| if ctx.Err() == context.DeadlineExceeded { | ||
| log.Error("playwright execution timed out", "timeout", timeout) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusOK) | ||
| json.NewEncoder(w).Encode(ExecutePlaywrightResult{ | ||
| Success: false, | ||
| Error: fmt.Sprintf("execution timed out after %v", timeout), | ||
| }) | ||
| return | ||
| } | ||
|
|
||
| log.Error("playwright execution failed", "error", err, "output", string(output)) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusOK) | ||
|
|
||
| var result ExecutePlaywrightResult | ||
| if jsonErr := json.Unmarshal(output, &result); jsonErr == nil { | ||
| json.NewEncoder(w).Encode(result) | ||
| } else { | ||
| json.NewEncoder(w).Encode(ExecutePlaywrightResult{ | ||
| Success: false, | ||
| Error: fmt.Sprintf("execution failed: %v", err), | ||
| Stderr: string(output), | ||
| }) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| var result ExecutePlaywrightResult | ||
| if err := json.Unmarshal(output, &result); err != nil { | ||
| log.Error("failed to parse playwright output", "error", err, "output", string(output)) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusOK) | ||
| json.NewEncoder(w).Encode(ExecutePlaywrightResult{ | ||
| Success: false, | ||
| Error: fmt.Sprintf("failed to parse output: %v", err), | ||
| Stdout: string(output), | ||
| }) | ||
| return | ||
| } | ||
|
|
||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusOK) | ||
| json.NewEncoder(w).Encode(result) | ||
| } | ||
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| package api | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "encoding/json" | ||
| "log/slog" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "os" | ||
| "testing" | ||
|
|
||
| "github.com/onkernel/kernel-images/server/lib/logger" | ||
| ) | ||
|
|
||
| func TestExecutePlaywrightRequest_Validation(t *testing.T) { | ||
| s := &Service{} | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| requestBody string | ||
| expectedStatus int | ||
| checkError bool | ||
| }{ | ||
| { | ||
| name: "empty code", | ||
| requestBody: `{"code": ""}`, | ||
| expectedStatus: http.StatusBadRequest, | ||
| checkError: true, | ||
| }, | ||
| { | ||
| name: "missing code field", | ||
| requestBody: `{}`, | ||
| expectedStatus: http.StatusBadRequest, | ||
| checkError: true, | ||
| }, | ||
| { | ||
| name: "invalid json", | ||
| requestBody: `{invalid}`, | ||
| expectedStatus: http.StatusBadRequest, | ||
| checkError: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| req := httptest.NewRequest(http.MethodPost, "/playwright/execute", bytes.NewBufferString(tt.requestBody)) | ||
| req.Header.Set("Content-Type", "application/json") | ||
|
|
||
| testLogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) | ||
| ctx := logger.AddToContext(context.Background(), testLogger) | ||
| req = req.WithContext(ctx) | ||
|
|
||
| w := httptest.NewRecorder() | ||
|
|
||
| s.ExecutePlaywrightCode(w, req) | ||
|
|
||
| if w.Code != tt.expectedStatus { | ||
| t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestExecutePlaywrightRequest_ValidCode(t *testing.T) { | ||
|
||
| t.Skip("Skipping integration test that requires Playwright to be installed") | ||
|
|
||
| s := &Service{} | ||
|
|
||
| reqBody := ExecutePlaywrightRequest{ | ||
| Code: "return 'hello world';", | ||
| } | ||
|
|
||
| body, _ := json.Marshal(reqBody) | ||
| req := httptest.NewRequest(http.MethodPost, "/playwright/execute", bytes.NewBuffer(body)) | ||
| req.Header.Set("Content-Type", "application/json") | ||
|
|
||
| testLogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) | ||
| ctx := logger.AddToContext(context.Background(), testLogger) | ||
| req = req.WithContext(ctx) | ||
|
|
||
| w := httptest.NewRecorder() | ||
|
|
||
| s.ExecutePlaywrightCode(w, req) | ||
|
|
||
| if w.Code != http.StatusOK { | ||
| t.Logf("Response body: %s", w.Body.String()) | ||
| } | ||
|
|
||
| var result ExecutePlaywrightResult | ||
| if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { | ||
| t.Logf("Could not parse response as ExecutePlaywrightResult, this is expected if Playwright is not available") | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { chromium } from 'playwright'; | ||
|
|
||
| async function main() { | ||
| const userCode = process.argv[2]; | ||
|
||
|
|
||
| if (!userCode) { | ||
| console.error('Usage: tsx playwright-executor.ts <code>'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| let browser; | ||
| let result; | ||
|
|
||
| try { | ||
| browser = await chromium.connectOverCDP('http://localhost:9222'); | ||
|
||
| const contexts = browser.contexts(); | ||
| const context = contexts.length > 0 ? contexts[0] : await browser.newContext(); | ||
| const pages = context.pages(); | ||
| const page = pages.length > 0 ? pages[0] : await context.newPage(); | ||
|
|
||
| const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; | ||
| const userFunction = new AsyncFunction('page', 'context', 'browser', userCode); | ||
| result = await userFunction(page, context, browser); | ||
|
|
||
| if (result !== undefined) { | ||
| console.log(JSON.stringify({ success: true, result: result })); | ||
| } else { | ||
| console.log(JSON.stringify({ success: true, result: null })); | ||
| } | ||
| } catch (error: any) { | ||
| console.error(JSON.stringify({ | ||
| success: false, | ||
| error: error.message, | ||
| stack: error.stack | ||
| })); | ||
| process.exit(1); | ||
| } finally { | ||
| if (browser) { | ||
| try { | ||
| await browser.close(); | ||
| } catch (e) { | ||
| // Ignore errors when closing CDP connection | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| } | ||
|
|
||
| main(); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tembo use v22:
curl -fsSL https://deb.nodesource.com/setup_22.x