import test from 'node:test'; import assert from 'node:assert/strict'; import monitorModule from '../src/main/repository-monitor.cjs'; const { RepositoryMonitor } = monitorModule; test('repository monitor establishes a baseline and emits only on later changes', async () => { let revision = 1; const changes = []; const git = { status: async (localPath) => ({ localPath, revision }), statusFingerprint: (status) => String(status.revision) }; const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } }; const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) }); monitor.setPaths(['/repo']); await monitor.tick(); assert.equal(changes.length, 0); revision = 2; await monitor.tick(); assert.equal(changes.length, 1); assert.equal(changes[0].reason, 'working-tree-changed'); monitor.pause('/repo'); revision = 3; await monitor.tick(); assert.equal(changes.length, 1); monitor.resume('/repo'); await monitor.tick(); assert.equal(changes.length, 2); }); test('repository monitor checks multiple repositories concurrently with a bounded worker pool', async () => { let active = 0; let peak = 0; const git = { status: async (localPath) => { active += 1; peak = Math.max(peak, active); await new Promise((resolve) => setTimeout(resolve, 15)); active -= 1; return { localPath, revision: 1 }; }, statusFingerprint: (status) => String(status.revision) }; const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } }; const monitor = new RepositoryMonitor({ store, git }); monitor.setPaths(Array.from({ length: 10 }, (_, index) => `/repo-${index}`)); await monitor.tick(); assert.equal(peak, 4); assert.equal(active, 0); assert.equal(monitor.fingerprints.size, 10); }); test('a watched repository is read on filesystem activity instead of on every interval', async (context) => { const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); const os = await import('node:os'); const path = await import('node:path'); const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-watch-')); context.after(() => rm(root, { recursive: true, force: true })); await mkdir(path.join(root, '.git'), { recursive: true }); let revision = 1; const reads = []; const changes = []; const git = { status: async (localPath) => { reads.push(localPath); return { localPath, revision }; }, statusFingerprint: (status) => String(status.revision) }; const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } }; const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) }); context.after(() => monitor.stop()); monitor.restart(); monitor.setPaths([root]); if (!monitor.watchers.has(root)) { context.skip('this platform does not support recursive directory watching'); return; } await monitor.tick(); assert.equal(reads.length, 1, 'the baseline is established once'); // Without filesystem activity the interval must not spawn another read. await monitor.tick(); assert.equal(reads.length, 1); revision = 2; await writeFile(path.join(root, 'feature.txt'), 'changed\n'); // The watcher debounce and the per-repository cooldown both apply here. const deadline = Date.now() + 5_000; while (changes.length === 0 && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); } assert.ok(reads.length > 1, 'filesystem activity triggers a read'); assert.equal(changes.length, 1); assert.equal(changes[0].reason, 'working-tree-changed'); const readsAfterChange = reads.length; await new Promise((resolve) => setTimeout(resolve, 800)); assert.equal(reads.length, readsAfterChange, 'a quiet repository is not read again'); monitor.stop(); assert.equal(monitor.watchers.size, 0, 'stopping releases every watcher'); }); test('background Gitea awareness fetches read-only remote state with bounded concurrency', async () => { let active = 0; let peak = 0; const changes = []; const git = { fetch: async (localPath) => { active += 1; peak = Math.max(peak, active); await new Promise((resolve) => setTimeout(resolve, 15)); active -= 1; return { status: { localPath, revision: 2, branch: { head: 'main', ahead: 0, behind: 1 }, counts: {} } }; }, statusFingerprint: (status) => String(status.revision), }; const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2, fetchIntervalMinutes: 1 } } }; const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) }); const paths = Array.from({ length: 6 }, (_, index) => `/repo-${index}`); monitor.setPaths(paths); for (const localPath of paths) { monitor.fingerprints.set(localPath, '1'); monitor.lastFetchedAt.set(localPath, Date.now() - 61_000); } await monitor.fetchRemoteUpdates(); assert.equal(peak, 2); assert.equal(active, 0); assert.equal(changes.length, paths.length); assert.ok(changes.every((change) => change.reason === 'remote-state-changed')); }); test('a zero remote fetch interval disables background network access', async () => { let fetches = 0; const git = { fetch: async () => { fetches += 1; return { status: { revision: 2 } }; }, statusFingerprint: (status) => String(status.revision), }; const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2, fetchIntervalMinutes: 0 } } }; const monitor = new RepositoryMonitor({ store, git }); monitor.setPaths(['/repo']); monitor.lastFetchedAt.set('/repo', 0); await monitor.fetchRemoteUpdates(Date.now() + 24 * 60 * 60_000); assert.equal(fetches, 0); });