aboutsummaryrefslogtreecommitdiffhomepage
path: root/worker
diff options
context:
space:
mode:
Diffstat (limited to 'worker')
-rw-r--r--worker/php/exec.mjs78
-rw-r--r--worker/php/justfile4
-rw-r--r--worker/php/lib.mjs79
-rw-r--r--worker/php/lib.test.mjs150
-rw-r--r--worker/php/package.json6
-rw-r--r--worker/php/vitest.config.mjs5
-rw-r--r--worker/swift/exec_test.go149
-rw-r--r--worker/swift/handlers_test.go66
-rw-r--r--worker/swift/justfile5
-rw-r--r--worker/swift/models_test.go70
10 files changed, 539 insertions, 73 deletions
diff --git a/worker/php/exec.mjs b/worker/php/exec.mjs
index d8ca899..21bd93f 100644
--- a/worker/php/exec.mjs
+++ b/worker/php/exec.mjs
@@ -1,65 +1,14 @@
import PHPWasm from "./php-wasm.js";
+import { buildResult, createIOCallbacks, preprocessCode } from "./lib.mjs";
process.once("message", async ({ code: originalCode, input }) => {
- const PRELUDE = `
- define('STDIN', fopen('php://stdin', 'r'));
- define('STDOUT', fopen('php://stdout', 'r'));
- define('STDERR', fopen('php://stderr', 'r'));
-
- error_reporting(E_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED);
-
- `;
-
- // remove php tag
- let code;
- if (originalCode.startsWith("<?php")) {
- code = PRELUDE + originalCode.slice(5);
- } else if (originalCode.startsWith("<?")) {
- code = PRELUDE + originalCode.slice(2);
- } else {
- code = PRELUDE + originalCode;
- }
-
- const BUFFER_MAX = 10 * 1024;
-
- let stdinPos = 0; // bytewise
- const stdinBuf = Buffer.from(input);
- let stdoutPos = 0; // bytewise
- const stdoutBuf = Buffer.alloc(BUFFER_MAX);
- let stderrPos = 0; // bytewise
- const stderrBuf = Buffer.alloc(BUFFER_MAX);
+ const code = preprocessCode(originalCode);
+ const io = createIOCallbacks(input);
const { ccall } = await PHPWasm({
- stdin: () => {
- if (stdinBuf.length <= stdinPos) {
- return null;
- }
- return stdinBuf.readUInt8(stdinPos++);
- },
- stdout: (asciiCode) => {
- if (asciiCode === null) {
- return; // flush
- }
- if (BUFFER_MAX <= stdoutPos) {
- return; // ignore
- }
- stdoutBuf.writeUInt8(
- asciiCode < 0 ? asciiCode + 256 : asciiCode,
- stdoutPos++,
- );
- },
- stderr: (asciiCode) => {
- if (asciiCode === null) {
- return; // flush
- }
- if (BUFFER_MAX <= stderrPos) {
- return; // ignore
- }
- stderrBuf.writeUInt8(
- asciiCode < 0 ? asciiCode + 256 : asciiCode,
- stderrPos++,
- );
- },
+ stdin: io.stdin,
+ stdout: io.stdout,
+ stderr: io.stderr,
});
let err;
@@ -69,17 +18,6 @@ process.once("message", async ({ code: originalCode, input }) => {
} catch (e) {
err = e;
}
- if (err) {
- process.send({
- status: "runtime_error",
- stdout: stdoutBuf.subarray(0, stdoutPos).toString(),
- stderr: `${stderrBuf.subarray(0, stderrPos).toString()}\n${err.toString()}`,
- });
- } else {
- process.send({
- status: result === 0 ? "success" : "runtime_error",
- stdout: stdoutBuf.subarray(0, stdoutPos).toString(),
- stderr: stderrBuf.subarray(0, stderrPos).toString(),
- });
- }
+
+ process.send(buildResult(err, result, io.getStdout, io.getStderr));
});
diff --git a/worker/php/justfile b/worker/php/justfile
index 115165d..896798b 100644
--- a/worker/php/justfile
+++ b/worker/php/justfile
@@ -1,5 +1,9 @@
check:
npm run check
+test:
+ npm test
+
ci:
npx biome ci .
+ npm test
diff --git a/worker/php/lib.mjs b/worker/php/lib.mjs
new file mode 100644
index 0000000..4a34733
--- /dev/null
+++ b/worker/php/lib.mjs
@@ -0,0 +1,79 @@
+const PRELUDE = `
+ define('STDIN', fopen('php://stdin', 'r'));
+ define('STDOUT', fopen('php://stdout', 'r'));
+ define('STDERR', fopen('php://stderr', 'r'));
+
+ error_reporting(E_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED);
+
+ `;
+
+const BUFFER_MAX = 10 * 1024;
+
+export function preprocessCode(originalCode) {
+ if (originalCode.startsWith("<?php")) {
+ return PRELUDE + originalCode.slice(5);
+ }
+ if (originalCode.startsWith("<?")) {
+ return PRELUDE + originalCode.slice(2);
+ }
+ return PRELUDE + originalCode;
+}
+
+export function createIOCallbacks(input) {
+ let stdinPos = 0;
+ const stdinBuf = Buffer.from(input);
+ let stdoutPos = 0;
+ const stdoutBuf = Buffer.alloc(BUFFER_MAX);
+ let stderrPos = 0;
+ const stderrBuf = Buffer.alloc(BUFFER_MAX);
+
+ return {
+ stdin: () => {
+ if (stdinBuf.length <= stdinPos) {
+ return null;
+ }
+ return stdinBuf.readUInt8(stdinPos++);
+ },
+ stdout: (asciiCode) => {
+ if (asciiCode === null) {
+ return;
+ }
+ if (BUFFER_MAX <= stdoutPos) {
+ return;
+ }
+ stdoutBuf.writeUInt8(
+ asciiCode < 0 ? asciiCode + 256 : asciiCode,
+ stdoutPos++,
+ );
+ },
+ stderr: (asciiCode) => {
+ if (asciiCode === null) {
+ return;
+ }
+ if (BUFFER_MAX <= stderrPos) {
+ return;
+ }
+ stderrBuf.writeUInt8(
+ asciiCode < 0 ? asciiCode + 256 : asciiCode,
+ stderrPos++,
+ );
+ },
+ getStdout: () => stdoutBuf.subarray(0, stdoutPos).toString(),
+ getStderr: () => stderrBuf.subarray(0, stderrPos).toString(),
+ };
+}
+
+export function buildResult(err, ccallResult, getStdout, getStderr) {
+ if (err) {
+ return {
+ status: "runtime_error",
+ stdout: getStdout(),
+ stderr: `${getStderr()}\n${err.toString()}`,
+ };
+ }
+ return {
+ status: ccallResult === 0 ? "success" : "runtime_error",
+ stdout: getStdout(),
+ stderr: getStderr(),
+ };
+}
diff --git a/worker/php/lib.test.mjs b/worker/php/lib.test.mjs
new file mode 100644
index 0000000..6613066
--- /dev/null
+++ b/worker/php/lib.test.mjs
@@ -0,0 +1,150 @@
+import { describe, expect, it } from "vitest";
+import { buildResult, createIOCallbacks, preprocessCode } from "./lib.mjs";
+
+describe("preprocessCode", () => {
+ it("removes <?php tag and prepends PRELUDE", () => {
+ const result = preprocessCode('<?php echo "hello";');
+ expect(result).toContain('echo "hello";');
+ expect(result).toContain("error_reporting");
+ expect(result).not.toContain("<?php");
+ });
+
+ it("removes <? short tag and prepends PRELUDE", () => {
+ const result = preprocessCode('<? echo "hello";');
+ expect(result).toContain('echo "hello";');
+ expect(result).toContain("error_reporting");
+ expect(result).not.toContain("<?");
+ });
+
+ it("prepends PRELUDE when no php tag present", () => {
+ const result = preprocessCode('echo "hello";');
+ expect(result).toContain('echo "hello";');
+ expect(result).toContain("error_reporting");
+ });
+
+ it("handles empty string", () => {
+ const result = preprocessCode("");
+ expect(result).toContain("error_reporting");
+ });
+
+ it("does not remove <?php when not at the start", () => {
+ const result = preprocessCode('echo "x"; <?php echo "y";');
+ expect(result).toContain("<?php");
+ });
+});
+
+describe("createIOCallbacks", () => {
+ describe("stdin", () => {
+ it("reads input byte by byte", () => {
+ const io = createIOCallbacks("AB");
+ expect(io.stdin()).toBe(65); // 'A'
+ expect(io.stdin()).toBe(66); // 'B'
+ });
+
+ it("returns null at EOF", () => {
+ const io = createIOCallbacks("A");
+ io.stdin(); // consume 'A'
+ expect(io.stdin()).toBeNull();
+ expect(io.stdin()).toBeNull();
+ });
+
+ it("returns null immediately for empty input", () => {
+ const io = createIOCallbacks("");
+ expect(io.stdin()).toBeNull();
+ });
+ });
+
+ describe("stdout", () => {
+ it("captures ASCII writes", () => {
+ const io = createIOCallbacks("");
+ io.stdout(72); // 'H'
+ io.stdout(105); // 'i'
+ expect(io.getStdout()).toBe("Hi");
+ });
+
+ it("ignores null (flush)", () => {
+ const io = createIOCallbacks("");
+ io.stdout(65);
+ io.stdout(null);
+ io.stdout(66);
+ expect(io.getStdout()).toBe("AB");
+ });
+
+ it("corrects negative asciiCode by adding 256", () => {
+ const io = createIOCallbacks("");
+ // -191 + 256 = 65 = 'A'
+ io.stdout(-191);
+ expect(io.getStdout()).toBe("A");
+ });
+
+ it("truncates output at 10KB buffer limit", () => {
+ const io = createIOCallbacks("");
+ const limit = 10 * 1024;
+ for (let i = 0; i < limit + 100; i++) {
+ io.stdout(65);
+ }
+ expect(io.getStdout().length).toBe(limit);
+ });
+ });
+
+ describe("stderr", () => {
+ it("captures ASCII writes", () => {
+ const io = createIOCallbacks("");
+ io.stderr(69); // 'E'
+ io.stderr(114); // 'r'
+ expect(io.getStderr()).toBe("Er");
+ });
+
+ it("ignores null (flush)", () => {
+ const io = createIOCallbacks("");
+ io.stderr(65);
+ io.stderr(null);
+ expect(io.getStderr()).toBe("A");
+ });
+
+ it("corrects negative asciiCode by adding 256", () => {
+ const io = createIOCallbacks("");
+ // -156 + 256 = 100 = 'd'
+ io.stderr(-156);
+ expect(io.getStderr()).toBe("d");
+ });
+
+ it("truncates output at 10KB buffer limit", () => {
+ const io = createIOCallbacks("");
+ const limit = 10 * 1024;
+ for (let i = 0; i < limit + 100; i++) {
+ io.stderr(65);
+ }
+ expect(io.getStderr().length).toBe(limit);
+ });
+ });
+});
+
+describe("buildResult", () => {
+ it("returns success when err is null and result is 0", () => {
+ const result = buildResult(null, 0, () => "out", () => "");
+ expect(result).toEqual({
+ status: "success",
+ stdout: "out",
+ stderr: "",
+ });
+ });
+
+ it("returns runtime_error when result is non-zero", () => {
+ const result = buildResult(null, 1, () => "out", () => "err");
+ expect(result).toEqual({
+ status: "runtime_error",
+ stdout: "out",
+ stderr: "err",
+ });
+ });
+
+ it("returns runtime_error with concatenated stderr when err is thrown", () => {
+ const err = new Error("fatal");
+ const result = buildResult(err, undefined, () => "out", () => "err");
+ expect(result.status).toBe("runtime_error");
+ expect(result.stdout).toBe("out");
+ expect(result.stderr).toContain("err");
+ expect(result.stderr).toContain("Error: fatal");
+ });
+});
diff --git a/worker/php/package.json b/worker/php/package.json
index 406df5c..12127bd 100644
--- a/worker/php/package.json
+++ b/worker/php/package.json
@@ -5,13 +5,15 @@
"main": "index.mjs",
"scripts": {
"check": "npm run check:biome",
- "check:biome": "biome check --write"
+ "check:biome": "biome check --write",
+ "test": "vitest run"
},
"dependencies": {
"@hono/node-server": "^1.19.9",
"hono": "^4.11.9"
},
"devDependencies": {
- "@biomejs/biome": "^2.3.15"
+ "@biomejs/biome": "^2.3.15",
+ "vitest": "^3.2.1"
}
}
diff --git a/worker/php/vitest.config.mjs b/worker/php/vitest.config.mjs
new file mode 100644
index 0000000..def6022
--- /dev/null
+++ b/worker/php/vitest.config.mjs
@@ -0,0 +1,5 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {},
+});
diff --git a/worker/swift/exec_test.go b/worker/swift/exec_test.go
new file mode 100644
index 0000000..ead2dc6
--- /dev/null
+++ b/worker/swift/exec_test.go
@@ -0,0 +1,149 @@
+package main
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestConvertCommandErrorToResultType(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ defaultStatus string
+ want string
+ }{
+ {"nil error returns success", nil, resultRuntimeError, resultSuccess},
+ {"DeadlineExceeded returns timeout", context.DeadlineExceeded, resultRuntimeError, resultTimeout},
+ {"other error returns default status", os.ErrNotExist, resultCompileError, resultCompileError},
+ {"other error returns runtime_error default", os.ErrPermission, resultRuntimeError, resultRuntimeError},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := convertCommandErrorToResultType(tt.err, tt.defaultStatus)
+ if got != tt.want {
+ t.Errorf("convertCommandErrorToResultType() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestExecCommandWithTimeout_Success(t *testing.T) {
+ stdout, stderr, err := execCommandWithTimeout(
+ context.Background(),
+ t.TempDir(),
+ 5*time.Second,
+ func(ctx context.Context) *exec.Cmd {
+ return exec.CommandContext(ctx, "echo", "hello")
+ },
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if stdout != "hello\n" {
+ t.Errorf("stdout = %q, want %q", stdout, "hello\n")
+ }
+ if stderr != "" {
+ t.Errorf("stderr = %q, want empty", stderr)
+ }
+}
+
+func TestExecCommandWithTimeout_Failure(t *testing.T) {
+ _, _, err := execCommandWithTimeout(
+ context.Background(),
+ t.TempDir(),
+ 5*time.Second,
+ func(ctx context.Context) *exec.Cmd {
+ return exec.CommandContext(ctx, "false")
+ },
+ )
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+}
+
+func TestExecCommandWithTimeout_Timeout(t *testing.T) {
+ _, _, err := execCommandWithTimeout(
+ context.Background(),
+ t.TempDir(),
+ 50*time.Millisecond,
+ func(ctx context.Context) *exec.Cmd {
+ return exec.CommandContext(ctx, "sleep", "10")
+ },
+ )
+ if err != context.DeadlineExceeded {
+ t.Errorf("expected DeadlineExceeded, got %v", err)
+ }
+}
+
+func TestExecCommandWithTimeout_Stderr(t *testing.T) {
+ _, stderr, _ := execCommandWithTimeout(
+ context.Background(),
+ t.TempDir(),
+ 5*time.Second,
+ func(ctx context.Context) *exec.Cmd {
+ return exec.CommandContext(ctx, "sh", "-c", "echo errmsg >&2")
+ },
+ )
+ if stderr != "errmsg\n" {
+ t.Errorf("stderr = %q, want %q", stderr, "errmsg\n")
+ }
+}
+
+func TestPrepareWorkingDir(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "subdir")
+ res := prepareWorkingDir(dir)
+ if res.Status != resultSuccess {
+ t.Fatalf("prepareWorkingDir() status = %q, want %q", res.Status, resultSuccess)
+ }
+ info, err := os.Stat(dir)
+ if err != nil {
+ t.Fatalf("directory not created: %v", err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("expected directory, got file")
+ }
+}
+
+func TestPutSwiftSourceFile(t *testing.T) {
+ t.Run("writes file when Sources/ exists", func(t *testing.T) {
+ dir := t.TempDir()
+ sourcesDir := filepath.Join(dir, "Sources")
+ if err := os.MkdirAll(sourcesDir, 0755); err != nil {
+ t.Fatalf("failed to create Sources dir: %v", err)
+ }
+ res := putSwiftSourceFile(dir, "print(\"hello\")")
+ if res.Status != resultSuccess {
+ t.Fatalf("putSwiftSourceFile() status = %q, want %q", res.Status, resultSuccess)
+ }
+ content, err := os.ReadFile(filepath.Join(sourcesDir, "main.swift"))
+ if err != nil {
+ t.Fatalf("failed to read file: %v", err)
+ }
+ if string(content) != "print(\"hello\")" {
+ t.Errorf("file content = %q, want %q", string(content), "print(\"hello\")")
+ }
+ })
+
+ t.Run("returns error when Sources/ does not exist", func(t *testing.T) {
+ dir := t.TempDir()
+ res := putSwiftSourceFile(dir, "print(\"hello\")")
+ if res.Status == resultSuccess {
+ t.Fatal("expected error status, got success")
+ }
+ })
+}
+
+func TestRemoveWorkingDir(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "toremove")
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ t.Fatalf("failed to create dir: %v", err)
+ }
+ removeWorkingDir(dir)
+ if _, err := os.Stat(dir); !os.IsNotExist(err) {
+ t.Errorf("directory still exists after removeWorkingDir")
+ }
+}
diff --git a/worker/swift/handlers_test.go b/worker/swift/handlers_test.go
new file mode 100644
index 0000000..f9c2e0b
--- /dev/null
+++ b/worker/swift/handlers_test.go
@@ -0,0 +1,66 @@
+package main
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/labstack/echo/v4"
+)
+
+func TestNewBadRequestError(t *testing.T) {
+ httpErr := newBadRequestError(errors.New("test error"))
+ if httpErr.Code != http.StatusBadRequest {
+ t.Errorf("status code = %d, want %d", httpErr.Code, http.StatusBadRequest)
+ }
+ msg, ok := httpErr.Message.(string)
+ if !ok {
+ t.Fatalf("expected string message, got %T", httpErr.Message)
+ }
+ if !strings.Contains(msg, "test error") {
+ t.Errorf("message = %q, want it to contain %q", msg, "test error")
+ }
+}
+
+func TestHandleExec_InvalidJSON(t *testing.T) {
+ e := echo.New()
+ req := httptest.NewRequest(http.MethodPost, "/exec", strings.NewReader("not json"))
+ req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ err := handleExec(c)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ httpErr, ok := err.(*echo.HTTPError)
+ if !ok {
+ t.Fatalf("expected *echo.HTTPError, got %T", err)
+ }
+ if httpErr.Code != http.StatusBadRequest {
+ t.Errorf("status code = %d, want %d", httpErr.Code, http.StatusBadRequest)
+ }
+}
+
+func TestHandleExec_ZeroMaxDuration(t *testing.T) {
+ e := echo.New()
+ body := `{"code":"print(1)","code_hash":"abc","stdin":"","max_duration_ms":0}`
+ req := httptest.NewRequest(http.MethodPost, "/exec", strings.NewReader(body))
+ req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ err := handleExec(c)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ httpErr, ok := err.(*echo.HTTPError)
+ if !ok {
+ t.Fatalf("expected *echo.HTTPError, got %T", err)
+ }
+ if httpErr.Code != http.StatusBadRequest {
+ t.Errorf("status code = %d, want %d", httpErr.Code, http.StatusBadRequest)
+ }
+}
diff --git a/worker/swift/justfile b/worker/swift/justfile
index b203597..decaaf0 100644
--- a/worker/swift/justfile
+++ b/worker/swift/justfile
@@ -2,4 +2,7 @@ check:
go build -o /dev/null ./...
go tool golangci-lint run
-ci: check
+test:
+ go test ./...
+
+ci: check test
diff --git a/worker/swift/models_test.go b/worker/swift/models_test.go
new file mode 100644
index 0000000..62d6675
--- /dev/null
+++ b/worker/swift/models_test.go
@@ -0,0 +1,70 @@
+package main
+
+import (
+ "testing"
+ "time"
+)
+
+func TestExecRequestData_Validate(t *testing.T) {
+ tests := []struct {
+ name string
+ maxDurationMs int
+ wantErr error
+ }{
+ {"positive value", 1000, nil},
+ {"zero", 0, errInvalidMaxDuration},
+ {"negative value", -1, errInvalidMaxDuration},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := &execRequestData{MaxDurationMilliseconds: tt.maxDurationMs}
+ err := req.validate()
+ if err != tt.wantErr {
+ t.Errorf("validate() = %v, want %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestExecRequestData_MaxDuration(t *testing.T) {
+ tests := []struct {
+ name string
+ maxDurationMs int
+ want time.Duration
+ }{
+ {"1000ms", 1000, 1 * time.Second},
+ {"500ms", 500, 500 * time.Millisecond},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := &execRequestData{MaxDurationMilliseconds: tt.maxDurationMs}
+ got := req.maxDuration()
+ if got != tt.want {
+ t.Errorf("maxDuration() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestExecResponseData_Success(t *testing.T) {
+ tests := []struct {
+ name string
+ status string
+ want bool
+ }{
+ {"success", resultSuccess, true},
+ {"compile_error", resultCompileError, false},
+ {"runtime_error", resultRuntimeError, false},
+ {"timeout", resultTimeout, false},
+ {"internal_error", resultInternalError, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ res := &execResponseData{Status: tt.status}
+ got := res.success()
+ if got != tt.want {
+ t.Errorf("success() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}