diff options
Diffstat (limited to 'worker')
| -rw-r--r-- | worker/php/exec.mjs | 78 | ||||
| -rw-r--r-- | worker/php/justfile | 4 | ||||
| -rw-r--r-- | worker/php/lib.mjs | 79 | ||||
| -rw-r--r-- | worker/php/lib.test.mjs | 150 | ||||
| -rw-r--r-- | worker/php/package.json | 6 | ||||
| -rw-r--r-- | worker/php/vitest.config.mjs | 5 | ||||
| -rw-r--r-- | worker/swift/exec_test.go | 149 | ||||
| -rw-r--r-- | worker/swift/handlers_test.go | 66 | ||||
| -rw-r--r-- | worker/swift/justfile | 5 | ||||
| -rw-r--r-- | worker/swift/models_test.go | 70 |
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) + } + }) + } +} |
