P4-B: notifications Web Push (VAPID) sur passage en waiting

- db: migration id=4 `push_subscriptions` (liées au token d'auth) ; clés VAPID en settings
- PushService: bootstrap idempotent des clés VAPID, subscribe/unsubscribe/count,
  notify() best-effort (purge des abonnements 410/404 Gone) ; sender injectable pour les tests
- routes/push.ts: GET vapid-public-key, POST subscribe/unsubscribe/test (toutes sous auth globale)
- pty-manager: déclencheur push sur FRONT MONTANT vers waiting, débouncé 1500ms et annulable
  (faux positif ignoré) ; câblage app/index/config (--vapid-contact)
- shared/api.ts: types VapidKeyResponse / PushSubscribeRequest / PushUnsubscribeRequest
- deps: web-push (+ @types/web-push) côté serveur
- tests: push-service (idempotence, UPSERT, 410-purge, payload) + trigger pty-manager (169 verts)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-06-15 12:13:56 +02:00
parent 9f7c13dddb
commit 28b9283825
13 changed files with 493 additions and 11 deletions

View File

@@ -4,6 +4,7 @@ import { join } from 'node:path';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
import type { PushPayload, PushService } from '../src/core/push-service.js';
import { openDb, type Db } from '../src/db/index.js';
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
@@ -344,6 +345,45 @@ describe('PtyManager (pty mocké)', () => {
});
});
describe('push trigger (P4-B)', () => {
it('une notif sur le front montant busy→waiting, après le debounce ; pas de notif en restant busy', async () => {
const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-'));
const notifies: PushPayload[] = [];
const fakePush = {
notify: async (p: PushPayload) => {
notifies.push(p);
},
} as unknown as PushService;
const m = new PtyManager(db, sessDir, fakePush);
try {
const summary = m.spawn({ cwd, command: 'claude' });
const p = lastPty();
const regFile = join(sessDir, `${p.pid}.json`);
const writeReg = (status: string, waitingFor?: string): void =>
writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status, ...(waitingFor ? { waitingFor } : {}) }));
// busy : front montant vers busy, pas de notif
writeReg('busy');
p.emitData('working…');
await sleep(260);
expect(notifies).toHaveLength(0);
// waiting + écran permission : front montant vers waiting → planifie la notif (debounce 1500ms)
writeReg('waiting', 'permission prompt');
p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n 1. Yes\r\n2. No\r\n');
await sleep(260);
expect(notifies).toHaveLength(0); // pas encore : debounce en cours
await sleep(1500); // dépasse le debounce
expect(notifies).toHaveLength(1);
expect(notifies[0]).toMatchObject({ sessionId: summary.id, kind: 'permission', url: `/sessions/${summary.id}` });
} finally {
m.shutdown();
rmSync(sessDir, { recursive: true, force: true });
}
});
});
describe('flow control', () => {
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
const { summary, pty } = spawnBash();

View File

@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest';
import { PushService, type PushSender, type PushPayload } from '../src/core/push-service.js';
import { openDb, getSetting, type Db } from '../src/db/index.js';
function sub(endpoint: string): { endpoint: string; keys: { p256dh: string; auth: string } } {
return { endpoint, keys: { p256dh: `p-${endpoint}`, auth: `a-${endpoint}` } };
}
const payload: PushPayload = { sessionId: 's1', title: 't', body: 'b', kind: 'permission', url: '/sessions/s1' };
describe('PushService', () => {
it('génère les clés VAPID une seule fois (idempotent) et les persiste', () => {
const db: Db = openDb(':memory:');
const a = new PushService(db);
const pub = a.publicKey();
expect(pub).toMatch(/.{20,}/); // clé base64url non triviale
expect(getSetting(db, 'vapid_private')).not.toBeNull();
// une seconde instance sur la même db réutilise les clés (pas de régénération)
const b = new PushService(db);
expect(b.publicKey()).toBe(pub);
});
it('subscribe (UPSERT par endpoint), count et unsubscribe', () => {
const db = openDb(':memory:');
const p = new PushService(db);
p.subscribe('tok1', sub('https://push/a'), 'UA');
p.subscribe('tok1', sub('https://push/b'), null);
expect(p.count()).toBe(2);
// ré-abonnement du même endpoint → pas de doublon
p.subscribe('tok2', sub('https://push/a'), 'UA2');
expect(p.count()).toBe(2);
p.unsubscribe('https://push/a');
expect(p.count()).toBe(1);
});
it('notify : supprime labonnement sur 410 Gone, conserve sur autre erreur, marque last_ok_at sur succès', async () => {
const db = openDb(':memory:');
const sender: PushSender = vi.fn(async (s) => {
if (s.endpoint.endsWith('/gone')) throw Object.assign(new Error('gone'), { statusCode: 410 });
if (s.endpoint.endsWith('/flaky')) throw Object.assign(new Error('boom'), { statusCode: 500 });
return { statusCode: 201 };
});
const p = new PushService(db, 'mailto:test@x', sender);
p.subscribe('tok', sub('https://push/ok'), null);
p.subscribe('tok', sub('https://push/gone'), null);
p.subscribe('tok', sub('https://push/flaky'), null);
await p.notify(payload);
expect(sender).toHaveBeenCalledTimes(3);
// 410 → purgé ; 500 → conservé ; ok → conservé
expect(p.count()).toBe(2);
const rows = db.prepare('SELECT endpoint, last_ok_at FROM push_subscriptions ORDER BY endpoint').all() as Array<{ endpoint: string; last_ok_at: string | null }>;
const byEndpoint = Object.fromEntries(rows.map((r) => [r.endpoint, r.last_ok_at]));
expect(byEndpoint['https://push/ok']).not.toBeNull(); // succès horodaté
expect(byEndpoint['https://push/flaky']).toBeNull(); // échec transitoire : pas d'horodatage, mais conservé
expect(byEndpoint['https://push/gone']).toBeUndefined();
});
it('notify sans abonnement : ne touche pas au sender', async () => {
const db = openDb(':memory:');
const sender: PushSender = vi.fn(async () => ({}));
const p = new PushService(db, 'mailto:test@x', sender);
await p.notify(payload);
expect(sender).not.toHaveBeenCalled();
});
it('le payload poussé est le JSON sérialisé de PushPayload', async () => {
const db = openDb(':memory:');
let captured = '';
const sender: PushSender = async (_s, body) => {
captured = body;
return {};
};
const p = new PushService(db, 'mailto:test@x', sender);
p.subscribe('tok', sub('https://push/ok'), null);
await p.notify(payload);
expect(JSON.parse(captured)).toEqual(payload);
});
});