feat(apps): stepped creation wizard, branch previews, and app-creation fixes
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// fakeStore is a minimal in-memory store satisfying the preview.Store
|
||||
// interface. Tests verify business logic without the SQLite layer.
|
||||
type fakeStore struct {
|
||||
workloads map[string]store.Workload
|
||||
createErr error
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{workloads: map[string]store.Workload{}}
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetWorkloadByID(id string) (store.Workload, error) {
|
||||
w, ok := f.workloads[id]
|
||||
if !ok {
|
||||
return store.Workload{}, errors.New("not found")
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListChildrenByParent(parentID string) ([]store.Workload, error) {
|
||||
out := []store.Workload{}
|
||||
for _, w := range f.workloads {
|
||||
if w.ParentWorkloadID == parentID {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateWorkload(w store.Workload) (store.Workload, error) {
|
||||
if f.createErr != nil {
|
||||
return store.Workload{}, f.createErr
|
||||
}
|
||||
if w.ID == "" {
|
||||
w.ID = "preview-" + w.Name
|
||||
}
|
||||
f.workloads[w.ID] = w
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteWorkload(id string) error {
|
||||
delete(f.workloads, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSlugifyBranch_StripsUnsafeChars(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"main", "main"},
|
||||
{"Feature/User-Auth", "feature-user-auth"},
|
||||
{"PR#42", "pr-42"},
|
||||
{"release/v1.2.3", "release-v1-2-3"},
|
||||
{"___", "branch"},
|
||||
{strings.Repeat("a", 50), strings.Repeat("a", 32)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := slugifyBranch(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("slugifyBranch(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchSourceConfigBranch_PreservesUnknownKeys(t *testing.T) {
|
||||
src := `{"port":3000,"dockerfile_path":"Dockerfile","branch":"main","provider":"github"}`
|
||||
out, err := patchSourceConfigBranch(src, "feat/x")
|
||||
if err != nil {
|
||||
t.Fatalf("patch: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got["branch"] != "feat/x" {
|
||||
t.Errorf("branch = %v, want feat/x", got["branch"])
|
||||
}
|
||||
if got["port"] == nil || got["dockerfile_path"] == nil || got["provider"] == nil {
|
||||
t.Errorf("unknown keys dropped: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchPublicFacesSubdomain_PrefixesSubdomains(t *testing.T) {
|
||||
faces := `[{"subdomain":"app","domain":"example.com"},{"subdomain":"","domain":"raw.example.com"}]`
|
||||
out, err := patchPublicFacesSubdomain(faces, "feat-x")
|
||||
if err != nil {
|
||||
t.Fatalf("patch: %v", err)
|
||||
}
|
||||
var got []map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got[0]["subdomain"] != "feat-x-app" {
|
||||
t.Errorf("first subdomain = %v, want feat-x-app", got[0]["subdomain"])
|
||||
}
|
||||
if got[1]["subdomain"] != "" {
|
||||
t.Errorf("empty subdomain must stay empty, got %v", got[1]["subdomain"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeForBranch_CreatesNewWhenMissing(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
template := store.Workload{
|
||||
ID: "tmpl-1",
|
||||
Kind: "project",
|
||||
Name: "myapp",
|
||||
AppID: "app-1",
|
||||
SourceKind: "dockerfile",
|
||||
SourceConfig: `{"branch":"main","port":3000}`,
|
||||
TriggerKind: "git",
|
||||
PublicFaces: `[{"subdomain":"www","domain":"x.test"}]`,
|
||||
}
|
||||
fs.workloads[template.ID] = template
|
||||
|
||||
child, err := MaterializeForBranch(fs, template, "feat/login")
|
||||
if err != nil {
|
||||
t.Fatalf("materialize: %v", err)
|
||||
}
|
||||
if child.ParentWorkloadID != template.ID {
|
||||
t.Errorf("parent = %q, want %q", child.ParentWorkloadID, template.ID)
|
||||
}
|
||||
if !strings.Contains(child.Name, "feat-login") {
|
||||
t.Errorf("name = %q, want it to include slug", child.Name)
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(child.SourceConfig), &cfg); err != nil {
|
||||
t.Fatalf("decode child source_config: %v", err)
|
||||
}
|
||||
if cfg["branch"] != "feat/login" {
|
||||
t.Errorf("child branch = %v, want feat/login", cfg["branch"])
|
||||
}
|
||||
if cfg["port"] == nil {
|
||||
t.Errorf("child should inherit template port; got %+v", cfg)
|
||||
}
|
||||
var faces []map[string]any
|
||||
if err := json.Unmarshal([]byte(child.PublicFaces), &faces); err != nil {
|
||||
t.Fatalf("decode child faces: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(faces[0]["subdomain"].(string), "feat-login-") {
|
||||
t.Errorf("face subdomain = %v, want feat-login- prefix", faces[0]["subdomain"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeForBranch_ReusesExisting(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
template := store.Workload{
|
||||
ID: "tmpl-1",
|
||||
Kind: "project",
|
||||
Name: "myapp",
|
||||
SourceKind: "dockerfile",
|
||||
SourceConfig: `{"branch":"main"}`,
|
||||
}
|
||||
fs.workloads[template.ID] = template
|
||||
|
||||
first, err := MaterializeForBranch(fs, template, "feat/x")
|
||||
if err != nil {
|
||||
t.Fatalf("first materialize: %v", err)
|
||||
}
|
||||
second, err := MaterializeForBranch(fs, template, "feat/x")
|
||||
if err != nil {
|
||||
t.Fatalf("second materialize: %v", err)
|
||||
}
|
||||
if first.ID != second.ID {
|
||||
t.Errorf("expected idempotence: got %q then %q", first.ID, second.ID)
|
||||
}
|
||||
if len(fs.workloads) != 2 {
|
||||
t.Errorf("expected exactly one preview created, store has %d", len(fs.workloads))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeForBranch_RejectsEmptyBranch(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
_, err := MaterializeForBranch(fs, store.Workload{ID: "tmpl"}, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty branch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPreviewForBranch_MissingReturnsFalse(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
_, ok, err := FindPreviewForBranch(fs, "tmpl", "feat/x")
|
||||
if err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected ok=false for missing preview")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user