From 379a0e385ffe6a4d3a16d64c792b2cf74fadeee1 Mon Sep 17 00:00:00 2001 From: dimiro1 Date: Wed, 3 Jun 2026 17:30:44 +0200 Subject: [PATCH 1/3] feat: add Starlark as a second function language Offer Starlark (google/starlark-go) alongside Lua for authoring functions. The language is chosen once at creation and stays fixed across versions. - internal/starlarkrt: new engine.Runtime mirroring the Lua runner, with all stdlib modules (log, kv, env, http, json, base64, crypto, time, url, strings, random, router, ai, email) over the shared services, plus Starlark-specific error formatting. - Per-version language: function_versions.language column (migration 000011, defaults to lua), sticky on updates; engine selects the runtime via an fx value group keyed by language. - GraphQL Language enum + CreateFunctionInput.language; CLI `functions create --language`; frontend language selector, Lua/Starlark starter templates, language-aware Monaco editor + hover/completions (refactored into editor-*-api.js + editor-completions.js), API reference, and a list column. - Tests: starlarkrt unit tests, engine runtime-selection tests, store language tests, and e2e coverage for execution (both languages), pages, and listing. - mise: seed and vendor-js moved to mise-tasks/ file tasks; seed now also creates Starlark examples. ADR 0013 and a lunar-starlark skill document it. --- cmd/app.go | 2 + ...-starlark-as-a-second-function-language.md | 70 ++ docs/adr/README.md | 1 + e2e/function_create_test.go | 28 + e2e/function_execution_test.go | 322 ++++++++++ e2e/function_pages_test.go | 107 ++++ e2e/functions_list_test.go | 16 + e2e/helpers_test.go | 40 +- frontend/js/api.js | 9 +- frontend/js/components/api-reference.js | 446 +++++++++++++ frontend/js/components/code-editor.js | 596 +----------------- frontend/js/components/code-viewer.js | 6 + frontend/js/components/editor-completions.js | 127 ++++ frontend/js/components/editor-lua-api.js | 532 ++++++++++++++++ frontend/js/components/editor-starlark-api.js | 504 +++++++++++++++ frontend/js/components/template-card.js | 148 +++++ frontend/js/i18n/locales/en.js | 8 +- frontend/js/i18n/locales/pt-BR.js | 8 +- frontend/js/views/function-code.js | 14 +- frontend/js/views/function-create.js | 54 +- frontend/js/views/functions-list.js | 24 + frontend/llms.txt | 7 + frontend/test/SpecRunner.html | 4 + .../components/editor-completions.spec.js | 60 ++ go.mod | 1 + go.sum | 8 +- gqlgen.yml | 2 + internal/api/server.go | 21 +- internal/api/server_test.go | 32 +- internal/engine/engine.go | 25 +- internal/engine/engine_test.go | 90 ++- internal/engine/errors.go | 9 + internal/engine/module.go | 4 +- internal/engine/runtime.go | 20 +- internal/graph/domains_test.go | 4 +- internal/graph/functions.resolvers.go | 6 +- internal/graph/generated.go | 86 ++- internal/graph/model/models_gen.go | 4 +- internal/graph/resolver.go | 9 + internal/graph/resolver_test.go | 4 +- internal/graph/schema/functions.graphqls | 4 +- internal/graph/schema/versions.graphqls | 10 +- internal/housekeeping/scheduler_test.go | 8 +- .../000011_add_version_language.down.sql | 1 + .../000011_add_version_language.up.sql | 3 + internal/runner/module.go | 38 +- internal/starlarkrt/converters.go | 200 ++++++ internal/starlarkrt/doc.go | 19 + internal/starlarkrt/errors.go | 195 ++++++ internal/starlarkrt/helpers.go | 63 ++ internal/starlarkrt/module.go | 57 ++ internal/starlarkrt/modules_ai.go | 119 ++++ internal/starlarkrt/modules_email.go | 147 +++++ internal/starlarkrt/modules_http.go | 57 ++ internal/starlarkrt/modules_router.go | 58 ++ internal/starlarkrt/modules_state.go | 134 ++++ internal/starlarkrt/modules_util.go | 325 ++++++++++ internal/starlarkrt/runtime.go | 163 +++++ internal/starlarkrt/runtime_test.go | 252 ++++++++ internal/store/memory.go | 14 +- internal/store/sqlite.go | 51 +- internal/store/sqlite_test.go | 92 ++- internal/store/store.go | 5 +- internal/store/types.go | 27 +- lunar-cli/cmd/functions.go | 8 +- lunar-cli/cmd/skills/lunar-starlark.md | 295 +++++++++ lunar-cli/cmd/versions.go | 2 + mise-tasks/seed | 126 ++++ mise-tasks/vendor-js | 34 + mise.toml | 118 ---- 70 files changed, 5238 insertions(+), 845 deletions(-) create mode 100644 docs/adr/0013-starlark-as-a-second-function-language.md create mode 100644 e2e/function_execution_test.go create mode 100644 e2e/function_pages_test.go create mode 100644 frontend/js/components/editor-completions.js create mode 100644 frontend/js/components/editor-lua-api.js create mode 100644 frontend/js/components/editor-starlark-api.js create mode 100644 frontend/test/spec/components/editor-completions.spec.js create mode 100644 internal/migrate/migrations/000011_add_version_language.down.sql create mode 100644 internal/migrate/migrations/000011_add_version_language.up.sql create mode 100644 internal/starlarkrt/converters.go create mode 100644 internal/starlarkrt/doc.go create mode 100644 internal/starlarkrt/errors.go create mode 100644 internal/starlarkrt/helpers.go create mode 100644 internal/starlarkrt/module.go create mode 100644 internal/starlarkrt/modules_ai.go create mode 100644 internal/starlarkrt/modules_email.go create mode 100644 internal/starlarkrt/modules_http.go create mode 100644 internal/starlarkrt/modules_router.go create mode 100644 internal/starlarkrt/modules_state.go create mode 100644 internal/starlarkrt/modules_util.go create mode 100644 internal/starlarkrt/runtime.go create mode 100644 internal/starlarkrt/runtime_test.go create mode 100644 lunar-cli/cmd/skills/lunar-starlark.md create mode 100755 mise-tasks/seed create mode 100755 mise-tasks/vendor-js diff --git a/cmd/app.go b/cmd/app.go index 6420fd8..5ae0566 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -24,6 +24,7 @@ import ( internalhttp "github.com/dimiro1/lunar/internal/services/http" "github.com/dimiro1/lunar/internal/services/kv" "github.com/dimiro1/lunar/internal/services/logger" + "github.com/dimiro1/lunar/internal/starlarkrt" "github.com/dimiro1/lunar/internal/store" "go.uber.org/fx" "go.uber.org/fx/fxevent" @@ -77,6 +78,7 @@ func appOptions() fx.Option { ai.Module, email.Module, runner.Module, + starlarkrt.Module, engine.Module, internalcron.Module, housekeeping.Module, diff --git a/docs/adr/0013-starlark-as-a-second-function-language.md b/docs/adr/0013-starlark-as-a-second-function-language.md new file mode 100644 index 0000000..6b62eab --- /dev/null +++ b/docs/adr/0013-starlark-as-a-second-function-language.md @@ -0,0 +1,70 @@ +# 0013. Starlark as a second function language + +- Status: Accepted +- Date: 2026-06-03 + +## Context + +[ADR-0009](0009-lua-as-the-function-language.md) chose Lua, executed in-process +via `yuin/gopher-lua`, as the function authoring language, and noted that +"broader language support would be a separate, larger decision." This is that +decision. + +The execution layer was already designed for more than one language: the engine +depends on an `engine.Runtime` interface, not on Lua, and the host capabilities +(HTTP client, KV, env, logging, AI, email, plus the pure utilities) live in +language-agnostic `internal/services` and `internal/runtime` packages. Only the +thin binding layer in `internal/runner` was Lua-specific. + +We wanted to offer a second language that (a) reuses that same host surface, (b) +preserves the in-process, single-binary, capability-sandboxed model, and (c) +gives users an alternative idiom. Lua's permissive sandbox (the base stdlib must +be actively restricted) and its unfamiliarity to some users were the main +motivations to look further. + +## Decision + +We will add **Starlark** as a second function language, executed in-process via +[`google/starlark-go`](https://github.com/google/starlark-go), alongside Lua. + +- A new `internal/starlarkrt` package implements `engine.Runtime`, mirroring + `internal/runner`: the same collaborators, the same `handler(ctx, event)` + contract, and the same module set (`log`, `kv`, `env`, `http`, `json`, + `base64`, `crypto`, `time`, `url`, `strings`, `random`, `router`, `ai`, + `email`). `ctx` and `event` are passed as structs (attribute access); the + handler returns a dict describing the HTTP response. Fallible host calls keep + Lua's two-value convention through tuple unpacking (`resp, err = http.get(...)`). +- Language is **chosen once, at function creation**, and is sticky thereafter. + It is stored per version (`function_versions.language` column, migration + `000011`, default `'lua'` so existing rows are unchanged); when a later version + is created (an edit/deploy) without an explicit language, the store carries the + function's most recent version's language forward. It is set via + `CreateFunctionInput.language` (GraphQL `Language` enum) and the + `lunar functions create --language` flag; `updateFunction` does not accept a + language. +- The engine selects the runtime by the executing version's language. Runtimes + are contributed to an fx value group (`group:"runtimes"`), each tagged with its + language, so adding a third language touches neither the engine nor existing + runtimes. An empty language defaults to Lua. + +## Consequences + +- Both languages share one host surface and timeout/sandbox model; a new + capability added to `internal/services` is exposed to both with two small + bindings. +- Starlark tightens the sandbox: it is deterministic and side-effect-free by + default (no filesystem, network, or clocks except through our APIs) and + supports bounded execution, so the capability model is enforced by the language + rather than by stripping a stdlib. +- `starlark-go` is pure Go, preserving the CGo-free single binary (see + [ADR-0007](0007-single-binary-embedded-frontend.md)). +- The language is fixed at creation and inherited by every later version, so a + function's behavior is predictable: editing code never silently changes the + runtime. Switching an existing function's language is intentionally not + supported — create a new function instead. +- Cost: a second binding layer and a second API doc to keep in sync, and a second + long-lived API contract. Starlark is intentionally not full Python (no `while`, + no recursion by default, no classes, limited stdlib); the authoring guide is + explicit about this so users do not expect Python semantics. +- We commit to the same beta backward-compatibility stance for the Starlark APIs + as for Lua. diff --git a/docs/adr/README.md b/docs/adr/README.md index c20714f..76abd30 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,3 +30,4 @@ to reverse-engineer it or interrupt the people who were there. | [0010](0010-in-house-i18n.md) | In-house i18n with locale modules | Accepted | | [0011](0011-testing-strategy.md) | Layered testing strategy without a JS test runner | Accepted | | [0012](0012-graphql-management-api.md) | GraphQL for the management API | Accepted | +| [0013](0013-starlark-as-a-second-function-language.md) | Starlark as a second function language | Accepted | diff --git a/e2e/function_create_test.go b/e2e/function_create_test.go index 6cfa3c2..6918cb8 100644 --- a/e2e/function_create_test.go +++ b/e2e/function_create_test.go @@ -48,6 +48,34 @@ func TestCreateFunctionWithAPITemplate(t *testing.T) { AssertFunctionCodeNot(functionName, "-- HTTP Handler") } +// TestCreateStarlarkFunction verifies selecting the Starlark language produces a +// Starlark function from the chosen template. +func TestCreateStarlarkFunction(t *testing.T) { + bt := newBrowserTest(t) + functionName := "star-func-" + time.Now().Format("150405") + + bt.Login("#!/functions/new"). + WaitVisible(`#function-language`). + Type(`#function-name`, functionName). + SelectOption(`#function-language`, "starlark"). + Sleep(200 * time.Millisecond). + Click(`.create-function-actions button`). + Sleep(1 * time.Second). + AssertURL("#!/functions"). + AssertURLNot("/new"). + AssertFunctionCode(functionName, + "# HTTP Handler", + "def handler(ctx, event):", + `"Hello from Starlark!"`, + ). + AssertFunctionCodeNot(functionName, "function handler(ctx, event)") + + fn := bt.GetFunction(functionName) + if fn == nil || fn.ActiveVersion.Language != "starlark" { + t.Fatalf("expected stored language %q, got %v", "starlark", fn) + } +} + // TestCreateFunctionValidation verifies validation error for empty name func TestCreateFunctionValidation(t *testing.T) { bt := newBrowserTest(t) diff --git a/e2e/function_execution_test.go b/e2e/function_execution_test.go new file mode 100644 index 0000000..1df1a4c --- /dev/null +++ b/e2e/function_execution_test.go @@ -0,0 +1,322 @@ +package e2e + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/dimiro1/lunar/internal/store" +) + +// seedFunction creates a function and an initial active version directly in the +// store, bypassing the UI. Used by execution tests that exercise the runtime +// over HTTP rather than the browser. +func seedFunction(t *testing.T, env *testEnv, id, language, code string) { + t.Helper() + ctx := context.Background() + if _, err := env.Store.CreateFunction(ctx, store.Function{ + ID: id, + Name: id, + EnvVars: map[string]string{}, + }); err != nil { + t.Fatalf("CreateFunction(%q): %v", id, err) + } + if _, err := env.Store.CreateVersion(ctx, id, code, language, nil); err != nil { + t.Fatalf("CreateVersion(%q): %v", id, err) + } +} + +// invoke sends an HTTP request to a function's execution endpoint and returns the +// response and its body. No auth is required for /fn execution. +func invoke(t *testing.T, env *testEnv, method, path, body string, headers map[string]string) (*http.Response, string) { + t.Helper() + req, err := http.NewRequest(method, env.Server.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + b, _ := io.ReadAll(resp.Body) + return resp, string(b) +} + +func TestExecute_Lua_HelloWorld(t *testing.T) { + env := startTestServer(t) + seedFunction(t, env, "lua_hello", "lua", ` +function handler(ctx, event) + return { + statusCode = 200, + headers = { ["Content-Type"] = "text/plain" }, + body = "hello-lua" + } +end`) + + resp, body := invoke(t, env, "GET", "/fn/lua_hello", "", nil) + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, body) + } + if body != "hello-lua" { + t.Errorf("body = %q, want hello-lua", body) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/plain" { + t.Errorf("Content-Type = %q, want text/plain", ct) + } + if resp.Header.Get("X-Execution-Id") == "" { + t.Error("expected X-Execution-Id header to be set") + } +} + +func TestExecute_Starlark_HelloWorld(t *testing.T) { + env := startTestServer(t) + seedFunction(t, env, "star_hello", "starlark", ` +def handler(ctx, event): + return { + "statusCode": 200, + "headers": {"Content-Type": "text/plain"}, + "body": "hello-starlark", + }`) + + resp, body := invoke(t, env, "GET", "/fn/star_hello", "", nil) + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, body) + } + if body != "hello-starlark" { + t.Errorf("body = %q, want hello-starlark", body) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/plain" { + t.Errorf("Content-Type = %q, want text/plain", ct) + } +} + +func TestExecute_EventMapping(t *testing.T) { + env := startTestServer(t) + seedFunction(t, env, "lua_echo", "lua", ` +function handler(ctx, event) + return { + statusCode = 200, + body = json.encode({ + method = event.method, + rel = event.relativePath, + q = event.query.foo, + h = event.headers["X-Test"], + body = event.body, + }) + } +end`) + seedFunction(t, env, "star_echo", "starlark", ` +def handler(ctx, event): + body, _ = json.encode({ + "method": event.method, + "rel": event.relativePath, + "q": event.query.get("foo", ""), + "h": event.headers.get("X-Test", ""), + "body": event.body, + }) + return {"statusCode": 200, "body": body}`) + + for _, id := range []string{"lua_echo", "star_echo"} { + resp, body := invoke(t, env, "POST", "/fn/"+id+"/sub/path?foo=bar", + "the-body", map[string]string{"X-Test": "hi"}) + if resp.StatusCode != 200 { + t.Fatalf("%s: status = %d (body: %s)", id, resp.StatusCode, body) + } + for _, want := range []string{ + `"method":"POST"`, + `"rel":"/sub/path"`, + `"q":"bar"`, + `"h":"hi"`, + `"body":"the-body"`, + } { + if !strings.Contains(body, want) { + t.Errorf("%s: body %q missing %q", id, body, want) + } + } + } +} + +func TestExecute_KVPersistsAcrossInvocations(t *testing.T) { + env := startTestServer(t) + seedFunction(t, env, "lua_counter", "lua", ` +function handler(ctx, event) + local n = tonumber(kv.get("c") or "0") + 1 + kv.set("c", tostring(n)) + return { statusCode = 200, body = tostring(n) } +end`) + seedFunction(t, env, "star_counter", "starlark", ` +def handler(ctx, event): + n = int(kv.get("c") or "0") + 1 + kv.set("c", str(n)) + return {"statusCode": 200, "body": str(n)}`) + + for _, id := range []string{"lua_counter", "star_counter"} { + _, first := invoke(t, env, "GET", "/fn/"+id, "", nil) + _, second := invoke(t, env, "GET", "/fn/"+id, "", nil) + if first != "1" || second != "2" { + t.Errorf("%s: counter = %q,%q want 1,2", id, first, second) + } + } +} + +func TestExecute_Starlark_JSONRoundTrip(t *testing.T) { + env := startTestServer(t) + seedFunction(t, env, "star_json", "starlark", ` +def handler(ctx, event): + data, err = json.decode(event.body) + if err != None: + return {"statusCode": 400, "body": err} + out, _ = json.encode({"next": data["n"] + 1}) + return {"statusCode": 200, "body": out}`) + + resp, body := invoke(t, env, "POST", "/fn/star_json", `{"n": 41}`, nil) + if resp.StatusCode != 200 { + t.Fatalf("status = %d (body: %s)", resp.StatusCode, body) + } + if !strings.Contains(body, `"next":42`) { + t.Errorf("body = %q, want next:42", body) + } +} + +func TestExecute_CustomStatusFromHandler(t *testing.T) { + env := startTestServer(t) + seedFunction(t, env, "star_404", "starlark", ` +def handler(ctx, event): + return {"statusCode": 404, "body": "nope"}`) + + resp, body := invoke(t, env, "GET", "/fn/star_404", "", nil) + if resp.StatusCode != 404 { + t.Errorf("status = %d, want 404", resp.StatusCode) + } + if body != "nope" { + t.Errorf("body = %q, want nope", body) + } +} + +func TestExecute_HandlerError(t *testing.T) { + env := startTestServer(t) + seedFunction(t, env, "lua_boom", "lua", + `function handler(ctx, event) error("boom") end`) + seedFunction(t, env, "star_boom", "starlark", + `def handler(ctx, event): + fail("boom")`) + + for _, id := range []string{"lua_boom", "star_boom"} { + resp, _ := invoke(t, env, "GET", "/fn/"+id, "", nil) + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("%s: status = %d, want 500", id, resp.StatusCode) + } + } +} + +func TestExecute_NotFound(t *testing.T) { + env := startTestServer(t) + resp, _ := invoke(t, env, "GET", "/fn/does-not-exist", "", nil) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404", resp.StatusCode) + } +} + +func TestExecute_DisabledFunction(t *testing.T) { + env := startTestServer(t) + seedFunction(t, env, "lua_disabled", "lua", + `function handler(ctx, event) return { statusCode = 200 } end`) + + disabled := true + if err := env.Store.UpdateFunction(context.Background(), "lua_disabled", + store.UpdateFunctionRequest{Disabled: &disabled}); err != nil { + t.Fatalf("UpdateFunction: %v", err) + } + + resp, _ := invoke(t, env, "GET", "/fn/lua_disabled", "", nil) + if resp.StatusCode != http.StatusForbidden { + t.Errorf("status = %d, want 403", resp.StatusCode) + } +} + +func TestExecute_EnvVar(t *testing.T) { + env := startTestServer(t) + seedFunction(t, env, "lua_env", "lua", + `function handler(ctx, event) return { statusCode = 200, body = env.get("GREETING") or "unset" } end`) + seedFunction(t, env, "star_env", "starlark", + `def handler(ctx, event): + return {"statusCode": 200, "body": env.get("GREETING") or "unset"}`) + + if err := env.EnvStore.Set("lua_env", "GREETING", "hola"); err != nil { + t.Fatalf("env set: %v", err) + } + if err := env.EnvStore.Set("star_env", "GREETING", "hej"); err != nil { + t.Fatalf("env set: %v", err) + } + + if _, body := invoke(t, env, "GET", "/fn/lua_env", "", nil); body != "hola" { + t.Errorf("lua env body = %q, want hola", body) + } + if _, body := invoke(t, env, "GET", "/fn/star_env", "", nil); body != "hej" { + t.Errorf("starlark env body = %q, want hej", body) + } +} + +func TestExecute_Router(t *testing.T) { + env := startTestServer(t) + seedFunction(t, env, "star_router", "starlark", ` +def handler(ctx, event): + if router.match(event.relativePath, "/users/:id"): + params = router.params(event.relativePath, "/users/:id") + return {"statusCode": 200, "body": params["id"]} + return {"statusCode": 404, "body": "no match"}`) + + resp, body := invoke(t, env, "GET", "/fn/star_router/users/99", "", nil) + if resp.StatusCode != 200 || body != "99" { + t.Errorf("got %d %q, want 200 99", resp.StatusCode, body) + } + + resp2, _ := invoke(t, env, "GET", "/fn/star_router/posts/1", "", nil) + if resp2.StatusCode != 404 { + t.Errorf("unmatched route status = %d, want 404", resp2.StatusCode) + } +} + +// TestExecute_StarlarkKeywordArgs verifies the Starlark host functions accept +// keyword arguments (a Starlark-only ergonomic the Lua runtime cannot offer). +func TestExecute_StarlarkKeywordArgs(t *testing.T) { + env := startTestServer(t) + seedFunction(t, env, "star_kwargs", "starlark", ` +def handler(ctx, event): + kv.set(key="k", value="v") + return {"statusCode": 200, "body": kv.get(key="k")}`) + + resp, body := invoke(t, env, "GET", "/fn/star_kwargs", "", nil) + if resp.StatusCode != 200 || body != "v" { + t.Errorf("got %d %q, want 200 v", resp.StatusCode, body) + } +} + +// TestExecute_LanguageStickyAcrossVersions verifies that a new version created +// without an explicit language keeps running on the function's language: a +// second Starlark version (language "") must still execute as Starlark. +func TestExecute_LanguageStickyAcrossVersions(t *testing.T) { + env := startTestServer(t) + ctx := context.Background() + seedFunction(t, env, "sticky", "starlark", + `def handler(ctx, event): + return {"statusCode": 200, "body": "v1"}`) + + // New version, no language specified — must inherit Starlark. + if _, err := env.Store.CreateVersion(ctx, "sticky", + `def handler(ctx, event): + return {"statusCode": 200, "body": "v2"}`, "", nil); err != nil { + t.Fatalf("CreateVersion v2: %v", err) + } + + resp, body := invoke(t, env, "GET", "/fn/sticky", "", nil) + if resp.StatusCode != 200 || body != "v2" { + t.Errorf("got %d %q, want 200 v2 (a Lua runtime would have failed to parse)", resp.StatusCode, body) + } +} diff --git a/e2e/function_pages_test.go b/e2e/function_pages_test.go new file mode 100644 index 0000000..212c9fc --- /dev/null +++ b/e2e/function_pages_test.go @@ -0,0 +1,107 @@ +package e2e + +import ( + "context" + "strings" + "testing" + "time" +) + +// assertLangLabel checks the editor's language label case-insensitively (it is +// uppercased by CSS, so the rendered text is e.g. "LUA"). +func assertLangLabel(t *testing.T, bt *browserTest, want string) { + t.Helper() + got := bt.GetText(`.code-editor-lang`) + if !strings.EqualFold(strings.TrimSpace(got), want) { + t.Errorf("editor language label = %q, want %q (any case)", got, want) + } +} + +// TestCodePage_Lua shows the Lua editor language and the Lua API reference. +func TestCodePage_Lua(t *testing.T) { + bt := newBrowserTest(t) + seedFunction(t, bt.env, "page_lua", "lua", + "function handler(ctx, event) return { statusCode = 200 } end") + + bt.Login("#!/functions/page_lua"). + WaitVisible(`.function-details-title`). + AssertElementExists(`.code-editor-container`). + // The handler section (default) types event fields as "table" for Lua. + AssertText(`.api-reference`, "table") + assertLangLabel(t, bt, "lua") +} + +// TestCodePage_Starlark shows the Starlark editor language and the Starlark API +// reference (event fields typed as "dict", not "table"). +func TestCodePage_Starlark(t *testing.T) { + bt := newBrowserTest(t) + seedFunction(t, bt.env, "page_star", "starlark", + "def handler(ctx, event):\n return {\"statusCode\": 200}") + + bt.Login("#!/functions/page_star"). + WaitVisible(`.function-details-title`). + AssertElementExists(`.code-editor-container`). + AssertText(`.api-reference`, "dict") + assertLangLabel(t, bt, "starlark") +} + +// TestVersionsPage_ListsAllVersions verifies every version of a function is +// listed on the versions page. +func TestVersionsPage_ListsAllVersions(t *testing.T) { + bt := newBrowserTest(t) + ctx := context.Background() + seedFunction(t, bt.env, "page_versions", "lua", + "function handler(ctx, event) return { statusCode = 200, body = 'v1' } end") + if _, err := bt.env.Store.CreateVersion(ctx, "page_versions", + "function handler(ctx, event) return { statusCode = 200, body = 'v2' } end", + "", nil); err != nil { + t.Fatalf("CreateVersion v2: %v", err) + } + + bt.Login("#!/functions/page_versions/versions"). + WaitVisible(`tbody tr`). + AssertElementCount(`tbody tr`, 2) +} + +// TestSettingsPage_Renders verifies the settings page renders with the function +// name and the general settings controls. +func TestSettingsPage_Renders(t *testing.T) { + bt := newBrowserTest(t) + seedFunction(t, bt.env, "page_settings", "lua", + "function handler(ctx, event) return { statusCode = 200 } end") + + bt.Login("#!/functions/page_settings/settings"). + WaitVisible(`.function-details-title`). + AssertText(`.function-details-title`, "page_settings"). + AssertElementExists(`#save-response`). + AssertElementExists(`#logRetention`) +} + +// TestExecutionsPage_ShowsInvocations verifies that invoking a function records +// an execution that then appears on the executions page. +func TestExecutionsPage_ShowsInvocations(t *testing.T) { + bt := newBrowserTest(t) + seedFunction(t, bt.env, "page_exec", "lua", + "function handler(ctx, event) return { statusCode = 200, body = 'ok' } end") + + // Two invocations -> two execution records. + invoke(t, bt.env, "GET", "/fn/page_exec", "", nil) + invoke(t, bt.env, "GET", "/fn/page_exec", "", nil) + + bt.Login("#!/functions/page_exec/executions"). + WaitVisible(`tbody tr`). + AssertElementCount(`tbody tr`, 2) +} + +// TestExecutionsPage_Empty verifies the executions page renders for a function +// that has never been invoked (empty state, no crash). +func TestExecutionsPage_Empty(t *testing.T) { + bt := newBrowserTest(t) + seedFunction(t, bt.env, "page_exec_empty", "lua", + "function handler(ctx, event) return { statusCode = 200 } end") + + bt.Login("#!/functions/page_exec_empty/executions"). + WaitVisible(`.function-details-title`). + Sleep(300 * time.Millisecond). + AssertElementCount(`tbody tr`, 0) +} diff --git a/e2e/functions_list_test.go b/e2e/functions_list_test.go index 4ffa6bc..b7bfff8 100644 --- a/e2e/functions_list_test.go +++ b/e2e/functions_list_test.go @@ -30,3 +30,19 @@ func TestNewFunctionButtonPresent(t *testing.T) { WaitVisible(`a[href="#!/functions/new"]`). AssertText(`a[href="#!/functions/new"]`, "New Function") } + +// TestFunctionsListShowsLanguage verifies the list displays each function's +// language alongside its row. +func TestFunctionsListShowsLanguage(t *testing.T) { + bt := newBrowserTest(t) + seedFunction(t, bt.env, "list_lua_fn", "lua", + "function handler(ctx, event) return { statusCode = 200 } end") + seedFunction(t, bt.env, "list_star_fn", "starlark", + "def handler(ctx, event):\n return {\"statusCode\": 200}") + + bt.Login("#!/functions"). + WaitVisible(`table tbody tr`). + AssertTextI(`thead`, "Language"). + AssertTextI(`tbody`, "Lua"). + AssertTextI(`tbody`, "Starlark") +} diff --git a/e2e/helpers_test.go b/e2e/helpers_test.go index c70fb2d..e280042 100644 --- a/e2e/helpers_test.go +++ b/e2e/helpers_test.go @@ -25,8 +25,9 @@ const testAPIKey = "test-api-key-12345" // testEnv holds the test server and database for e2e tests type testEnv struct { - Server *httptest.Server - Store *store.SQLiteDB + Server *httptest.Server + Store *store.SQLiteDB + EnvStore env.Store } // browserTest provides a fluent API for writing e2e tests @@ -97,6 +98,18 @@ func (bt *browserTest) Type(selector, text string) *browserTest { return bt.Run(chromedp.SendKeys(selector, text, chromedp.ByQuery)) } +// SelectOption sets a