import { describe, expect, it } from 'vitest'; import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp } from '../src/main/upgrade-watch'; describe('upgrade-watch', () => { it('lit une empreinte de fichier, et rien pour un chemin absent', () => { const file = join(mkdtempSync(join(tmpdir(), 'arb-stamp-')), 'bin'); writeFileSync(file, 'v1', 'utf8'); const stamp = readInstallStamp(file); expect(stamp?.size).toBe(2); expect(readInstallStamp(join(file, 'nulle-part'))).toBeNull(); }); it('détecte le remplacement du binaire (mtime/taille)', () => { const file = join(mkdtempSync(join(tmpdir(), 'arb-stamp-')), 'bin'); writeFileSync(file, 'v1', 'utf8'); const boot = readInstallStamp(file); expect(installChanged(boot, readInstallStamp(file))).toBe(false); writeFileSync(file, 'version deux', 'utf8'); utimesSync(file, new Date(), new Date(Date.now() + 5_000)); // dpkg pose un mtime plus récent expect(installChanged(boot, readInstallStamp(file))).toBe(true); }); it('une empreinte illisible ne conclut jamais à une mise à jour', () => { const stamp = { ino: 1, mtimeMs: 2, size: 3 }; expect(installChanged(null, stamp)).toBe(false); expect(installChanged(stamp, null)).toBe(false); expect(installChanged(null, null)).toBe(false); }); }); // Objectif produit : ZÉRO manipulation quand c'est sans risque. Un redémarrage ne détruit qu'une // chose, les sessions vivantes hébergées par le daemon : elles seules justifient de demander. describe('decideUpgradeAction', () => { it('rien à faire si l’installation n’a pas changé', () => { expect(decideUpgradeAction({ changed: false, liveSessions: 0, dismissed: false })).toBe('none'); expect(decideUpgradeAction({ changed: false, liveSessions: 3, dismissed: false })).toBe('none'); }); it('aucune session vivante : redémarrage automatique, sans dialogue', () => { expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: false })).toBe('restart'); }); it('des sessions tournent : on demande avant de les interrompre', () => { expect(decideUpgradeAction({ changed: true, liveSessions: 1, dismissed: false })).toBe('ask'); expect(decideUpgradeAction({ changed: true, liveSessions: 9, dismissed: false })).toBe('ask'); }); it('état inconnu (daemon injoignable) : on demande, par prudence', () => { expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: false })).toBe('ask'); }); it('un « Later » est définitif : jamais de redémarrage dans le dos de l’utilisateur', () => { expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: true })).toBe('none'); expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: true })).toBe('none'); }); }); describe('pollInstall', () => { it('signale le premier changement, puis s’arrête de lui-même', async () => { const dir = mkdtempSync(join(tmpdir(), 'arb-poll-')); const file = join(dir, 'app'); try { writeFileSync(file, 'v1'); const boot = readInstallStamp(file); const seen: number[] = []; const handle = pollInstall({ path: file, intervalMs: 10, boot, onChanged: (c) => seen.push(c.size) }); await new Promise((r) => setTimeout(r, 40)); expect(seen).toHaveLength(0); // rien n'a bougé writeFileSync(file, 'v2-plus-long'); await new Promise((r) => setTimeout(r, 80)); expect(seen).toHaveLength(1); // un second changement ne doit PAS rappeler : le poll s'arrête au premier writeFileSync(file, 'v3-encore-plus-long'); await new Promise((r) => setTimeout(r, 60)); expect(seen).toHaveLength(1); handle.stop(); } finally { rmSync(dir, { recursive: true, force: true }); } }); it('un chemin illisible ne déclenche rien', async () => { const seen: string[] = []; const handle = pollInstall({ path: '/definitely/not/here', intervalMs: 10, boot: { ino: 1, mtimeMs: 1, size: 1 }, onChanged: () => seen.push('x'), }); await new Promise((r) => setTimeout(r, 50)); expect(seen).toHaveLength(0); handle.stop(); }); });