200 lines
9.1 KiB
JavaScript
200 lines
9.1 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtemp, mkdir, symlink } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import repositoryModule from '../src/main/repository-service.cjs';
|
|
|
|
const { RepositoryService } = repositoryModule;
|
|
|
|
function status(head = 'a'.repeat(40)) {
|
|
return {
|
|
head,
|
|
shortHead: head.slice(0, 7),
|
|
clean: true,
|
|
counts: { changed: 0, conflicts: 0 },
|
|
branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 }
|
|
};
|
|
}
|
|
|
|
const remote = {
|
|
id: 1,
|
|
name: 'Portfolio',
|
|
full_name: 'Jens/Portfolio',
|
|
owner: { login: 'Jens' },
|
|
private: true,
|
|
default_branch: 'main',
|
|
html_url: 'https://gitea.example/Jens/Portfolio',
|
|
clone_url: 'https://gitea.example/Jens/Portfolio.git',
|
|
ssh_url: 'git@gitea.example:Jens/Portfolio.git'
|
|
};
|
|
|
|
function service() {
|
|
return new RepositoryService({ data: { preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] } }, {}, {});
|
|
}
|
|
|
|
test('a synchronized commit is deployable when the server is unknown or older', () => {
|
|
const current = status();
|
|
const unknown = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
|
|
{ id: 'prod', branch: 'main', state: { liveSha: null, healthy: null } }
|
|
]);
|
|
assert.equal(unknown.readyToDeploy, true);
|
|
|
|
const older = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
|
|
{ id: 'prod', branch: 'main', state: { liveSha: 'b'.repeat(40), healthy: true } }
|
|
]);
|
|
assert.equal(older.readyToDeploy, true);
|
|
});
|
|
|
|
test('a healthy commit already live on the server is not offered for deployment again', () => {
|
|
const current = status();
|
|
const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
|
|
{ id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: true } }
|
|
]);
|
|
assert.equal(repository.readyToDeploy, false);
|
|
});
|
|
|
|
test('an unhealthy live commit remains eligible for a controlled redeploy', () => {
|
|
const current = status();
|
|
const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
|
|
{ id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: false } }
|
|
]);
|
|
assert.equal(repository.readyToDeploy, true);
|
|
});
|
|
|
|
test('repository discovery is bounded, skips generated trees and ignores inaccessible roots', async (context) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-repositories-'));
|
|
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
|
|
await mkdir(path.join(root, 'group', 'app', '.git'), { recursive: true });
|
|
await mkdir(path.join(root, 'node_modules', 'ignored', '.git'), { recursive: true });
|
|
await mkdir(path.join(root, 'too', 'deep', 'repository', '.git'), { recursive: true });
|
|
try { await symlink(path.join(root, 'group'), path.join(root, 'linked'), 'junction'); } catch {}
|
|
|
|
const instance = service();
|
|
const found = await instance.discoverInRoot(root, 2);
|
|
assert.deepEqual(found, [await import('node:fs/promises').then(({ realpath }) => realpath(path.join(root, 'group', 'app')))]);
|
|
assert.deepEqual(await instance.discoverInRoot(path.join(root, 'missing')), []);
|
|
const all = await instance.discoverAll([root, root, '', null]);
|
|
assert.equal(all.length, 2);
|
|
assert.equal(new Set(all).size, 2);
|
|
assert.ok(all.includes(found[0]));
|
|
});
|
|
|
|
test('local descriptors preserve Git failures and watch paths are defensive copies', async () => {
|
|
const instance = new RepositoryService({ data: {} }, {
|
|
status: async (localPath) => {
|
|
if (localPath.endsWith('bad')) throw new Error('not a repository');
|
|
return { ...status(), root: `${localPath}/canonical`, remoteUrl: remote.clone_url };
|
|
}
|
|
}, {});
|
|
const descriptors = await instance.getLocalDescriptors(['good', 'bad']);
|
|
assert.equal(descriptors[0].localPath, 'good/canonical');
|
|
assert.equal(descriptors[1].error, 'not a repository');
|
|
instance.lastKnownLocalPaths = ['one'];
|
|
const watched = instance.getWatchPaths();
|
|
watched.push('two');
|
|
assert.deepEqual(instance.getWatchPaths(), ['one']);
|
|
});
|
|
|
|
test('refresh links explicit and remote-matched repositories and retains unmatched locals', async () => {
|
|
const diagnostics = [];
|
|
const store = {
|
|
data: {
|
|
gitea: { baseUrl: 'https://gitea.example' },
|
|
workspaceRoots: ['root'],
|
|
repositoryMappings: { 'jens/portfolio': 'C:/explicit' },
|
|
preferences: { preferredCloneProtocol: 'https' },
|
|
favorites: ['jens/portfolio']
|
|
},
|
|
getToken: () => 'token',
|
|
getDeploymentProfiles: (name) => name === remote.full_name ? [{ id: 'prod', branch: 'main' }] : [],
|
|
getDeploymentState: () => ({ liveSha: null, healthy: null })
|
|
};
|
|
const secondRemote = { ...remote, id: 2, name: 'Other', full_name: 'Jens/Other', clone_url: 'https://gitea.example/Jens/Other.git' };
|
|
const instance = new RepositoryService(store, {
|
|
status: async (localPath) => ({
|
|
...status(localPath.includes('unmatched') ? 'b'.repeat(40) : 'a'.repeat(40)),
|
|
root: localPath,
|
|
remoteUrl: localPath.includes('matched') ? secondRemote.clone_url : remote.clone_url
|
|
})
|
|
}, { listRepositories: async () => [remote, secondRemote] }, { debug: async (...args) => diagnostics.push(args) });
|
|
instance.discoverAll = async () => ['C:/matched', 'C:/unmatched'];
|
|
|
|
const repositories = await instance.refresh();
|
|
const explicit = repositories.find((item) => item.fullName === remote.full_name);
|
|
const matched = repositories.find((item) => item.fullName === secondRemote.full_name);
|
|
const unmatched = repositories.find((item) => item.linkState === 'unmatched-local');
|
|
assert.equal(explicit.localPath, 'C:/explicit');
|
|
assert.equal(explicit.favorite, true);
|
|
assert.equal(explicit.preferredCloneUrl, remote.clone_url);
|
|
assert.equal(matched.localPath, 'C:/matched');
|
|
assert.equal(unmatched.localPath, 'C:/unmatched');
|
|
assert.deepEqual(instance.getWatchPaths().sort(), ['C:/explicit', 'C:/matched', 'C:/unmatched'].sort());
|
|
assert.equal(diagnostics[0][0], 'repositories.refresh.completed');
|
|
});
|
|
|
|
test('refresh remains local-only without configured Gitea credentials', async () => {
|
|
const store = {
|
|
data: { gitea: { baseUrl: '' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] },
|
|
getToken: () => '', getDeploymentProfiles: () => [], getDeploymentState: () => null
|
|
};
|
|
const instance = new RepositoryService(store, {}, { listRepositories: async () => { throw new Error('must not call'); } });
|
|
instance.discoverAll = async () => [];
|
|
assert.deepEqual(await instance.refresh(), []);
|
|
});
|
|
|
|
test('refresh uses last-known Gitea repositories after a transient remote failure', async () => {
|
|
const warnings = [];
|
|
let remoteAvailable = true;
|
|
const store = {
|
|
data: {
|
|
gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {},
|
|
preferences: { preferredCloneProtocol: 'ssh' }, favorites: []
|
|
},
|
|
getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null
|
|
};
|
|
const instance = new RepositoryService(store, {}, {
|
|
listRepositories: async () => {
|
|
if (!remoteAvailable) throw new Error('Gitea timed out');
|
|
return [remote];
|
|
}
|
|
}, { debug: async () => {}, warning: async (...args) => warnings.push(args) });
|
|
instance.discoverAll = async () => [];
|
|
|
|
const fresh = await instance.refresh();
|
|
remoteAvailable = false;
|
|
const degraded = await instance.refresh();
|
|
|
|
assert.equal(fresh[0].remoteStale, false);
|
|
assert.equal(degraded[0].fullName, remote.full_name);
|
|
assert.equal(degraded[0].remoteStale, true);
|
|
assert.equal(degraded[0].remoteRefreshError, 'Gitea timed out');
|
|
assert.ok(degraded[0].remoteLastRefreshedAt);
|
|
assert.equal(warnings[0][0], 'repositories.remote-refresh.degraded');
|
|
});
|
|
|
|
test('initial Gitea failure remains visible when no safe cache exists', async () => {
|
|
const store = {
|
|
data: { gitea: { baseUrl: 'https://gitea.example' } },
|
|
getToken: () => 'token'
|
|
};
|
|
const instance = new RepositoryService(store, {}, {
|
|
listRepositories: async () => { throw new Error('Gitea unavailable'); }
|
|
});
|
|
await assert.rejects(() => instance.refresh(), /Gitea unavailable/);
|
|
});
|
|
|
|
test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => {
|
|
const instance = service();
|
|
const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []);
|
|
assert.equal(conflicted.attentionReason, 'Merge conflict');
|
|
assert.equal(conflicted.readyToDeploy, false);
|
|
const behind = instance.decorate(remote, { localPath: 'repo', status: { ...status(), branch: { ...status().branch, behind: 1 } } }, []);
|
|
assert.equal(behind.attentionReason, '1 commit behind remote');
|
|
const broken = instance.decorate(remote, { localPath: 'repo', status: null, error: 'broken checkout' }, []);
|
|
assert.equal(broken.attentionReason, 'broken checkout');
|
|
const remoteOnly = instance.decorate({ ...remote, ssh_url: '', clone_url: '' }, null, []);
|
|
assert.equal(remoteOnly.linkState, 'remote-only');
|
|
assert.equal(remoteOnly.preferredCloneUrl, '');
|
|
});
|