Feat v0.9.0 multi-surface packages (#73)
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m48s
CI/CD / test-sqlite (pull_request) Successful in 2m57s
CI/CD / build-and-deploy (pull_request) Successful in 1m23s

Packages can declare a `surfaces` array with per-path access controls,
titles, and layouts. A single package can serve a public form, an
authenticated dashboard, and an admin page — each with independent
access enforcement.

Kernel:
- Manifest validation for surfaces array (path, access, duplicates)
- Auto-synthesis from legacy auth/layout for existing packages
- Unified /s/:slug route tree (RegisterExtensionRoutes) dispatches
  between surface rendering and ext API calls
- matchSurface() with Gin-style :param support and specificity ordering
- evaluateAccess() for per-surface access checks
- Nav filters out pending_review packages (pre-existing bug fix)

Frontend:
- __SURFACE_PATH__ and __SURFACE_PARAMS__ template injection
- sw.navigate(path, params) for SPA-style intra-package routing
- surface.navigate event + popstate handling
- SDK version bumped to 0.9.0

Docs:
- MULTI-SURFACE-GUIDE.md — full developer guide
- PACKAGE-FORMAT.md — surfaces field reference
- CHANGELOG.md, ROADMAP.md updated

22 new tests (11 manifest validation, 11 route matching/nav).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 12:34:38 +00:00
parent 98fd3eb3e6
commit c2473efee2
12 changed files with 1075 additions and 62 deletions

View File

@@ -0,0 +1,175 @@
package pages
import (
"testing"
)
// ── matchSurface tests ──────────────────────────────────────
func TestMatchSurface_StaticRoot(t *testing.T) {
surfaces := []any{
map[string]any{"path": "/", "title": "Dashboard"},
map[string]any{"path": "/submit", "title": "Submit"},
}
s, params := matchSurface(surfaces, "/")
if s == nil {
t.Fatal("expected match for /")
}
if s["title"] != "Dashboard" {
t.Errorf("expected Dashboard, got %v", s["title"])
}
if len(params) != 0 {
t.Errorf("expected no params, got %v", params)
}
}
func TestMatchSurface_StaticPath(t *testing.T) {
surfaces := []any{
map[string]any{"path": "/", "title": "Dashboard"},
map[string]any{"path": "/submit", "title": "Submit"},
map[string]any{"path": "/monitor", "title": "Monitor"},
}
s, _ := matchSurface(surfaces, "/submit")
if s == nil {
t.Fatal("expected match for /submit")
}
if s["title"] != "Submit" {
t.Errorf("expected Submit, got %v", s["title"])
}
}
func TestMatchSurface_ParamPath(t *testing.T) {
surfaces := []any{
map[string]any{"path": "/", "title": "Dashboard"},
map[string]any{"path": "/:id", "title": "Detail"},
}
s, params := matchSurface(surfaces, "/abc123")
if s == nil {
t.Fatal("expected match for /:id")
}
if s["title"] != "Detail" {
t.Errorf("expected Detail, got %v", s["title"])
}
if params["id"] != "abc123" {
t.Errorf("expected id=abc123, got %v", params["id"])
}
}
func TestMatchSurface_MultiSegmentParam(t *testing.T) {
surfaces := []any{
map[string]any{"path": "/monitor", "title": "Monitor"},
map[string]any{"path": "/monitor/:id", "title": "Instance Detail"},
}
s, params := matchSurface(surfaces, "/monitor/inst-42")
if s == nil {
t.Fatal("expected match for /monitor/:id")
}
if s["title"] != "Instance Detail" {
t.Errorf("expected Instance Detail, got %v", s["title"])
}
if params["id"] != "inst-42" {
t.Errorf("expected id=inst-42, got %v", params["id"])
}
}
func TestMatchSurface_NoMatch(t *testing.T) {
surfaces := []any{
map[string]any{"path": "/", "title": "Dashboard"},
map[string]any{"path": "/submit", "title": "Submit"},
}
s, _ := matchSurface(surfaces, "/nonexistent/deep")
if s != nil {
t.Errorf("expected no match, got %v", s)
}
}
func TestMatchSurface_StaticPreferredOverParam(t *testing.T) {
surfaces := []any{
map[string]any{"path": "/:id", "title": "Detail"},
map[string]any{"path": "/new", "title": "New"},
}
s, params := matchSurface(surfaces, "/new")
if s == nil {
t.Fatal("expected match for /new")
}
if s["title"] != "New" {
t.Errorf("expected static match 'New', got %v", s["title"])
}
if len(params) != 0 {
t.Errorf("expected no params for static match, got %v", params)
}
}
func TestMatchSurface_EmptySurfaces(t *testing.T) {
s, _ := matchSurface([]any{}, "/")
if s != nil {
t.Errorf("expected no match for empty surfaces, got %v", s)
}
}
// ── splitPath tests ─────────────────────────────────────────
func TestSplitPath(t *testing.T) {
tests := []struct {
input string
want int
}{
{"/", 0},
{"/submit", 1},
{"/monitor/:id", 2},
{"/:id/edit", 2},
}
for _, tt := range tests {
parts := splitPath(tt.input)
if len(parts) != tt.want {
t.Errorf("splitPath(%q) = %d parts, want %d", tt.input, len(parts), tt.want)
}
}
}
// ── findNavSurface tests ────────────────────────────────────
func TestFindNavSurface_ExplicitNav(t *testing.T) {
surfaces := []any{
map[string]any{"path": "/", "title": "Dashboard"},
map[string]any{"path": "/monitor", "title": "Monitor", "nav": true},
}
s := findNavSurface(surfaces)
if s == nil {
t.Fatal("expected nav surface")
}
if s["title"] != "Monitor" {
t.Errorf("expected Monitor, got %v", s["title"])
}
}
func TestFindNavSurface_FallbackRoot(t *testing.T) {
surfaces := []any{
map[string]any{"path": "/submit", "title": "Submit"},
map[string]any{"path": "/", "title": "Dashboard"},
}
s := findNavSurface(surfaces)
if s == nil {
t.Fatal("expected nav surface")
}
if s["title"] != "Dashboard" {
t.Errorf("expected Dashboard, got %v", s["title"])
}
}
func TestFindNavSurface_NoMatch(t *testing.T) {
surfaces := []any{
map[string]any{"path": "/submit", "title": "Submit"},
map[string]any{"path": "/admin", "title": "Admin"},
}
s := findNavSurface(surfaces)
if s != nil {
t.Errorf("expected nil, got %v", s)
}
}
// ── evaluateAccess tests ────────────────────────────────────
// Note: evaluateAccess depends on gin.Context which is hard to unit test
// without a full Gin setup. These are covered via handler integration tests.
// The matchSurface + splitPath + findNavSurface functions above are the
// pure-logic components that benefit most from unit testing.