import { test } from 'node:test'; import assert from 'node:assert/strict'; import { Store } from '../src/store/index.js'; import { createNotifier, startNotifier, sanitizeForOsascript, NOTIFY_SUPPRESS_MS } from '../src/daemon/notify.js'; const noopLog = { info: (): void => undefined, error: (): void => undefined }; function setup(): { store: Store; projectId: string } { const store = new Store(':memory:'); const p = store.createProject({ name: 'notify', repoPath: '/tmp/notify-repo-' + Math.random() }); return { store, projectId: p.id }; } /** 注入假 osascript:捕获脚本文本,不真发通知 */ function capture(store: Store, now?: () => number): { scripts: string[]; stop: () => void } { const scripts: string[] = []; const stop = createNotifier(store, noopLog, { osascript: (s) => scripts.push(s), ...(now ? { now } : {}) }); return { scripts, stop }; } const emptyResult = { branch: 'maestro/x', worktree: '/wt', diffSummary: null, commits: [], prUrl: null, summary: null, verdict: null, securitySummary: null, securityVerdict: null, } as const; test('notify:进入 spec_review 闸 →「等待审核(方案)」;与任务名一致', () => { const { store, projectId } = setup(); const t = store.createTask({ projectId, title: '写登录方案', complexity: 'medium' }); const { scripts } = capture(store); store.setSpec(t.id, '方案'); store.transition(t.id, 'spec_review'); assert.equal(scripts.length, 1); assert.match(scripts[0], /display notification "⏳ 写登录方案 等待审核(方案)" with title "maestro"/); store.close(); }); test('notify:exec_review 且任一复审 verdict=reject → 文案改「复审建议拒绝」', () => { const { store, projectId } = setup(); const t = store.createTask({ projectId, title: '高危改动', complexity: 'easy' }); store.transition(t.id, 'queued'); store.transition(t.id, 'executing'); store.setResult(t.id, { ...emptyResult, securityVerdict: 'reject' }); const { scripts } = capture(store); store.transition(t.id, 'exec_review'); assert.equal(scripts.length, 1); assert.match(scripts[0], /⚠ 高危改动 复审建议拒绝/); store.close(); }); test('notify:exec_review 复审均通过 →「等待审核(PR)」', () => { const { store, projectId } = setup(); const t = store.createTask({ projectId, title: '普通改动', complexity: 'easy' }); store.transition(t.id, 'queued'); store.transition(t.id, 'executing'); store.setResult(t.id, { ...emptyResult, verdict: 'approve', securityVerdict: 'approve' }); const { scripts } = capture(store); store.transition(t.id, 'exec_review'); assert.equal(scripts.length, 1); assert.match(scripts[0], /⏳ 普通改动 等待审核(PR)/); store.close(); }); test('notify:needs_attention →「连续失败需人工」', () => { const { store, projectId } = setup(); const t = store.createTask({ projectId, title: '坏任务', complexity: 'easy' }); store.transition(t.id, 'queued'); store.transition(t.id, 'executing'); store.transition(t.id, 'failed'); const { scripts } = capture(store); store.transition(t.id, 'needs_attention'); assert.equal(scripts.length, 1); assert.match(scripts[0], /❌ 坏任务 连续失败需人工/); store.close(); }); test('notify:approval.granted 且 gate=exec →「已合并完成」;plan/spec 闸通过不通知', () => { const { store, projectId } = setup(); // spec 闸 accept:不触发"已合并完成" const m = store.createTask({ projectId, title: '方案任务', complexity: 'medium' }); store.setSpec(m.id, 'spec'); store.transition(m.id, 'spec_review'); // exec 闸 accept:触发 const e = store.createTask({ projectId, title: '执行任务', complexity: 'easy' }); store.transition(e.id, 'queued'); store.transition(e.id, 'executing'); store.setResult(e.id, { ...emptyResult }); store.transition(e.id, 'exec_review'); const { scripts } = capture(store); store.decide(m.id, 'accept', 'user'); // spec 闸 → 无"合并完成"通知 store.decide(e.id, 'accept', 'user'); // exec 闸 → 通知 const merged = scripts.filter((s) => s.includes('已合并完成')); assert.equal(merged.length, 1); assert.match(merged[0], /✅ 执行任务 已合并完成/); store.close(); }); test('notify:事件过滤——ready/queued/executing 等普通流转不通知', () => { const { store, projectId } = setup(); const t = store.createTask({ projectId, title: '安静任务', complexity: 'easy' }); const { scripts } = capture(store); store.transition(t.id, 'queued'); store.transition(t.id, 'executing'); store.transition(t.id, 'failed'); store.transition(t.id, 'queued'); assert.equal(scripts.length, 0); store.close(); }); test('notify:同任务同类型 60s 抑制;超窗后再发;不同任务不互相抑制', () => { const { store, projectId } = setup(); const t = store.createTask({ projectId, title: '反复任务', complexity: 'medium' }); const t2 = store.createTask({ projectId, title: '另一个', complexity: 'medium' }); let fakeNow = 1_000_000; const { scripts } = capture(store, () => fakeNow); store.setSpec(t.id, 's1'); store.transition(t.id, 'spec_review'); // 第 1 次:发 assert.equal(scripts.length, 1); store.decide(t.id, 'reject', 'user', '再改改'); // 回 speccing(approval.rejected 不通知) fakeNow += 30_000; store.transition(t.id, 'spec_review'); // 30s 内同类型:抑制 assert.equal(scripts.length, 1); store.setSpec(t2.id, 's2'); store.transition(t2.id, 'spec_review'); // 不同任务:不受抑制 assert.equal(scripts.length, 2); store.decide(t.id, 'reject', 'user', '还得改'); fakeNow += NOTIFY_SUPPRESS_MS; // 距上次该任务通知已 >60s store.transition(t.id, 'spec_review'); assert.equal(scripts.length, 3); store.close(); }); test('notify:取消订阅后不再收到通知', () => { const { store, projectId } = setup(); const t = store.createTask({ projectId, title: '退订', complexity: 'medium' }); const { scripts, stop } = capture(store); stop(); store.setSpec(t.id, 's'); store.transition(t.id, 'spec_review'); assert.equal(scripts.length, 0); store.close(); }); test('notify:MAESTRO_NOTIFY=0 时 startNotifier 不启用', () => { const { store } = setup(); const prev = process.env.MAESTRO_NOTIFY; process.env.MAESTRO_NOTIFY = '0'; try { assert.equal(startNotifier(store, { log: noopLog }), null); } finally { if (prev === undefined) delete process.env.MAESTRO_NOTIFY; else process.env.MAESTRO_NOTIFY = prev; store.close(); } }); test('sanitizeForOsascript:转义引号反斜杠、去换行、截断 80 字符', () => { assert.equal(sanitizeForOsascript('say "hi" \\ ok'), 'say \\"hi\\" \\\\ ok'); assert.equal(sanitizeForOsascript('一行\n两行\r\n三行'), '一行 两行 三行'); const long = 'x'.repeat(200); const cut = sanitizeForOsascript(long); assert.equal(cut.length, 80); assert.ok(cut.endsWith('…')); });