Files
arboretum/packages/desktop/test/upgrade-watch.test.ts
T
johanleroy 17e95754b1 fix(server, desktop): plus de transcript perdu, et la mise à jour s'applique toute seule
Deux défauts vécus sur le poste, tous deux « invisibles » jusqu'à ce qu'on regarde.

1. Une console Claude ouverte depuis l'app affichait « Transcript saving is off,
   inherited CLAUDE_CODE_CHILD_SESSION marker ». Le daemon avait été lancé depuis
   une session Claude Code, il héritait donc de ses marqueurs d'exécution et les
   repassait à CHAQUE session qu'il lance. Le CLI se croyait sous-session et
   coupait la sauvegarde de son transcript : plus d'historique, plus de --resume,
   claudeSessionId restant null (et avec lui l'état fin busy/waiting/idle, ce qui
   explique les sessions sans activité détectée). L'environnement des PTY est
   desormais assaini de ces marqueurs, pour `claude` comme pour les shells (un
   `claude` tapé à la main en héritait aussi). La configuration légitime de
   l'utilisateur (CLAUDE_CONFIG_DIR, ANTHROPIC_*, proxies) passe intacte.
   Vérifié par acceptance-p17 : le daemon de test est lancé avec un environnement
   volontairement pollué, et le PTY n'en voit plus rien.

2. Une mise à jour installée à chaud demandait encore une manipulation. La 0.2.4
   détectait le remplacement du binaire et proposait un dialogue « Restart now » :
   le travail restait à la charge de l'utilisateur. L'app redémarre maintenant
   d'elle-même quand cela ne coûte rien, c'est-à-dire le cas courant, et ne
   demande que s'il y a quelque chose à perdre : des sessions vivantes à
   interrompre (le dialogue dit combien) ou un daemon injoignable. Un « Later »
   reste définitif pour cette version : rien ne redémarre dans le dos de
   personne. La détection ne dépend plus d'un retour par le tray ou le Dock : un
   `stat` toutes les 30 s la couvre même fenêtre ouverte, par poll et non par
   `fs.watch`, qui ne voit souvent rien quand un paquet remplace un binaire ou
   tout un répertoire.
2026-08-05 11:35:51 +02:00

101 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});