diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 0e6eadbe..6294003c 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -28,7 +28,7 @@ | `lerd tui` | Open a btop-style terminal dashboard with live site / service / worker status, per-site detail pane, inline domain and version editing, shell drop-in, log tailing, filter + sort, and global settings | | `lerd check` | Validate `.lerd.yaml` syntax, services, and PHP version before setup | | `lerd doctor` | Full environment diagnostic: podman, systemd, DNS, ports, PHP images, config validity; also reports how much podman disk is reclaimable. Add `--fix` to apply the safe automatic repairs (confirming each; `--yes` to skip prompts, `--dry-run` to preview); privileged and external-state findings are left for you to run. `--json` emits the findings, each tagged with a fix tier, for tooling | -| `lerd site:doctor [domain]` | App-level health checks for a single site (env file, services the site declares that this machine has never installed, which `--fix` and the dashboard's *Install the missing services* button install, ones that are installed but stopped, which the *Start the stopped services* button starts, services picked in `.lerd.yaml` that the site's env file does not point at, a key the env file sets more than once, env drift, application key, a configured database that is missing (a SQLite file that is absent or empty, or a MySQL/Postgres schema that does not exist on the service), composer/node dependency install + lock, `composer audit`/`npm audit`, PHP version range, an nginx vhost that no longer matches what lerd would write for the site, routes running well above the site's typical response time, plus the framework's own checks). A broken database suppresses the framework migration check so the remedy isn't repeated. Defaults to the site in the current directory; pass a domain to target another. Add `--json` for machine-readable output, or `--fix` to apply the findings lerd can resolve on its own and re-check | +| `lerd site:doctor [domain]` | App-level health checks for a single site (env file, services the site declares that this machine has never installed, which `--fix` and the dashboard's *Install the missing services* button install, ones that are installed but stopped, which the *Start the stopped services* button starts, services picked in `.lerd.yaml` that the site's env file does not point at, a key the env file sets more than once, env drift, application key, a configured database that is missing (a SQLite file that is absent or empty, or a MySQL/Postgres schema that does not exist on the service, which `--fix` and the dashboard's *Create the missing database* button create), composer/node dependency install + lock, `composer audit`/`npm audit`, PHP version range, an nginx vhost that no longer matches what lerd would write for the site, routes running well above the site's typical response time, plus the framework's own checks). A broken database suppresses the framework migration check so the remedy isn't repeated. Defaults to the site in the current directory; pass a domain to target another. Add `--json` for machine-readable output, or `--fix` to apply the findings lerd can resolve on its own and re-check | | `lerd cleanup` | Reclaim podman disk from orphaned lerd images (old PHP build and base images a rebuild left behind), unused service images no installed service references any more (e.g. an old `mysql:8.0` after upgrading, keeping each service's current image and its one-back rollback target), and dangling untagged images. Previews the list and confirms before removing. Never touches a tagged image in use, your databases, or volumes | | `lerd cleanup --dry-run` | Show what would be reclaimed and the approximate size, remove nothing | | `lerd cleanup --safe` | Only reclaim images provably built by lerd, leave unused service and dangling images alone | diff --git a/docs/usage/framework-definitions.md b/docs/usage/framework-definitions.md index 4d5bb890..8aa74e1c 100644 --- a/docs/usage/framework-definitions.md +++ b/docs/usage/framework-definitions.md @@ -409,7 +409,7 @@ The repeated-query warning is the case that needs it. On a content management sy The `doctor:` section adds framework-specific health checks to the ones every site gets for free (env file present, every picked service wired into it, dependencies installed and locked, audit clean, PHP version in range, nginx vhost current). They run on `lerd site:doctor` and in the dashboard's doctor panel. Keeping them declarative is what stops the doctor from growing a Go branch per framework. -The section also takes a `migrate_command`, naming whichever of the framework's own `commands:` applies the schema. The universal database checks offer it as their fix, so an empty or missing database is reported with the button that fills it. Every framework spells it differently (Laravel `migrate`, Symfony `doctrine:migrations:migrate`, Drupal `updb`), so nothing but the definition can say; a framework that declares none, or names a command it does not have, gets a finding with no fix rather than a button that maps to nothing. +The section also takes a `migrate_command`, naming whichever of the framework's own `commands:` applies the schema. The universal database checks offer it as their fix, so an empty or missing database is reported with the button that fills it. A server database that does not exist at all is the exception: migrations have nowhere to run until the schema is there, so that finding carries a button that creates it and the migrate button returns on the re-check. Every framework spells it differently (Laravel `migrate`, Symfony `doctrine:migrations:migrate`, Drupal `updb`), so nothing but the definition can say; a framework that declares none, or names a command it does not have, gets a finding with no fix rather than a button that maps to nothing. Each check carries a `name` (a stable id), a `type` that selects the evaluator, an optional `label` for display, an optional `detail` that overrides the generated message, an optional `severity`, and an optional `fix`. diff --git a/internal/cli/site_doctor.go b/internal/cli/site_doctor.go index f8337742..219e426d 100644 --- a/internal/cli/site_doctor.go +++ b/internal/cli/site_doctor.go @@ -92,9 +92,28 @@ func readyDeclaredServices(path string, fw *config.Framework, quiet bool) (bool, return changed, nil } +// createMissingDatabases creates the databases the project points at that their +// engine does not hold, which is what a missing schema needs before migrations +// have anywhere to run. It reports whether anything was created and stops at the +// first failure rather than working through an engine that just refused. +func createMissingDatabases(path string, quiet bool) (bool, error) { + created := false + for _, t := range sitedoctor.MissingDatabases(path) { + if _, err := serviceops.CreateDatabase(t.Service, t.Database); err != nil { + return created, fmt.Errorf("creating %s on %s: %w", t.Database, t.Service, err) + } + created = true + if !quiet { + fmt.Printf(" %s\n\n", feedback.Dim("created the "+t.Database+" database on "+t.Service)) + } + } + return created, nil +} + // applySiteDoctorFixes resolves the findings lerd can act on by itself and -// returns a fresh report: a drifted vhost is rewritten, and a service picked but -// not wired has its connection written. The composer and npm ones are left out; +// returns a fresh report: a drifted vhost is rewritten, a database the engine +// does not hold is created, and a service picked but not wired has its +// connection written. The composer and npm ones are left out; // they run in the site's container behind a run lock and stream their output, // which belongs to the surfaces that can show it. func applySiteDoctorFixes(path, fwName string, resp sitedoctor.Response, quiet bool) sitedoctor.Response { @@ -119,6 +138,14 @@ func applySiteDoctorFixes(path, fwName string, resp sitedoctor.Response, quiet b if ready { fixed = true } + case sitedoctor.FixCreateDatabase: + created, err := createMissingDatabases(path, quiet) + if err != nil { + feedback.Warn("%v", err) + } + if created { + fixed = true + } case sitedoctor.FixEnvSync: if err := runLerdEnvTo(path, fixOutput(quiet)); err != nil { feedback.Warn("writing the env: %v", err) diff --git a/internal/sitedoctor/server_database.go b/internal/sitedoctor/server_database.go index 14682ce8..e0cf73d3 100644 --- a/internal/sitedoctor/server_database.go +++ b/internal/sitedoctor/server_database.go @@ -40,19 +40,46 @@ func stubDatabaseLister(fn func(string) ([]string, error)) func() { // checked like any other. It used to read DB_CONNECTION, DB_HOST and DB_DATABASE // by name, which are Laravel's, so the frameworks least likely to be wired that // way were the ones it could not check. -func checkServerDatabase(path string, fw *config.Framework) (Check, bool) { - targets := config.DBTargetsFor(path) - if len(targets) == 0 { - // No lerd-run database: a file database, an external server, or nothing - // configured. None of those is this check's to judge. +// +// The fix creates the database rather than migrating it: migrations fail against +// a database the engine does not hold, so the schema has to exist first, and the +// migrate button returns on the re-check that follows. +func checkServerDatabase(path string) (Check, bool) { + missing, checked := missingDatabases(path) + if !checked { + // Either nothing could be asked of an engine, or the project points at no + // lerd-run database at all: a file database, an external server, or + // nothing configured. None of those is this check's to judge. return Check{}, false } - checked := false - for _, t := range targets { + if len(missing) == 0 { + return Check{Name: "server_database", Status: StatusOK}, true + } + named := make([]string, 0, len(missing)) + for _, t := range missing { + named = append(named, fmt.Sprintf("%q on %s", t.Database, t.Service)) + } + return Check{Name: "server_database", Status: StatusFail, Fix: FixCreateDatabase, + Detail: fmt.Sprintf("%s %s %s not exist. Create %s, then run migrations.", + plural(len(missing), "Database", "Databases"), strings.Join(named, ", "), + plural(len(missing), "does", "do"), plural(len(missing), "it", "them"))}, true +} + +// MissingDatabases returns the lerd-managed databases a project points at that +// their engine does not hold. Exported so the fix works the set out again +// rather than trusting the client, the same way the service fixes do. +func MissingDatabases(path string) []config.DBTarget { + missing, _ := missingDatabases(path) + return missing +} + +// missingDatabases pairs the set with whether any engine could be asked at all: +// an unreachable one leaves the site unjudged rather than reported as missing a +// schema that may well exist. +func missingDatabases(path string) (missing []config.DBTarget, checked bool) { + for _, t := range config.DBTargetsFor(path) { names, err := listDatabases(t.Service) if err != nil { - // The engine is down or unreachable. Reporting that as a missing - // schema would send the user to create a database that may exist. continue } checked = true @@ -64,13 +91,8 @@ func checkServerDatabase(path string, fw *config.Framework) (Check, bool) { } } if !found { - return Check{Name: "server_database", Status: StatusFail, Fix: migrateFix(fw), - Detail: fmt.Sprintf("Database %q does not exist on %s — create it with lerd db:create %s, then run migrations.", t.Database, t.Service, t.Database)}, true + missing = append(missing, t) } } - if !checked { - // Nothing could be asked, so there is nothing to report either way. - return Check{}, false - } - return Check{Name: "server_database", Status: StatusOK}, true + return missing, checked } diff --git a/internal/sitedoctor/server_database_declared_test.go b/internal/sitedoctor/server_database_declared_test.go index ac37fc7e..a42b170b 100644 --- a/internal/sitedoctor/server_database_declared_test.go +++ b/internal/sitedoctor/server_database_declared_test.go @@ -43,14 +43,13 @@ func TestCheckServerDatabase_readsAPHPSettingsFile(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "web/sites/default/settings.php"), []byte(body), 0o644); err != nil { t.Fatal(err) } - fw, ok := config.GetFrameworkForDir("drupalish", dir) - if !ok { + if _, ok := config.GetFrameworkForDir("drupalish", dir); !ok { t.Fatal("the test definition did not resolve") } restore := stubDatabaseLister(func(string) ([]string, error) { return []string{"other"}, nil }) defer restore() - c, produced := checkServerDatabase(dir, fw) + c, produced := checkServerDatabase(dir) if !produced || c.Status != StatusFail { t.Fatalf("check = %+v (produced=%v), want a failure for the missing schema", c, produced) } @@ -58,7 +57,7 @@ func TestCheckServerDatabase_readsAPHPSettingsFile(t *testing.T) { restore() restore2 := stubDatabaseLister(func(string) ([]string, error) { return []string{"shop"}, nil }) defer restore2() - if c, _ := checkServerDatabase(dir, fw); c.Status != StatusOK { + if c, _ := checkServerDatabase(dir); c.Status != StatusOK { t.Errorf("check = %+v, want ok once the database exists", c) } } diff --git a/internal/sitedoctor/server_database_fix_test.go b/internal/sitedoctor/server_database_fix_test.go new file mode 100644 index 00000000..140784a6 --- /dev/null +++ b/internal/sitedoctor/server_database_fix_test.go @@ -0,0 +1,66 @@ +package sitedoctor + +import ( + "errors" + "strings" + "testing" +) + +// A schema that does not exist is not something migrations can create: the +// migrate command fails against a database the engine does not have, so the +// finding used to name a remedy that could not work and sent the user to the +// CLI. The fix creates the database, and the migrate button comes back on the +// re-check that follows. +func TestCheckServerDatabase_offersToCreateTheDatabase(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("XDG_DATA_HOME", tmp) + + dir := t.TempDir() + writeEnv(t, dir, ".env", "DB_CONNECTION=mysql\nDB_HOST=lerd-mysql\nDB_DATABASE=shop\n") + restore := stubDatabaseLister(func(string) ([]string, error) { return []string{"other"}, nil }) + defer restore() + + c, ok := checkServerDatabase(dir) + if !ok || c.Status != StatusFail { + t.Fatalf("check = %+v (ok=%v), want a failure for the missing schema", c, ok) + } + if c.Fix != FixCreateDatabase { + t.Errorf("fix = %q, want %q", c.Fix, FixCreateDatabase) + } + if strings.Contains(c.Detail, "lerd db:create") { + t.Errorf("detail = %q, should not send the user to the CLI now the fix creates it", c.Detail) + } +} + +// The fix works the set out again rather than trusting the client, so it can +// only ever create what the check would have reported. +func TestMissingDatabases(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("XDG_DATA_HOME", tmp) + + dir := t.TempDir() + writeEnv(t, dir, ".env", "DB_CONNECTION=mysql\nDB_HOST=lerd-mysql\nDB_DATABASE=shop\n") + + restore := stubDatabaseLister(func(string) ([]string, error) { return []string{"other"}, nil }) + missing := MissingDatabases(dir) + restore() + if len(missing) != 1 || missing[0].Database != "shop" || missing[0].Service != "mysql" { + t.Fatalf("missing = %+v, want shop on mysql", missing) + } + + restore = stubDatabaseLister(func(string) ([]string, error) { return []string{"shop"}, nil }) + if got := MissingDatabases(dir); len(got) != 0 { + t.Errorf("missing = %+v, want none once the database exists", got) + } + restore() + + // An engine that cannot be reached holds no missing database as far as the + // fix is concerned, or pressing it would create a schema that already exists. + restore = stubDatabaseLister(func(string) ([]string, error) { return nil, errors.New("engine down") }) + defer restore() + if got := MissingDatabases(dir); len(got) != 0 { + t.Errorf("missing = %+v, want none while the engine is unreachable", got) + } +} diff --git a/internal/sitedoctor/sitedoctor.go b/internal/sitedoctor/sitedoctor.go index d19b43a2..efdd38d0 100644 --- a/internal/sitedoctor/sitedoctor.go +++ b/internal/sitedoctor/sitedoctor.go @@ -64,6 +64,10 @@ const ( // into the env file its framework declares, which is what `lerd env` does // and what resolves a service picked but not wired. FixEnvSync = "env_sync" + // FixCreateDatabase creates the databases a site points at that its engine + // does not hold, a host action like the service fixes since a site cannot + // create its own schema from inside its container. + FixCreateDatabase = "database_create" ) // DoctorFixCommands maps each universal fix key to the shell command run in the @@ -206,7 +210,7 @@ func Run(ctx context.Context, path string, fw *config.Framework) Response { } // Whether the site's database exists is answered through the framework // declaration, so it is asked of every format, not only dotenv. - if c, ok := checkServerDatabase(path, fw); ok { + if c, ok := checkServerDatabase(path); ok { resp.add(c) dbBroken = dbBroken || c.Status == StatusFail } diff --git a/internal/sitedoctor/sitedoctor_test.go b/internal/sitedoctor/sitedoctor_test.go index 6762c366..2ff9bdaa 100644 --- a/internal/sitedoctor/sitedoctor_test.go +++ b/internal/sitedoctor/sitedoctor_test.go @@ -823,15 +823,14 @@ func TestCheckServerDatabase(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmp) t.Setenv("XDG_DATA_HOME", tmp) - migrateFW := &config.Framework{Commands: []config.FrameworkCommand{{Name: "migrate"}}} env := "DB_CONNECTION=mysql\nDB_HOST=lerd-mysql\nDB_DATABASE=shop\n" - t.Run("missing schema fails with a migrate fix", func(t *testing.T) { + t.Run("missing schema fails with a create fix", func(t *testing.T) { dir := t.TempDir() writeEnv(t, dir, ".env", env) restore := stubDatabaseLister(func(string) ([]string, error) { return []string{"other", "lerd"}, nil }) defer restore() - c, ok := checkServerDatabase(dir, migrateFW) + c, ok := checkServerDatabase(dir) if !ok || c.Status != StatusFail { t.Fatalf("a missing schema should fail, got ok=%v %+v", ok, c) } @@ -842,7 +841,7 @@ func TestCheckServerDatabase(t *testing.T) { writeEnv(t, dir, ".env", env) restore := stubDatabaseLister(func(string) ([]string, error) { return []string{"shop"}, nil }) defer restore() - c, ok := checkServerDatabase(dir, migrateFW) + c, ok := checkServerDatabase(dir) if !ok || c.Status != StatusOK { t.Fatalf("an existing schema should pass, got ok=%v %+v", ok, c) } @@ -853,7 +852,7 @@ func TestCheckServerDatabase(t *testing.T) { writeEnv(t, dir, ".env", env) restore := stubDatabaseLister(func(string) ([]string, error) { return nil, errors.New("engine down") }) defer restore() - if _, ok := checkServerDatabase(dir, migrateFW); ok { + if _, ok := checkServerDatabase(dir); ok { t.Fatal("an engine that could not be queried should produce no check") } }) @@ -861,7 +860,7 @@ func TestCheckServerDatabase(t *testing.T) { t.Run("sqlite is left to the sqlite check", func(t *testing.T) { dir := t.TempDir() writeEnv(t, dir, ".env", "DB_CONNECTION=sqlite\n") - if _, ok := checkServerDatabase(dir, migrateFW); ok { + if _, ok := checkServerDatabase(dir); ok { t.Fatal("sqlite should be skipped") } }) diff --git a/internal/ui/site_doctor.go b/internal/ui/site_doctor.go index 30814991..bff4fb19 100644 --- a/internal/ui/site_doctor.go +++ b/internal/ui/site_doctor.go @@ -2,7 +2,9 @@ package ui import ( "encoding/json" + "fmt" "net/http" + "strings" "github.com/geodro/lerd/internal/config" "github.com/geodro/lerd/internal/serviceops" @@ -71,6 +73,13 @@ func handleDoctorFixRun(w http.ResponseWriter, r *http.Request, site *config.Sit handleDoctorServiceFix(w, r, site, key) return } + // Creating a schema runs in the engine's container, not the site's, so it is + // a host action as well: the site the finding belongs to could not create it + // from the inside even with a shell. + if key == sitedoctor.FixCreateDatabase { + handleDoctorDatabaseFix(w, r, site) + return + } shell, ok := sitedoctor.DoctorFixCommands[key] if !ok { writeJSON(w, map[string]any{"error": "unknown doctor fix: " + key}) @@ -161,6 +170,32 @@ func handleDoctorServiceFix(w http.ResponseWriter, r *http.Request, site *config send("done", string(body)) } +// handleDoctorDatabaseFix creates the databases the site points at that their +// engine does not hold. Like the service fix it resolves the set again rather +// than trusting the client, so it can only ever create what the check reported, +// and it stops at the first failure instead of reporting a half-done run as done. +func handleDoctorDatabaseFix(w http.ResponseWriter, r *http.Request, site *config.Site) { + path, ok := resolveDoctorPath(w, site, r.URL.Query().Get("branch")) + if !ok { + return + } + missing := sitedoctor.MissingDatabases(path) + if len(missing) == 0 { + streamHostAction(w, "nothing to create: every database this site points at exists, or its engine could not be reached", nil) + return + } + var created []string + var failed error + for _, t := range missing { + if _, err := serviceops.CreateDatabase(t.Service, t.Database); err != nil { + failed = fmt.Errorf("creating %s on %s: %w", t.Database, t.Service, err) + break + } + created = append(created, t.Database+" on "+t.Service) + } + streamHostAction(w, "created "+strings.Join(created, ", "), failed) +} + // installPhaseLine renders one install phase as a line of output, skipping the // pull's own progress chatter, which arrives many times a second and says // nothing a doctor fix log needs. diff --git a/internal/ui/site_doctor_test.go b/internal/ui/site_doctor_test.go index b63ab398..6dbd71d1 100644 --- a/internal/ui/site_doctor_test.go +++ b/internal/ui/site_doctor_test.go @@ -63,6 +63,24 @@ func TestDoctorFixRun_StreamsAllowlistedCommand(t *testing.T) { } } +// Creating a missing schema is a host action rather than a container command, +// so it has to be routed before the allowlist lookup that would reject it. +func TestDoctorFixRun_CreateDatabaseIsAHostAction(t *testing.T) { + registerSite(t, "acme", "acme.test") + req := httptest.NewRequest(http.MethodPost, "/api/sites/acme.test/doctor/fix/database_create/run", nil) + req.RemoteAddr = "127.0.0.1:1234" + rec := httptest.NewRecorder() + handleSiteAction(rec, req) + if strings.Contains(rec.Body.String(), "unknown doctor fix") { + t.Fatalf("the create-database fix should be handled, got %q", rec.Body.String()) + } + // The registered site points at no database, so nothing is created; it must + // still say so through a done frame rather than erroring out the endpoint. + if !strings.Contains(rec.Body.String(), "event: done") { + t.Errorf("expected a done event, got %q", rec.Body.String()) + } +} + func TestDoctorFixRun_NonLoopbackForbidden(t *testing.T) { registerSite(t, "acme", "acme.test") req := httptest.NewRequest(http.MethodPost, "/api/sites/acme.test/doctor/fix/composer_install/run", nil) diff --git a/internal/ui/web/src/tabs/sites/SiteDoctorModal.svelte b/internal/ui/web/src/tabs/sites/SiteDoctorModal.svelte index 6b75ede1..f2c60543 100644 --- a/internal/ui/web/src/tabs/sites/SiteDoctorModal.svelte +++ b/internal/ui/web/src/tabs/sites/SiteDoctorModal.svelte @@ -48,6 +48,10 @@ 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', migrations: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4', + server_database: + 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4', + sqlite_database: + 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4', env_present: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', composer_deps: @@ -133,7 +137,8 @@ env_sync: 'Run lerd env (writes the connection and repoints the app)', vhost_regenerate: 'Regenerate nginx vhost', services_install: 'Install the missing services', - services_start: 'Start the stopped services' + services_start: 'Start the stopped services', + database_create: 'Create the missing database' }; async function runFix(check: DoctorCheck) { diff --git a/internal/ui/web/src/tabs/sites/SiteDoctorModal.test.ts b/internal/ui/web/src/tabs/sites/SiteDoctorModal.test.ts index cdbf41c0..c7ee4174 100644 --- a/internal/ui/web/src/tabs/sites/SiteDoctorModal.test.ts +++ b/internal/ui/web/src/tabs/sites/SiteDoctorModal.test.ts @@ -77,6 +77,26 @@ describe('SiteDoctorModal', () => { expect(runSettled).toHaveBeenCalled(); }); + // A schema the engine does not hold is created through the doctor fix + // endpoint: no framework command can make it, and migrations fail without it. + it('creates a missing database through the fix endpoint', async () => { + loadDoctor.mockResolvedValue({ + checks: [ + { name: 'server_database', label: 'Database', status: 'fail', detail: 'Database "shop" on mysql does not exist.', fix: 'database_create' } + ], + failures: 1, + warnings: 0 + }); + loadCommands.mockResolvedValue([]); + + render(SiteDoctorModal, { props: { open: true, site: site(), branch: '', onclose: () => {} } }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Fix' })); + + expect(executeDoctorFix).toHaveBeenCalledWith('acme.test', 'database_create', 'Create the missing database', ''); + expect(launchCommand).not.toHaveBeenCalled(); + }); + it('omits the Fix button when no matching command is available', async () => { loadDoctor.mockResolvedValue({ checks: [{ name: 'storage_link', status: 'warn', detail: 'symlink missing', fix: 'storage:link' }],