Files
alexei.dolgolyov 410a131cec 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.
2026-05-29 02:09:54 +03:00

138 lines
3.6 KiB
Go

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)
}
}