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.
101 lines
4.3 KiB
TypeScript
101 lines
4.3 KiB
TypeScript
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();
|
||
});
|
||
});
|