mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
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:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Tests for ComposeDiscoveryService.probeComposeDiscovery and
|
||||
* FileSystemService.countImportCandidates.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { probeComposeDiscovery } from '../services/ComposeDiscoveryService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
|
||||
const { tmpRoot } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodeOs = require('os');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodePath = require('path');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodeFs = require('fs');
|
||||
const tmpRoot: string = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), 'sencho-discovery-'));
|
||||
return { tmpRoot };
|
||||
});
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getComposeDir: () => tmpRoot,
|
||||
getDefaultNodeId: () => 1,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const COMPOSE = 'services:\n app:\n image: nginx:1.27\n';
|
||||
|
||||
describe('probeComposeDiscovery', () => {
|
||||
beforeAll(() => {
|
||||
fs.mkdirSync(path.join(tmpRoot, 'existing'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpRoot, 'existing', 'compose.yaml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(tmpRoot, 'docker-compose.yml'), COMPOSE);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns readable discovery with stack and adopt counts', async () => {
|
||||
const probe = await probeComposeDiscovery(1);
|
||||
expect(probe.readable).toBe(true);
|
||||
if (!probe.readable) return;
|
||||
expect(probe.composeDir).toBe(tmpRoot);
|
||||
expect(probe.discovery.stackCount).toBe(1);
|
||||
expect(probe.discovery.adoptCandidateCount).toBe(1);
|
||||
expect(probe.discovery.adoptCandidatesTruncated).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('FileSystemService.countImportCandidates', () => {
|
||||
it('matches findImportCandidates length when under the cap', async () => {
|
||||
const listed = await FileSystemService.getInstance().findImportCandidates();
|
||||
const counted = await FileSystemService.getInstance().countImportCandidates(100);
|
||||
expect(counted.count).toBe(listed.length);
|
||||
expect(counted.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('sets truncated only when more than maxCandidates exist', async () => {
|
||||
const wrap = path.join(tmpRoot, 'trunc-wrap');
|
||||
fs.mkdirSync(wrap, { recursive: true });
|
||||
try {
|
||||
for (let i = 0; i < 101; i++) {
|
||||
const dir = path.join(wrap, `c${i}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE);
|
||||
}
|
||||
const at100 = await FileSystemService.getInstance().countImportCandidates(100);
|
||||
expect(at100.count).toBe(100);
|
||||
expect(at100.truncated).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(wrap, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -87,4 +87,17 @@ describe('GET /api/diagnostics/environment', () => {
|
||||
expect(typeof c.detail).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('still returns 200 when discovery probe succeeds on a readable compose dir', async () => {
|
||||
const res = await request(app).get('/api/diagnostics/environment').set('Authorization', adminAuthHeader);
|
||||
expect(res.status).toBe(200);
|
||||
if (res.body.discovery) {
|
||||
expect(res.body.discovery).toMatchObject({
|
||||
composeDir: expect.any(String),
|
||||
stackCount: expect.any(Number),
|
||||
adoptCandidateCount: expect.any(Number),
|
||||
adoptCandidatesTruncated: expect.any(Boolean),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,43 @@ describe('FileSystemService.importCandidateIntoStack', () => {
|
||||
expect(await FileSystemService.getInstance().getStacks()).toContain('webapp');
|
||||
});
|
||||
|
||||
it('renames a non-canonical loose-root yaml to compose.yaml so the stack registers', async () => {
|
||||
fs.writeFileSync(path.join(tmpRoot, 'nginx.yml'), COMPOSE);
|
||||
try {
|
||||
await FileSystemService.getInstance().importCandidateIntoStack(
|
||||
{ location: 'nginx.yml', composeFile: 'nginx.yml', status: 'loose-root' },
|
||||
'nginx',
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'nginx', 'compose.yaml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'nginx', 'nginx.yml'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'nginx.yml'))).toBe(false);
|
||||
expect(await FileSystemService.getInstance().getStacks()).toContain('nginx');
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'nginx'), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(tmpRoot, 'nginx.yml'), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('renames a nested non-canonical yaml to compose.yaml after promoting the directory', async () => {
|
||||
fs.mkdirSync(path.join(tmpRoot, 'apps', 'plex'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpRoot, 'apps', 'plex', 'plex.yml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(tmpRoot, 'apps', 'plex', '.env'), 'TOKEN=1\n');
|
||||
try {
|
||||
await FileSystemService.getInstance().importCandidateIntoStack(
|
||||
{ location: 'apps/plex/plex.yml', composeFile: 'plex.yml', status: 'nested' },
|
||||
'plex',
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'plex', 'compose.yaml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'plex', 'plex.yml'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'plex', '.env'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'apps', 'plex'))).toBe(false);
|
||||
expect(await FileSystemService.getInstance().getStacks()).toContain('plex');
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'plex'), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(tmpRoot, 'apps', 'plex'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves sibling root files (a root .env) untouched when moving a loose-root file', async () => {
|
||||
fs.writeFileSync(path.join(tmpRoot, 'compose.yaml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(tmpRoot, '.env'), 'TOKEN=keep-me\n');
|
||||
@@ -79,6 +116,49 @@ describe('FileSystemService.importCandidateIntoStack', () => {
|
||||
expect(await FileSystemService.getInstance().getStacks()).toContain('vault');
|
||||
});
|
||||
|
||||
it('refuses nested non-canonical adopt when compose.yaml already exists in the source dir', async () => {
|
||||
fs.mkdirSync(path.join(tmpRoot, 'apps', 'clash'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpRoot, 'apps', 'clash', 'plex.yml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(tmpRoot, 'apps', 'clash', 'compose.yaml'), 'services:\n other: {}\n');
|
||||
await expect(
|
||||
FileSystemService.getInstance().importCandidateIntoStack(
|
||||
{ location: 'apps/clash/plex.yml', composeFile: 'plex.yml', status: 'nested' },
|
||||
'clash',
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'DEST_EXISTS' });
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'apps', 'clash', 'plex.yml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'clash'))).toBe(false);
|
||||
fs.rmSync(path.join(tmpRoot, 'apps', 'clash'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rolls nested directory back when post-promote rename to compose.yaml fails', async () => {
|
||||
fs.mkdirSync(path.join(tmpRoot, 'apps', 'plexrb'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpRoot, 'apps', 'plexrb', 'plex.yml'), COMPOSE);
|
||||
const realRename = fsPromises.rename.bind(fsPromises);
|
||||
let calls = 0;
|
||||
const spy = vi.spyOn(fsPromises, 'rename').mockImplementation(async (...args: Parameters<typeof fsPromises.rename>) => {
|
||||
calls += 1;
|
||||
if (calls === 2) {
|
||||
throw Object.assign(new Error('rename failed'), { code: 'EIO' });
|
||||
}
|
||||
return realRename(...args);
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
FileSystemService.getInstance().importCandidateIntoStack(
|
||||
{ location: 'apps/plexrb/plex.yml', composeFile: 'plex.yml', status: 'nested' },
|
||||
'plex-rb',
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'EIO' });
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'apps', 'plexrb', 'plex.yml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'plex-rb'))).toBe(false);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
fs.rmSync(path.join(tmpRoot, 'apps', 'plexrb'), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(tmpRoot, 'plex-rb'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('honors a destination name different from the nested folder name', async () => {
|
||||
fs.mkdirSync(path.join(tmpRoot, 'group', 'api'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpRoot, 'group', 'api', 'compose.yaml'), COMPOSE);
|
||||
|
||||
@@ -151,6 +151,104 @@ describe('FileSystemService.findImportCandidates', () => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it('surfaces non-canonical loose-root yaml (e.g. nginx.yml)', async () => {
|
||||
fs.writeFileSync(path.join(tmpRoot, 'nginx.yml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
const loose = candidates.find((c) => c.location === 'nginx.yml');
|
||||
expect(loose).toMatchObject({
|
||||
name: '',
|
||||
composeFile: 'nginx.yml',
|
||||
status: 'loose-root',
|
||||
});
|
||||
expect(loose?.content).toContain('services:');
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'nginx.yml'), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces nested non-canonical yaml (e.g. apps/plex/plex.yml)', async () => {
|
||||
const dir = path.join(tmpRoot, 'apps', 'plex');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'plex.yml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
const nested = candidates.find((c) => c.location === 'apps/plex/plex.yml');
|
||||
expect(nested).toMatchObject({
|
||||
name: 'plex',
|
||||
composeFile: 'plex.yml',
|
||||
status: 'nested',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'apps', 'plex'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not surface compose override filenames as adopt candidates', async () => {
|
||||
fs.writeFileSync(path.join(tmpRoot, 'compose.override.yml'), COMPOSE);
|
||||
const wrap = path.join(tmpRoot, 'ovr-wrap', 'ovr');
|
||||
fs.mkdirSync(wrap, { recursive: true });
|
||||
fs.writeFileSync(path.join(wrap, 'docker-compose.override.yaml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
expect(candidates.some((c) => c.composeFile.includes('override'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'compose.override.yml'), { force: true });
|
||||
fs.rmSync(path.join(tmpRoot, 'ovr-wrap'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not surface non-canonical yaml inside a top-level folder (not a stack, not adoptable)', async () => {
|
||||
// plex/plex.yml at the compose root is neither a stack (no canonical compose)
|
||||
// nor an adopt candidate (promoting with destName === folder would conflict).
|
||||
const dir = path.join(tmpRoot, 'plex-top');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'plex.yml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
expect(candidates.some((c) => c.location.includes('plex-top'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('picks the localeCompare-first non-canonical yaml when several exist', async () => {
|
||||
const dir = path.join(tmpRoot, 'sort-wrap', 'svc');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'z.yml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(dir, 'a.yml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
const nested = candidates.find((c) => c.name === 'svc');
|
||||
expect(nested).toMatchObject({
|
||||
composeFile: 'a.yml',
|
||||
location: 'sort-wrap/svc/a.yml',
|
||||
status: 'nested',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'sort-wrap'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers a canonical compose filename over a sibling non-canonical yaml', async () => {
|
||||
const dir = path.join(tmpRoot, 'pref-wrap', 'svc');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE);
|
||||
fs.writeFileSync(path.join(dir, 'nginx.yml'), COMPOSE);
|
||||
try {
|
||||
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
||||
const nested = candidates.find((c) => c.name === 'svc');
|
||||
expect(nested).toMatchObject({
|
||||
composeFile: 'compose.yaml',
|
||||
location: 'pref-wrap/svc/compose.yaml',
|
||||
status: 'nested',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(path.join(tmpRoot, 'pref-wrap'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('truncates at maxCandidates', async () => {
|
||||
// Base fixtures yield 2 candidates (loose-root + nested); add a third loose
|
||||
// file so a cap of 2 actually truncates rather than coincidentally matching.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Route tests for GET /api/stacks/discovery: auth, contract, and route shadowing.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authCookie: string;
|
||||
let viewerCookie: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const bcrypt = (await import('bcrypt')).default;
|
||||
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'disc-viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'disc-viewer', password: 'viewerpass' });
|
||||
const cookies = loginRes.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
fs.mkdirSync(path.join(composeDir, 'stack-a'), { recursive: true });
|
||||
fs.writeFileSync(path.join(composeDir, 'stack-a', 'compose.yaml'), 'services:\n web:\n image: nginx\n');
|
||||
fs.writeFileSync(path.join(composeDir, 'docker-compose.yml'), 'services:\n loose:\n image: nginx\n');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('GET /api/stacks/discovery', () => {
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/stacks/discovery');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('allows stack:read for a viewer', async () => {
|
||||
const res = await request(app).get('/api/stacks/discovery').set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.composeDir).toBe('string');
|
||||
expect(res.body.readable).toBe(true);
|
||||
expect(res.body.discovery).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns the stable discovery contract for a readable compose dir', async () => {
|
||||
const res = await request(app).get('/api/stacks/discovery').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
readable: true,
|
||||
discovery: {
|
||||
composeDir: expect.any(String),
|
||||
stackCount: expect.any(Number),
|
||||
adoptCandidateCount: expect.any(Number),
|
||||
adoptCandidatesTruncated: expect.any(Boolean),
|
||||
},
|
||||
});
|
||||
expect(res.body.composeDir).toBe(res.body.discovery.composeDir);
|
||||
});
|
||||
|
||||
it('does not shadow GET /:stackName (discovery is not treated as a stack name)', async () => {
|
||||
const res = await request(app).get('/api/stacks/discovery').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/json/);
|
||||
expect(res.body).toHaveProperty('readable');
|
||||
expect(res.text).not.toMatch(/^services:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/import/move viewer denial', () => {
|
||||
it('rejects move without stack:create', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/import/move')
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ location: 'docker-compose.yml', name: 'moved' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user