Files
maestro/test/notify.test.ts
T
wangjia 740d2c2637 feat: PR 审核流程 + 自动合并 + macOS 通知 + 订阅额度透传 + 归档详情
PR 流程(开发→提PR→CodeReview→安全审计→审核→通过即合并):
- reviewer 拆分为 code review 与安全审计两个独立 CC run(kind=reviewer/security),
  TaskResult 四字段(summary/verdict + securitySummary/securityVerdict),闸上两节报告两枚结论徽章
- executor/merge.ts: 通过即合并——临时 worktree 内 merge --no-ff,绝不碰用户工作区/不 push;
  冲突安全拒绝(报冲突文件);重复合并幂等;合并后回收执行 worktree + 删分支
- decide 路由 merge:false 逃生口 + 看板「仅通过」按钮(目标分支被工作区检出时用)

通知(daemon/notify.ts):
- macOS 原生通知: 进审核闸(复审建议拒绝标⚠)/连续失败需人工/合并完成
- 同任务同类型 60s 抑制、osascript 转义截断、MAESTRO_NOTIFY=0 关闭

订阅额度透传(daemon/usage.ts):
- OAuth usage API(与 Claude Code/claude-hud 同源),凭证 keychain→内存零泄漏
- 60s 成败双缓存+并发去重+5s 超时,失败降级 null
- GET /api/agents 顶层 usage 字段;Agent 面板显示 5h/周用量条+重置倒计时(>80%琥珀/>95%红)

看板与生命周期:
- 归档区: 深度1整树完成沉底,时间倒序分页(尺寸 chip 10/20/50/100 置底)
- 归档详情对话框: 全属性/执行历史与时长/审批记录/状态流转时间线(GET /api/tasks/:id/events)
- 容器收口: 已拆解 Hard 子任务全 done 自动 done(afterDone 逐级向上)
- 同步按钮收进配置面板;执行白名单扩测试命令(npm/go/shellcheck/make/pytest)
- Agent 面板显示调度模式与各复杂度模型;被依赖阻塞→被阻塞

测试: 74/74(新增 merge 6/notify 10/usage 7/容器收口/双复审适配)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 03:22:30 +08:00

169 lines
7.0 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 { 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('notifyexec_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('notifyexec_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('notifyneeds_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('notifyapproval.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', '再改改'); // 回 speccingapproval.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('notifyMAESTRO_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('…'));
});