Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 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
20 changes: 20 additions & 0 deletions images/chromium-headful/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,23 @@ RUN set -eux; \
RUN add-apt-repository -y ppa:xtradeb/apps
RUN apt update -y && apt install -y chromium sqlite3

# install Node.js 22.x by copying from the node:22-bullseye-slim stage
COPY --from=client /usr/local/bin/node /usr/local/bin/node
COPY --from=client /usr/local/lib/node_modules /usr/local/lib/node_modules
# Recreate symlinks for npm/npx/corepack to point into node_modules
RUN set -eux; \
ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm; \
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx; \
if [ -e /usr/local/lib/node_modules/corepack/dist/corepack.js ]; then \
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack; \
fi

# Install TypeScript and Playwright globally
RUN npm install -g typescript tsx playwright

# Set NODE_PATH so that globally installed modules can be resolved
ENV NODE_PATH=/usr/lib/node_modules

# setup desktop env & app
ENV DISPLAY_NUM=1
ENV HEIGHT=768
Expand All @@ -183,6 +200,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" ]
20 changes: 20 additions & 0 deletions images/chromium-headless/image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,23 @@ 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 22.x by copying from the node:22-bullseye-slim stage
COPY --from=client /usr/local/bin/node /usr/local/bin/node
COPY --from=client /usr/local/lib/node_modules /usr/local/lib/node_modules
# Recreate symlinks for npm/npx/corepack to point into node_modules
RUN set -eux; \
ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm; \
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx; \
if [ -e /usr/local/lib/node_modules/corepack/dist/corepack.js ]; then \
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack; \
fi

# Install TypeScript and Playwright globally
RUN npm install -g typescript tsx playwright

# Set NODE_PATH so that globally installed modules can be resolved
ENV NODE_PATH=/usr/lib/node_modules
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Node Module Path Mismatch Causes Runtime Failures

The NODE_PATH is set to /usr/lib/node_modules, but global Node.js packages (like TypeScript and Playwright) are installed in /usr/local/lib/node_modules. This path mismatch prevents Node.js from resolving these modules, leading to runtime failures for the Playwright executor.

Additional Locations (1)

Fix in Cursor Fix in Web


ENV WITHDOCKER=true

# Create a non-root user with a home directory
Expand All @@ -93,4 +110,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" ]
131 changes: 131 additions & 0 deletions server/cmd/api/api/playwright.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package api

import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"time"

"github.com/onkernel/kernel-images/server/lib/logger"
"github.com/onkernel/kernel-images/server/lib/oapi"
)

// ExecutePlaywrightCode implements the Playwright code execution endpoint
func (s *ApiService) ExecutePlaywrightCode(ctx context.Context, request oapi.ExecutePlaywrightCodeRequestObject) (oapi.ExecutePlaywrightCodeResponseObject, error) {
log := logger.FromContext(ctx)

// Validate request
if request.Body == nil || request.Body.Code == "" {
return oapi.ExecutePlaywrightCode400JSONResponse{
BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{
Message: "code is required",
},
}, nil
}

// Determine timeout (default to 60 seconds)
timeout := 60 * time.Second
if request.Body.TimeoutSec != nil && *request.Body.TimeoutSec > 0 {
timeout = time.Duration(*request.Body.TimeoutSec) * time.Second
}

// Create a temporary file for the user code
tmpFile, err := os.CreateTemp("", "playwright-code-*.ts")
if err != nil {
log.Error("failed to create temp file", "error", err)
return oapi.ExecutePlaywrightCode500JSONResponse{
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
Message: fmt.Sprintf("failed to create temp file: %v", err),
},
}, nil
}
tmpFilePath := tmpFile.Name()
defer os.Remove(tmpFilePath) // Clean up the temp file

// Write the user code to the temp file
if _, err := tmpFile.WriteString(request.Body.Code); err != nil {
tmpFile.Close()
log.Error("failed to write code to temp file", "error", err)
return oapi.ExecutePlaywrightCode500JSONResponse{
InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{
Message: fmt.Sprintf("failed to write code to temp file: %v", err),
},
}, nil
}
tmpFile.Close()

// Create context with timeout
execCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

// Execute the Playwright code via the executor script
cmd := exec.CommandContext(execCtx, "tsx", "/usr/local/lib/playwright-executor.ts", tmpFilePath)

output, err := cmd.CombinedOutput()

if err != nil {
if execCtx.Err() == context.DeadlineExceeded {
log.Error("playwright execution timed out", "timeout", timeout)
success := false
errorMsg := fmt.Sprintf("execution timed out after %v", timeout)
return oapi.ExecutePlaywrightCode200JSONResponse{
Success: success,
Error: &errorMsg,
}, nil
}

log.Error("playwright execution failed", "error", err, "output", string(output))

// Try to parse the error output as JSON
var result struct {
Success bool `json:"success"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Stack string `json:"stack,omitempty"`
}
if jsonErr := json.Unmarshal(output, &result); jsonErr == nil {
success := result.Success
errorMsg := result.Error
stderr := string(output)
return oapi.ExecutePlaywrightCode200JSONResponse{
Success: success,
Error: &errorMsg,
Stderr: &stderr,
}, nil
}

// If we can't parse the output, return a generic error
success := false
errorMsg := fmt.Sprintf("execution failed: %v", err)
stderr := string(output)
return oapi.ExecutePlaywrightCode200JSONResponse{
Success: success,
Error: &errorMsg,
Stderr: &stderr,
}, nil
}

// Parse successful output
var result struct {
Success bool `json:"success"`
Result interface{} `json:"result,omitempty"`
}
if err := json.Unmarshal(output, &result); err != nil {
log.Error("failed to parse playwright output", "error", err, "output", string(output))
success := false
errorMsg := fmt.Sprintf("failed to parse output: %v", err)
stdout := string(output)
return oapi.ExecutePlaywrightCode200JSONResponse{
Success: success,
Error: &errorMsg,
Stdout: &stdout,
}, nil
}

return oapi.ExecutePlaywrightCode200JSONResponse{
Success: result.Success,
Result: &result.Result,
}, nil
}
81 changes: 81 additions & 0 deletions server/e2e/e2e_chromium_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
Expand Down Expand Up @@ -729,3 +730,83 @@ func getXvfbResolution(ctx context.Context) (width, height int, err error) {

return 0, 0, fmt.Errorf("Xvfb process not found in ps aux output")
}

func TestPlaywrightExecuteAPI(t *testing.T) {
image := headlessImage
name := containerName + "-playwright-api"

logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo}))
baseCtx := logctx.AddToContext(context.Background(), logger)

if _, err := exec.LookPath("docker"); err != nil {
require.NoError(t, err, "docker not available: %v", err)
}

// Clean slate
_ = stopContainer(baseCtx, name)

env := map[string]string{}

// Start container
_, exitCh, err := runContainer(baseCtx, image, name, env)
require.NoError(t, err, "failed to start container: %v", err)
defer stopContainer(baseCtx, name)

ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute)
defer cancel()

require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err)

client, err := apiClient()
require.NoError(t, err)

// Test simple Playwright script that navigates to a page and returns the title
playwrightCode := `
await page.goto('https://example.com');
const title = await page.title();
return title;
`

logger.Info("[test]", "action", "executing playwright code")
req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{
Code: playwrightCode,
}

rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req)
require.NoError(t, err, "playwright execute request error: %v", err)
require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for playwright execute: %s body=%s", rsp.Status(), string(rsp.Body))
require.NotNil(t, rsp.JSON200, "expected JSON200 response, got nil")

// Log the full response for debugging
if !rsp.JSON200.Success {
var errorMsg string
if rsp.JSON200.Error != nil {
errorMsg = *rsp.JSON200.Error
}
var stdout, stderr string
if rsp.JSON200.Stdout != nil {
stdout = *rsp.JSON200.Stdout
}
if rsp.JSON200.Stderr != nil {
stderr = *rsp.JSON200.Stderr
}
logger.Error("[test]", "error", errorMsg, "stdout", stdout, "stderr", stderr)
}

require.True(t, rsp.JSON200.Success, "expected success=true, got success=false. Error: %s", func() string {
if rsp.JSON200.Error != nil {
return *rsp.JSON200.Error
}
return "nil"
}())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tembo test case still failing, please fix

=== RUN TestPlaywrightExecuteAPI
time=2025-10-22T22:32:29.916Z level=INFO msg=[docker] action=run args="run --name server-e2e-test-playwright-api --privileged -p 10001:10001 -p 9222:9222 --tmpfs /dev/shm:size=2g onkernel/chromium-headless:b7cf337"
time=2025-10-22T22:32:39.918Z level=INFO msg=[test] action="executing playwright code"
time=2025-10-22T22:32:40.217Z level=ERROR msg=[test] error="execution failed: exit status 1" stdout="" stderr="node:internal/modules/cjs/loader:1383\n const err = new Error(message);\n ^\n\nError: Cannot find module 'playwright'\nRequire stack:\n- /usr/local/lib/playwright-executor.ts\n at node:internal/modules/cjs/loader:1383:15\n at nextResolveSimple (/usr/lib/node_modules/tsx/dist/register-D46fvsV_.cjs:4:1004)\n at /usr/lib/node_modules/tsx/dist/register-D46fvsV_.cjs:3:2630\n at /usr/lib/node_modules/tsx/dist/register-D46fvsV_.cjs:3:1542\n at resolveTsPaths (/usr/lib/node_modules/tsx/dist/register-D46fvsV_.cjs:4:760)\n at /usr/lib/node_modules/tsx/dist/register-D46fvsV_.cjs:4:1102\n at m._resolveFilename (file:///usr/lib/node_modules/tsx/dist/register-B7jrtLTO.mjs:1:789)\n at defaultResolveImpl (node:internal/modules/cjs/loader:1025:19)\n at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1030:22)\n at Function._load (node:internal/modules/cjs/loader
e2e_chromium_test.go:796:
Error Trace: /home/runner/work/kernel-images/kernel-images/server/e2e/e2e_chromium_test.go:796
Error: Should be true
Test: TestPlaywrightExecuteAPI
Messages: expected success=true, got success=false. Error: execution failed: exit status 1

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tembo still failing:

=== RUN TestPlaywrightExecuteAPI
time=2025-10-22T22:51:11.563Z level=INFO msg=[docker] action=run args="run --name server-e2e-test-playwright-api --privileged -p 10001:10001 -p 9222:9222 --tmpfs /dev/shm:size=2g onkernel/chromium-headless:73cb9b9"
time=2025-10-22T22:51:22.065Z level=INFO msg=[test] action="executing playwright code"
time=2025-10-22T22:51:22.383Z level=ERROR msg=[test] error="execution failed: exit status 1" stdout="" stderr="node:internal/modules/cjs/loader:1383\n const err = new Error(message);\n ^\n\nError: Cannot find module 'playwright'\nRequire stack:\n- /usr/local/lib/playwright-executor.ts\n at node:internal/modules/cjs/loader:1383:15\n at nextResolveSimple (/usr/lib/node_modules/tsx/dist/register-D46fvsV_.cjs:4:1004)\n at /usr/lib/node_modules/tsx/dist/register-D46fvsV_.cjs:3:2630\n at /usr/lib/node_modules/tsx/dist/register-D46fvsV_.cjs:3:1542\n at resolveTsPaths (/usr/lib/node_modules/tsx/dist/register-D46fvsV_.cjs:4:760)\n at /usr/lib/node_modules/tsx/dist/register-D46fvsV_.cjs:4:1102\n at m._resolveFilename (file:///usr/lib/node_modules/tsx/dist/register-B7jrtLTO.mjs:1:789)\n at defaultResolveImpl (node:internal/modules/cjs/loader:1025:19)\n at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1030:22)\n at Function._load (node:internal/modules/cjs/loader:1192:37) {\n code: 'MODULE_NOT_FOUND',\n requireStack: [ '/usr/local/lib/playwright-executor.ts' ]\n}\n\nNode.js v22.21.0\n"
e2e_chromium_test.go:796:
Error Trace: /home/runner/work/kernel-images/kernel-images/server/e2e/e2e_chromium_test.go:796
Error: Should be true
Test: TestPlaywrightExecuteAPI
Messages: expected success=true, got success=false. Error: execution failed: exit status 1

i think the problem is that we install playwright globally in the dockerfile, but are we configuring the execution of this script to point to that global location when resolving node_modules?

require.NotNil(t, rsp.JSON200.Result, "expected result to be non-nil")

// Verify the result contains "Example Domain" (the title of example.com)
resultBytes, err := json.Marshal(rsp.JSON200.Result)
require.NoError(t, err, "failed to marshal result: %v", err)
resultStr := string(resultBytes)
logger.Info("[test]", "result", resultStr)
require.Contains(t, resultStr, "Example Domain", "expected result to contain 'Example Domain'")

logger.Info("[test]", "result", "playwright execute API test passed")
}
Loading
Loading