Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
61 changes: 0 additions & 61 deletions .github/workflows/ci.yml

This file was deleted.

37 changes: 12 additions & 25 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: '1.26'

- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.33.0

- name: Build UI
run: make build-ui
go-version-file: go.mod

- name: Install lint dependencies
run: make depend
Expand All @@ -37,21 +29,16 @@ jobs:

test:
name: Test
strategy:
fail-fast: true
matrix:
go: ['1.26']
os: ['ubuntu-latest']
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest

steps:
- name: Check out code
uses: actions/checkout@v7

- name: Set up Go ${{ matrix.go }}
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go }}
go-version-file: go.mod

- name: Setup pnpm
uses: pnpm/action-setup@v6
Expand All @@ -61,18 +48,18 @@ jobs:
- name: Install Chrome
uses: browser-actions/setup-chrome@v2

- name: Install dependencies
run: go mod download

- name: Build UI
run: make build-ui

- name: Build
run: |
cd cmd/mdl && go build
cd ../stz && go build
- name: Test UI
working-directory: cmd/mdl/webapp
run: pnpm test

- name: Type check UI
working-directory: cmd/mdl/webapp
run: pnpm exec tsc --noEmit

- name: Run tests
- name: Test Go and browser behavior
env:
CHROME_BIN: google-chrome
run: make test
110 changes: 110 additions & 0 deletions cmd/mdl/headless.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// This file matches browser render results to the exact CLI request that
// started them. View IDs and model digests prevent one run from accepting
// another run's output.
package main

import (
"encoding/json"
"fmt"
"net/http"
"sync"
)

type (
headlessResult struct {
Status string `json:"status"`
ViewID string `json:"viewId"`
ModelDigest string `json:"modelDigest"`
SVG string `json:"svg,omitempty"`
Error string `json:"error,omitempty"`
}

renderKey struct {
viewID string
modelDigest string
}

renderBroker struct {
mu sync.Mutex
waiters map[renderKey]chan headlessResult
}
)

// newRenderBroker creates an empty result matcher for one mdl svg process.
func newRenderBroker() *renderBroker {
return &renderBroker{waiters: make(map[renderKey]chan headlessResult)}
}

// register creates the one result channel allowed for a view and model digest.
func (b *renderBroker) register(viewID, modelDigest string) (<-chan headlessResult, func(), error) {
key := renderKey{viewID: viewID, modelDigest: modelDigest}
b.mu.Lock()
defer b.mu.Unlock()
if _, exists := b.waiters[key]; exists {
return nil, nil, fmt.Errorf("render %s is already registered", viewID)
}
result := make(chan headlessResult, 1)
b.waiters[key] = result
unregister := func() {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.waiters, key)
}
return result, unregister, nil
}

// handleResult accepts one complete or failed browser result for a registered request.
func (b *renderBroker) handleResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<20))
decoder.DisallowUnknownFields()
var result headlessResult
if err := decoder.Decode(&result); err != nil {
http.Error(w, fmt.Sprintf("decode render result: %v", err), http.StatusBadRequest)
return
}
if err := validateHeadlessResult(result); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

key := renderKey{viewID: result.ViewID, modelDigest: result.ModelDigest}
b.mu.Lock()
waiter, exists := b.waiters[key]
if exists {
select {
case waiter <- result:
default:
exists = false
}
}
b.mu.Unlock()
if !exists {
http.Error(w, "no matching render request", http.StatusConflict)
return
}
w.WriteHeader(http.StatusAccepted)
}

// validateHeadlessResult requires exactly the fields allowed by each status.
func validateHeadlessResult(result headlessResult) error {
if result.ViewID == "" || result.ModelDigest == "" {
return fmt.Errorf("viewId and modelDigest are required")
}
switch result.Status {
case "complete":
if result.SVG == "" || result.Error != "" {
return fmt.Errorf("complete result requires svg and forbids error")
}
case "error":
if result.Error == "" || result.SVG != "" {
return fmt.Errorf("error result requires error and forbids svg")
}
default:
return fmt.Errorf("invalid render status %q", result.Status)
}
return nil
}
83 changes: 83 additions & 0 deletions cmd/mdl/headless_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package main

import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
)

func TestRenderBrokerMatchesViewAndDigest(t *testing.T) {
broker := newRenderBroker()
results, unregister, err := broker.register("AURA Services", "digest")
if err != nil {
t.Fatalf("register render: %v", err)
}
defer unregister()

body := bytes.NewBufferString(
`{"status":"complete","viewId":"AURA Services","modelDigest":"digest","svg":"<svg/>"}`,
)
request := httptest.NewRequest(http.MethodPost, "/headless/result", body)
response := httptest.NewRecorder()
broker.handleResult(response, request)
if response.Code != http.StatusAccepted {
t.Fatalf("expected accepted result, got %d: %s", response.Code, response.Body.String())
}
result := <-results
if result.ViewID != "AURA Services" || result.ModelDigest != "digest" {
t.Fatalf("received wrong result: %#v", result)
}
}

func TestRenderBrokerRejectsUnmatchedResult(t *testing.T) {
broker := newRenderBroker()
body := bytes.NewBufferString(
`{"status":"error","viewId":"Flows","modelDigest":"other","error":"failed"}`,
)
request := httptest.NewRequest(http.MethodPost, "/headless/result", body)
response := httptest.NewRecorder()
broker.handleResult(response, request)
if response.Code != http.StatusConflict {
t.Fatalf("expected conflict, got %d", response.Code)
}
}

func TestValidateHeadlessResult(t *testing.T) {
tests := []struct {
name string
result headlessResult
}{
{
name: "complete without SVG",
result: headlessResult{
Status: "complete",
ViewID: "view",
ModelDigest: "digest",
},
},
{
name: "error without detail",
result: headlessResult{
Status: "error",
ViewID: "view",
ModelDigest: "digest",
},
},
{
name: "unknown status",
result: headlessResult{
Status: "running",
ViewID: "view",
ModelDigest: "digest",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := validateHeadlessResult(test.result); err == nil {
t.Fatal("expected validation error")
}
})
}
}
Loading
Loading