178 lines
12 KiB
JavaScript
178 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import preflightModule from '../src/main/preflight-service.cjs';
|
|
|
|
const { PreflightService, summarize, check } = preflightModule;
|
|
|
|
test('only required failed checks block readiness', () => {
|
|
const summary = summarize([
|
|
check('required-pass', 'Required pass', 'pass', 'ok', { required: true }),
|
|
check('optional-warning', 'Optional warning', 'warning', 'notice'),
|
|
check('optional-fail', 'Optional fail', 'fail', 'not blocking'),
|
|
check('required-fail', 'Required fail', 'fail', 'blocked', { required: true })
|
|
]);
|
|
assert.equal(summary.ready, false);
|
|
assert.deepEqual(summary.blocking, ['required-fail']);
|
|
assert.equal(summary.counts.warning, 1);
|
|
});
|
|
|
|
test('system preflight can pass before credentials are entered', async (t) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-system-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const service = new PreflightService({
|
|
store: { data: { gitea: { baseUrl: '' } }, getToken: () => '' },
|
|
git: { isAvailable: async () => ({ available: true, version: 'git version test' }) },
|
|
gitea: {}, deployments: {},
|
|
diagnostics: { logDirectory: path.join(root, 'diagnostics'), info: async () => {} },
|
|
userDataPath: path.join(root, 'data'),
|
|
secureStorageAvailable: () => true
|
|
});
|
|
service.gitIdentity = async () => ({ name: 'Jens', email: 'jens@example.test' });
|
|
const result = await service.runSystem({ roots: [root] });
|
|
assert.equal(result.summary.ready, true);
|
|
assert.equal(result.checks.find((item) => item.id === 'gitea.connection').status, 'warning');
|
|
assert.equal(result.checks.find((item) => item.id === 'storage.credentials').status, 'pass');
|
|
});
|
|
|
|
test('deployment preflight verifies exact Git, workflow, Actions and server prerequisites', async (t) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-deploy-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
await mkdir(path.join(root, '.gitea', 'workflows'), { recursive: true });
|
|
await writeFile(path.join(root, '.gitea', 'workflows', 'deploy.yml'), 'name: deploy\n');
|
|
await writeFile(path.join(root, '.gitea', 'workflows', 'rollback.yml'), 'name: rollback\n');
|
|
const sha = 'a'.repeat(40);
|
|
const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: 'https://app.example.test/status', healthcheckUrl: 'https://app.example.test/health' };
|
|
const service = new PreflightService({
|
|
store: { getDeploymentProfile: () => profile },
|
|
git: {
|
|
status: async () => ({ root, head: sha, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }),
|
|
verifyCommitOnRemoteBranch: async () => true
|
|
},
|
|
gitea: { repositoryFileExists: async () => true, listWorkflowRuns: async () => ({ runs: [] }) },
|
|
deployments: {
|
|
readStatusEndpoint: async () => ({ configured: true, reachable: true, ok: true, liveSha: sha, status: 200 }),
|
|
checkHealth: async () => ({ configured: true, healthy: true, status: 200, latencyMs: 12 })
|
|
},
|
|
diagnostics: { info: async () => {} }, userDataPath: root
|
|
});
|
|
const result = await service.runDeployment({ repository: { fullName: 'jens/app', localPath: root }, profileId: profile.id });
|
|
assert.equal(result.summary.ready, true);
|
|
assert.equal(result.checks.filter((item) => item.status === 'fail').length, 0);
|
|
assert.equal(result.head, sha);
|
|
});
|
|
|
|
test('system preflight reports unavailable Git, storage, roots and rejected Gitea credentials', async (t) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-failures-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const ordinaryFile = path.join(root, 'not-a-directory');
|
|
await writeFile(ordinaryFile, 'file');
|
|
const events = [];
|
|
const service = new PreflightService({
|
|
store: { data: { gitea: { baseUrl: 'https://stored.test' } }, getToken: () => 'stored-token' },
|
|
git: { isAvailable: async () => ({ available: false, error: 'git missing' }) },
|
|
gitea: { validateConnection: async () => { throw new Error('token rejected'); } },
|
|
deployments: {}, diagnostics: { logDirectory: path.join(root, 'logs'), info: async (...args) => events.push(args) },
|
|
userDataPath: path.join(root, 'data'), secureStorageAvailable: () => false
|
|
});
|
|
service.writableDirectory = async (directory) => {
|
|
if (directory.endsWith('data')) throw new Error('read only');
|
|
return true;
|
|
};
|
|
const result = await service.runSystem({ roots: [ordinaryFile, path.join(root, 'missing'), ordinaryFile, ''] });
|
|
assert.equal(result.checks.find((item) => item.id === 'git.available').status, 'fail');
|
|
assert.equal(result.checks.find((item) => item.id === 'storage.userdata').status, 'fail');
|
|
assert.equal(result.checks.find((item) => item.id === 'storage.diagnostics').status, 'pass');
|
|
assert.equal(result.checks.find((item) => item.id === 'storage.credentials').status, 'warning');
|
|
assert.equal(result.checks.find((item) => item.id === 'workspace.root.0').status, 'fail');
|
|
assert.equal(result.checks.find((item) => item.id === 'workspace.root.1').status, 'fail');
|
|
assert.equal(result.checks.find((item) => item.id === 'gitea.connection').status, 'fail');
|
|
assert.equal(result.summary.ready, false);
|
|
assert.equal(events[0][0], 'preflight.system.completed');
|
|
});
|
|
|
|
test('system preflight warns on incomplete Git identity and accepts unknown Gitea version', async (t) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-identity-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const service = new PreflightService({
|
|
store: { data: { gitea: { baseUrl: '' } }, getToken: () => '' },
|
|
git: { isAvailable: async () => ({ available: true, version: 'git' }) },
|
|
gitea: { validateConnection: async () => ({ version: null, user: null, repositoryCount: 0 }) }, deployments: {},
|
|
diagnostics: { logDirectory: path.join(root, 'logs'), info: async () => {} }, userDataPath: path.join(root, 'data')
|
|
});
|
|
service.gitIdentity = async () => ({ name: '', email: '' });
|
|
const result = await service.runSystem({ baseUrl: 'https://gitea.test', token: 'token', roots: [] });
|
|
assert.equal(result.checks.find((item) => item.id === 'git.identity').status, 'warning');
|
|
assert.match(result.checks.find((item) => item.id === 'gitea.connection').detail, /unknown version.*user/i);
|
|
assert.equal(result.checks.find((item) => item.id === 'gitea.repositories').status, 'pass');
|
|
assert.equal(result.checks.find((item) => item.id === 'workspace.roots').status, 'warning');
|
|
|
|
service.gitIdentity = async () => { throw new Error('identity lookup failed'); };
|
|
const second = await service.runSystem();
|
|
assert.match(second.checks.find((item) => item.id === 'git.identity').detail, /lookup failed/i);
|
|
});
|
|
|
|
test('deployment preflight fails fast for invalid identity, profile and missing local link', async () => {
|
|
const diagnostics = [];
|
|
const service = new PreflightService({
|
|
store: { getDeploymentProfile: (_name, id) => id === 'known' ? { id: 'known', name: 'Production' } : null },
|
|
git: {}, gitea: {}, deployments: {}, diagnostics: { info: async (...args) => diagnostics.push(args) }, userDataPath: ''
|
|
});
|
|
await assert.rejects(service.runDeployment({ repository: null, profileId: 'known' }), /identity is required/i);
|
|
await assert.rejects(service.runDeployment({ repository: { fullName: 'owner/app' }, profileId: 'missing' }), /profile not found/i);
|
|
const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: '' }, profileId: 'known' });
|
|
assert.deepEqual(result.summary.blocking, ['repository.linked']);
|
|
assert.equal(diagnostics[0][0], 'preflight.deployment.completed');
|
|
});
|
|
|
|
test('deployment preflight preserves actionable evidence across Git, workflow and endpoint failures', async (t) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-degraded-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml' };
|
|
const service = new PreflightService({
|
|
store: { getDeploymentProfile: () => profile },
|
|
git: {
|
|
status: async () => ({ root, head: 'b'.repeat(40), clean: false, counts: { changed: 4 }, branch: { head: '', upstream: '', ahead: 2, behind: 3 } }),
|
|
verifyCommitOnRemoteBranch: async () => { throw new Error('commit not published'); }
|
|
},
|
|
gitea: { repositoryFileExists: async () => false, listWorkflowRuns: async () => { throw new Error('Actions disabled'); } },
|
|
deployments: {}, diagnostics: { info: async () => {} }, userDataPath: root
|
|
});
|
|
const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
|
|
for (const id of ['git.branch', 'git.clean', 'git.upstream', 'git.sync', 'git.remote-sha', 'workflow.deploy.local', 'workflow.deploy.remote', 'gitea.actions', 'server.status.configured']) {
|
|
assert.equal(result.checks.find((item) => item.id === id).status, 'fail', id);
|
|
}
|
|
assert.equal(result.checks.find((item) => item.id === 'workflow.rollback.local').status, 'warning');
|
|
assert.equal(result.checks.find((item) => item.id === 'server.health').status, 'warning');
|
|
assert.equal(result.head, 'b'.repeat(40));
|
|
});
|
|
|
|
test('deployment preflight distinguishes unreachable and mismatched status evidence', async (t) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-status-'));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
await mkdir(path.join(root, '.gitea', 'workflows'), { recursive: true });
|
|
await writeFile(path.join(root, '.gitea', 'workflows', 'deploy.yml'), 'name: deploy\n');
|
|
const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', statusUrl: 'https://app/status', healthcheckUrl: 'https://app/health' };
|
|
let status = { reachable: false, ok: false, status: 503, error: '' };
|
|
const service = new PreflightService({
|
|
store: { getDeploymentProfile: () => profile },
|
|
git: { status: async () => { throw new Error('checkout corrupt'); } },
|
|
gitea: { repositoryFileExists: async () => { throw new Error('Gitea offline'); } },
|
|
deployments: { readStatusEndpoint: async () => status, checkHealth: async () => ({ healthy: false, status: 500, error: '' }) },
|
|
diagnostics: { info: async () => {} }, userDataPath: root
|
|
});
|
|
const unreachable = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
|
|
assert.match(unreachable.checks.find((item) => item.id === 'server.status.reachable').detail, /HTTP 503/i);
|
|
assert.match(unreachable.checks.find((item) => item.id === 'server.health').detail, /HTTP 500/i);
|
|
assert.match(unreachable.checks.find((item) => item.id === 'git.repository').detail, /checkout corrupt/i);
|
|
|
|
status = { reachable: true, ok: true, repository: 'other/app', environment: 'staging', liveSha: null };
|
|
const mismatch = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
|
|
const identity = mismatch.checks.find((item) => item.id === 'server.status.identity');
|
|
assert.equal(identity.status, 'fail');
|
|
assert.equal(identity.required, true);
|
|
assert.match(mismatch.checks.find((item) => item.id === 'server.status.reachable').detail, /no live SHA/i);
|
|
});
|