feat: first-boot compose discovery and adopt-first sidebar (#1600)

* feat: add compose discovery for setup preflight and sidebar empty state

Expose read-only compose discovery via GET /api/stacks/discovery and setup

diagnostics. Replace the blank sidebar with path-aware discovery and move

adopt into a dedicated dialog with a three-tab Create Stack flow.

* test: assert post-setup handoff via sessionStorage read-back

The Setup preflight test spied on Storage.prototype.setItem to check the
post-setup adopt handoff. When the jsdom storage probe fails and the test
harness swaps in its in-memory storage stub (which does not extend Storage),
that stub's setItem never touches Storage.prototype, so the spy records zero
calls and the assertion fails even though the component wrote the value.

Read the value back with sessionStorage.getItem instead, matching how every
other storage test in the suite asserts. This is robust to both the native
jsdom storage and the in-memory fallback.

* fix(setup): surface compose discovery as a preflight check row

Drop the Setup discovery banner and non-working Review button. Show

counts as a pass row in EnvironmentChecks (Setup only) and keep

Enter Sencho as the handoff that opens adopt when candidates exist.

* test(setup): cover zero-count discovery row omission

* fix(stacks): widen adopt scan to any yaml and rename into place

Homelab layouts often use nginx.yml or plex.yml. Surface those for
adopt (except overrides), rename to compose.yaml on move so stacks
register, and reset the confirm UI when a move fails.
This commit is contained in:
Anso
2026-07-10 08:42:52 -04:00
committed by GitHub
parent 7848ce339a
commit ba2e7bded9
26 changed files with 1490 additions and 130 deletions
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { Setup } from '../Setup';
const apiFetchMock = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetchMock(...args) }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn() } }));
const ENV_REPORT = {
checks: [
{ id: 'compose_dir', label: 'Compose dir', status: 'pass', detail: '/opt/compose' },
{ id: 'docker_socket', label: 'Docker', status: 'pass', detail: 'ok' },
],
generatedAt: 1,
discovery: {
composeDir: '/opt/compose',
stackCount: 1,
adoptCandidateCount: 2,
adoptCandidatesTruncated: false,
},
};
function jsonRes(body: unknown, ok = true) {
return { ok, json: async () => body } as Response;
}
describe('Setup preflight', () => {
beforeEach(() => {
apiFetchMock.mockReset();
vi.stubGlobal('fetch', vi.fn());
sessionStorage.clear();
});
it('runs exactly one diagnostics fetch on the environment step', async () => {
vi.mocked(fetch).mockResolvedValue(jsonRes({ success: true }));
apiFetchMock.mockImplementation((path: string) => {
if (path === '/diagnostics/environment') {
return Promise.resolve(jsonRes(ENV_REPORT));
}
return Promise.resolve(jsonRes({}));
});
render(<Setup onComplete={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'admin' } });
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password123' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'password123' } });
fireEvent.click(screen.getByRole('button', { name: /initialize console/i }));
await waitFor(() => expect(screen.getByText('Preflight')).toBeTruthy());
await waitFor(() => {
const envCalls = apiFetchMock.mock.calls.filter((c) => c[0] === '/diagnostics/environment');
expect(envCalls).toHaveLength(1);
});
expect(apiFetchMock).not.toHaveBeenCalledWith('/stacks/discovery', expect.anything());
expect(screen.getByText('Compose discovery')).toBeTruthy();
expect(screen.getByText(/Found 1 stack and 2 files to adopt in \/opt\/compose/i)).toBeTruthy();
expect(screen.getByText(/Enter Sencho to review and adopt/i)).toBeTruthy();
expect(screen.queryByRole('button', { name: /review discovered files/i })).toBeNull();
expect(screen.getByText('ok')).toBeTruthy();
});
it('sets post-setup adopt handoff when discovery has candidates', async () => {
vi.mocked(fetch).mockResolvedValue(jsonRes({ success: true }));
apiFetchMock.mockResolvedValue(jsonRes(ENV_REPORT));
const onComplete = vi.fn();
render(<Setup onComplete={onComplete} />);
fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'admin' } });
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password123' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'password123' } });
fireEvent.click(screen.getByRole('button', { name: /initialize console/i }));
await waitFor(() => expect(screen.getByText(/2 files to adopt/i)).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: /enter sencho/i }));
expect(onComplete).toHaveBeenCalledTimes(1);
expect(sessionStorage.getItem('sencho:post-setup')).toBe(JSON.stringify({ openAdopt: true }));
});
});