P4-A: commande WS answer — répondre aux dialogues sans clavier
Nouvelle commande WS de haut niveau `answer {channel, action, optionN?}`
traduite côté serveur en keystrokes (chiffre+Entrée / Entrée / Esc) et
validée contre le dialogue courant du tracker (anti-frappe fantôme mobile).
- protocol.ts : message `answer` + validation parseClientMessage + ErrorCode INVALID_ANSWER
- pty-manager.answer() : mappe l'intention en write PTY, réutilise le modèle mono-utilisateur
- gateway : case `answer` (NOT_CONTROLLING / SESSION_EXITED / INVALID_ANSWER)
- web : Attachment.answer(), DialogPrompt.vue (attache interactive légère sink no-op),
intégré dans SessionView, SessionsListView, WorktreeCard + i18n en/fr
- tests : parseClientMessage (answer valide/malformé + fuzz), pty-manager.answer (163 verts)
This commit is contained in:
@@ -42,6 +42,15 @@ function assertInvariants(msg: ClientMessage): void {
|
||||
expect(typeof msg.data).toBe('string');
|
||||
expect(msg.data.length).toBeLessThanOrEqual(65536);
|
||||
break;
|
||||
case 'answer':
|
||||
expect(isU32(msg.channel)).toBe(true);
|
||||
expect(['select', 'confirm', 'deny']).toContain(msg.action);
|
||||
if (msg.action === 'select') {
|
||||
expect(typeof msg.optionN).toBe('number');
|
||||
expect(msg.optionN).toBeGreaterThanOrEqual(1);
|
||||
expect(msg.optionN).toBeLessThanOrEqual(99);
|
||||
}
|
||||
break;
|
||||
case 'resize':
|
||||
expect(isU32(msg.channel)).toBe(true);
|
||||
expect(isDim(msg.cols)).toBe(true);
|
||||
@@ -92,6 +101,18 @@ describe('parseClientMessage — cas valides', () => {
|
||||
.toEqual({ type: 'resize', channel: 3, cols: 120, rows: 32 });
|
||||
});
|
||||
|
||||
it('answer select / confirm / deny', () => {
|
||||
expect(parseClientMessage('{"type":"answer","channel":2,"action":"select","optionN":3}'))
|
||||
.toEqual({ type: 'answer', channel: 2, action: 'select', optionN: 3 });
|
||||
expect(parseClientMessage('{"type":"answer","channel":2,"action":"confirm"}'))
|
||||
.toEqual({ type: 'answer', channel: 2, action: 'confirm' });
|
||||
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny"}'))
|
||||
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
|
||||
// optionN n'est porté que par 'select' (ignoré pour confirm/deny)
|
||||
expect(parseClientMessage('{"type":"answer","channel":2,"action":"deny","optionN":3}'))
|
||||
.toEqual({ type: 'answer', channel: 2, action: 'deny' });
|
||||
});
|
||||
|
||||
it('ack à zéro octet', () => {
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
|
||||
});
|
||||
@@ -153,6 +174,15 @@ describe('parseClientMessage — cas malformés', () => {
|
||||
expect(parseClientMessage('{"type":"stdin","channel":1}')).toBeNull();
|
||||
});
|
||||
|
||||
it('answer : action inconnue, optionN manquant/hors bornes, channel invalide', () => {
|
||||
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select"}')).toBeNull(); // optionN requis
|
||||
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":0}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":100}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"answer","channel":1,"action":"select","optionN":1.5}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"answer","channel":1,"action":"nope"}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"answer","channel":-1,"action":"deny"}')).toBeNull();
|
||||
});
|
||||
|
||||
it('ack : bytes négatif ou non numérique', () => {
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":-1}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":"0"}')).toBeNull();
|
||||
@@ -168,16 +198,16 @@ describe('parseClientMessage — cas malformés', () => {
|
||||
describe('parseClientMessage — fuzz rapide', () => {
|
||||
it('ne lève jamais et tout message accepté respecte les invariants', () => {
|
||||
const rand = mulberry32(0xa5b0e7);
|
||||
const types = ['hello', 'attach', 'detach', 'stdin', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
|
||||
const types = ['hello', 'attach', 'detach', 'stdin', 'answer', 'resize', 'ack', 'sub', 'ping', 'unknown', '', 'HELLO', 42, null];
|
||||
const values: unknown[] = [
|
||||
undefined, null, true, false, 0, -1, 1, 1.5, 2, 999, 1000, 1001, 0xffffffff, 0x100000000,
|
||||
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', {}, [], ['sessions'],
|
||||
undefined, null, true, false, 0, -1, 1, 1.5, 2, 99, 100, 999, 1000, 1001, 0xffffffff, 0x100000000,
|
||||
-0.0001, 1e21, '', 'x', '42', 'interactive', 'observer', 'sessions', 'select', 'confirm', 'deny', {}, [], ['sessions'],
|
||||
['sessions', 'sessions'], ['sessions', 'other'], [42], 'a'.repeat(70000),
|
||||
];
|
||||
const pick = <T>(arr: T[]): T => arr[Math.floor(rand() * arr.length)] as T;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const obj: Record<string, unknown> = { type: pick(types) };
|
||||
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics']) {
|
||||
for (const key of ['protocol', 'sessionId', 'mode', 'cols', 'rows', 'channel', 'data', 'bytes', 'topics', 'action', 'optionN']) {
|
||||
if (rand() < 0.7) obj[key] = pick(values);
|
||||
}
|
||||
const raw = JSON.stringify(obj);
|
||||
|
||||
Reference in New Issue
Block a user