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,137 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAcquireLockfile_FreshDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
release, err := AcquireLockfile(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("AcquireLockfile: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Lockfile should exist with our PID.
|
||||
data, err := os.ReadFile(filepath.Join(dir, "tinyforge.lock"))
|
||||
if err != nil {
|
||||
t.Fatalf("read lockfile: %v", err)
|
||||
}
|
||||
want := fmt.Sprintf("%d\n", os.Getpid())
|
||||
if string(data) != want {
|
||||
t.Errorf("lockfile content = %q, want %q", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireLockfile_HeldByLivePID_Refused(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Plant a lockfile holding the current PID (which is obviously alive).
|
||||
if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"),
|
||||
[]byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
|
||||
t.Fatalf("plant lockfile: %v", err)
|
||||
}
|
||||
release, err := AcquireLockfile(dir)
|
||||
if err == nil {
|
||||
release()
|
||||
t.Fatal("expected ErrLockHeld, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrLockHeld) {
|
||||
t.Errorf("error = %v, want wrap of ErrLockHeld", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireLockfile_StalePID_Reclaimed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// PID 1 is init/launchd/systemd on POSIX and the System Idle Process
|
||||
// on Windows — never our process, and very unlikely to be dead. We
|
||||
// use a deliberately-impossible PID instead: a 31-bit value far
|
||||
// above any plausible system maximum.
|
||||
stalePID := 2147483640
|
||||
if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"),
|
||||
[]byte(fmt.Sprintf("%d\n", stalePID)), 0o600); err != nil {
|
||||
t.Fatalf("plant stale lockfile: %v", err)
|
||||
}
|
||||
release, err := AcquireLockfile(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("expected reclaim of stale lock, got: %v", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Verify it now holds OUR pid, not the stale one.
|
||||
data, err := os.ReadFile(filepath.Join(dir, "tinyforge.lock"))
|
||||
if err != nil {
|
||||
t.Fatalf("read lockfile after reclaim: %v", err)
|
||||
}
|
||||
want := fmt.Sprintf("%d\n", os.Getpid())
|
||||
if string(data) != want {
|
||||
t.Errorf("lockfile content after reclaim = %q, want %q", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireLockfile_ConcurrentReclaim_SingleWinner(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Plant a stale lockfile (impossibly high, certainly-dead PID), then have
|
||||
// many goroutines race to reclaim it. Exactly one must win; the rest must
|
||||
// be refused with ErrLockHeld. A "last-writer-wins" reclaim would let
|
||||
// several goroutines all believe they own the lock.
|
||||
stalePID := 2147483640
|
||||
if err := os.WriteFile(filepath.Join(dir, "tinyforge.lock"),
|
||||
[]byte(fmt.Sprintf("%d\n", stalePID)), 0o600); err != nil {
|
||||
t.Fatalf("plant stale lockfile: %v", err)
|
||||
}
|
||||
|
||||
const n = 16
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
winners int
|
||||
releases []func()
|
||||
)
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
release, err := AcquireLockfile(dir)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrLockHeld) {
|
||||
t.Errorf("loser error = %v, want wrap of ErrLockHeld", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
winners++
|
||||
releases = append(releases, release)
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for _, r := range releases {
|
||||
r()
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Fatalf("concurrent reclaim winners = %d, want exactly 1", winners)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireLockfile_ReleaseRemovesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
release, err := AcquireLockfile(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("AcquireLockfile: %v", err)
|
||||
}
|
||||
release()
|
||||
|
||||
path := filepath.Join(dir, "tinyforge.lock")
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("lockfile still present after release: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user