pi-skill-evolve 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +81 -2
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
- package/extensions/skill-evolve/index.ts +300 -0
- package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
- package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
- package/extensions/skill-evolve/lib/apply.ts +141 -0
- package/extensions/skill-evolve/lib/attribution.ts +138 -0
- package/extensions/skill-evolve/lib/audit.ts +221 -0
- package/extensions/skill-evolve/lib/baseline.ts +176 -0
- package/extensions/skill-evolve/lib/child-env.ts +10 -0
- package/extensions/skill-evolve/lib/classify-action.ts +161 -0
- package/extensions/skill-evolve/lib/command.ts +100 -0
- package/extensions/skill-evolve/lib/config-command.ts +38 -0
- package/extensions/skill-evolve/lib/config.ts +194 -0
- package/extensions/skill-evolve/lib/detect.ts +62 -0
- package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
- package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
- package/extensions/skill-evolve/lib/dispatch.ts +300 -0
- package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
- package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
- package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
- package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
- package/extensions/skill-evolve/lib/evolution.ts +289 -0
- package/extensions/skill-evolve/lib/flags.ts +44 -0
- package/extensions/skill-evolve/lib/gate0.ts +78 -0
- package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
- package/extensions/skill-evolve/lib/lock.ts +215 -0
- package/extensions/skill-evolve/lib/logfile.ts +52 -0
- package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
- package/extensions/skill-evolve/lib/mechanical.ts +136 -0
- package/extensions/skill-evolve/lib/metrics.ts +172 -0
- package/extensions/skill-evolve/lib/model-config.ts +95 -0
- package/extensions/skill-evolve/lib/notify.ts +132 -0
- package/extensions/skill-evolve/lib/panel.ts +174 -0
- package/extensions/skill-evolve/lib/pending.ts +34 -0
- package/extensions/skill-evolve/lib/proc.ts +65 -0
- package/extensions/skill-evolve/lib/prompt.ts +80 -0
- package/extensions/skill-evolve/lib/proposal.ts +227 -0
- package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
- package/extensions/skill-evolve/lib/queue.ts +136 -0
- package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
- package/extensions/skill-evolve/lib/replay.ts +246 -0
- package/extensions/skill-evolve/lib/report.ts +181 -0
- package/extensions/skill-evolve/lib/review-post.ts +101 -0
- package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
- package/extensions/skill-evolve/lib/review-record.ts +63 -0
- package/extensions/skill-evolve/lib/review-run.ts +131 -0
- package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
- package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
- package/extensions/skill-evolve/lib/round.ts +116 -0
- package/extensions/skill-evolve/lib/run-watch.ts +34 -0
- package/extensions/skill-evolve/lib/runs.ts +208 -0
- package/extensions/skill-evolve/lib/selftest.ts +201 -0
- package/extensions/skill-evolve/lib/session-index.ts +145 -0
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
- package/extensions/skill-evolve/lib/single-flight.ts +111 -0
- package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
- package/extensions/skill-evolve/lib/stale.ts +217 -0
- package/extensions/skill-evolve/lib/telemetry.ts +205 -0
- package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
- package/extensions/skill-evolve/lib/web/actions.ts +299 -0
- package/extensions/skill-evolve/lib/web/activity.ts +57 -0
- package/extensions/skill-evolve/lib/web/applied.ts +64 -0
- package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
- package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
- package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
- package/extensions/skill-evolve/lib/web/data.ts +298 -0
- package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
- package/extensions/skill-evolve/lib/web/filter.ts +203 -0
- package/extensions/skill-evolve/lib/web/http.ts +68 -0
- package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
- package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
- package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
- package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
- package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
- package/extensions/skill-evolve/lib/web/render.ts +136 -0
- package/extensions/skill-evolve/lib/web/reports.ts +160 -0
- package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
- package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
- package/extensions/skill-evolve/lib/web/server.ts +268 -0
- package/extensions/skill-evolve/lib/web/shell.ts +87 -0
- package/extensions/skill-evolve/lib/web/skills.ts +202 -0
- package/extensions/skill-evolve/lib/web/style.ts +107 -0
- package/extensions/skill-evolve/lib/web/text.ts +52 -0
- package/extensions/skill-evolve/lib/web/trash.ts +36 -0
- package/extensions/skill-evolve/lib/weekly.ts +285 -0
- package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
- package/extensions/skill-evolve/scripts/replay.ts +93 -0
- package/extensions/skill-evolve/test/apply.test.ts +278 -0
- package/extensions/skill-evolve/test/approve.test.ts +248 -0
- package/extensions/skill-evolve/test/attribution.test.ts +133 -0
- package/extensions/skill-evolve/test/audit.test.ts +151 -0
- package/extensions/skill-evolve/test/baseline.test.ts +132 -0
- package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
- package/extensions/skill-evolve/test/command.test.ts +267 -0
- package/extensions/skill-evolve/test/config-command.test.ts +73 -0
- package/extensions/skill-evolve/test/config.test.ts +154 -0
- package/extensions/skill-evolve/test/detect.test.ts +71 -0
- package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
- package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
- package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
- package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
- package/extensions/skill-evolve/test/evolution.test.ts +378 -0
- package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
- package/extensions/skill-evolve/test/hard-rules.test.ts +116 -0
- package/extensions/skill-evolve/test/lock.test.ts +138 -0
- package/extensions/skill-evolve/test/logfile.test.ts +85 -0
- package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
- package/extensions/skill-evolve/test/metrics.test.ts +129 -0
- package/extensions/skill-evolve/test/model-config.test.ts +84 -0
- package/extensions/skill-evolve/test/notify.test.ts +133 -0
- package/extensions/skill-evolve/test/observe.test.ts +100 -0
- package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
- package/extensions/skill-evolve/test/panel.test.ts +115 -0
- package/extensions/skill-evolve/test/proposal.test.ts +159 -0
- package/extensions/skill-evolve/test/queue.test.ts +110 -0
- package/extensions/skill-evolve/test/reject.test.ts +134 -0
- package/extensions/skill-evolve/test/replay.test.ts +111 -0
- package/extensions/skill-evolve/test/report.test.ts +66 -0
- package/extensions/skill-evolve/test/review-post.test.ts +92 -0
- package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
- package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
- package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
- package/extensions/skill-evolve/test/selftest.test.ts +113 -0
- package/extensions/skill-evolve/test/session-index.test.ts +128 -0
- package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
- package/extensions/skill-evolve/test/stale.test.ts +272 -0
- package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
- package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
- package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
- package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
- package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
- package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
- package/extensions/skill-evolve/test/web-data.test.ts +420 -0
- package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
- package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
- package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
- package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
- package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
- package/extensions/skill-evolve/test/web-render.test.ts +300 -0
- package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
- package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
- package/extensions/skill-evolve/test/web-server.test.ts +465 -0
- package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
- package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
- package/extensions/skill-evolve/test/weekly.test.ts +123 -0
- package/package.json +34 -4
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/** 只读面板的服务端渲染:HTML 转义(技能正文里可能有 <script>)、各视图标题与空态、状态栏数字。 */
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import type { WebState } from "../lib/web/data.ts";
|
|
5
|
+
import { escapeHtml, formatStamp, parseView, renderPage } from "../lib/web/render.ts";
|
|
6
|
+
|
|
7
|
+
function withApproval(): WebState {
|
|
8
|
+
const state = emptyState();
|
|
9
|
+
state.approvals.push({
|
|
10
|
+
runId: "run-a",
|
|
11
|
+
skill: "demo",
|
|
12
|
+
level: "approval",
|
|
13
|
+
targets: [".pi/skills/demo/SKILL.md"],
|
|
14
|
+
createdAt: new Date(2026, 10, 8, 12).getTime(),
|
|
15
|
+
proposal: { type: "improve", added: 3, removed: 1 },
|
|
16
|
+
review: { verdict: "pass", blocking: 0 },
|
|
17
|
+
detail: "",
|
|
18
|
+
});
|
|
19
|
+
return state;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function emptyState(): WebState {
|
|
23
|
+
return {
|
|
24
|
+
pending: [],
|
|
25
|
+
approvals: [],
|
|
26
|
+
applied: [],
|
|
27
|
+
done: [],
|
|
28
|
+
reports: [],
|
|
29
|
+
summary: { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withPending(head: string, detail: string): WebState {
|
|
34
|
+
const state = emptyState();
|
|
35
|
+
state.pending.push({ runId: "run-x", kind: "approval", skill: head, at: 0, detail });
|
|
36
|
+
return state;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("escapeHtml:五个字符全转,且 & 先转", () => {
|
|
40
|
+
assert.equal(escapeHtml(`& < > " '`), "& < > " '");
|
|
41
|
+
assert.equal(escapeHtml("<script>alert(1)</script>"), "<script>alert(1)</script>");
|
|
42
|
+
assert.equal(escapeHtml("a&b"), "a&amp;b", "已转义文本再转一次也不会被二次解码");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("渲染:技能名与详情里的 <script> 一律转义,页面里没有裸 <script>", () => {
|
|
46
|
+
const html = renderPage(withPending("<script>alert(1)</script>", "证据:<script>x()</script>"), "pending");
|
|
47
|
+
assert.equal(/<script/i.test(html), false, "不能出现裸 <script>");
|
|
48
|
+
assert.match(html, /<script>alert\(1\)<\/script>/);
|
|
49
|
+
assert.match(html, /<script>x\(\)<\/script>/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("渲染:findings 里的 HTML 也被转义(报告页同样不可注入)", () => {
|
|
53
|
+
const state = emptyState();
|
|
54
|
+
state.reports.push({
|
|
55
|
+
runId: "run-1",
|
|
56
|
+
status: "applied",
|
|
57
|
+
trigger: "auto",
|
|
58
|
+
startedAt: 0,
|
|
59
|
+
findings: { total: 1, rows: [{ skill: "<img src=x onerror=alert(1)>", kind: "未加载", reason: "<b>没读</b>" }] },
|
|
60
|
+
proposal: { type: "improve", skill: "demo", added: 3, removed: 1 },
|
|
61
|
+
review: { status: "reviewed", verdict: "pass", blocking: 0 },
|
|
62
|
+
decision: { status: "applied", approver: "auto", at: 0, detail: "<script>d</script>" },
|
|
63
|
+
verify: { status: "applied", at: 0, gates: [{ gate: "gate0", name: "授权核对", ok: true }] },
|
|
64
|
+
});
|
|
65
|
+
const html = renderPage(state, "reports");
|
|
66
|
+
assert.equal(/<img/i.test(html), false);
|
|
67
|
+
assert.equal(/<script/i.test(html), false);
|
|
68
|
+
assert.match(html, /<img src=x onerror=alert\(1\)>/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("四个视图:标题、active 标签与列表内容各就各位", () => {
|
|
72
|
+
const state = withPending("demo", "详情");
|
|
73
|
+
const pending = renderPage(state, "pending");
|
|
74
|
+
assert.match(pending, /<title>技能进化 · 待处理<\/title>/);
|
|
75
|
+
assert.match(pending, /<a href="\/\?view=pending" class="active">待处理<\/a>/);
|
|
76
|
+
assert.match(pending, /\[待批\] run-x · demo/);
|
|
77
|
+
|
|
78
|
+
const approvals = renderPage(withApproval(), "approvals");
|
|
79
|
+
assert.match(approvals, /<title>技能进化 · 待审批<\/title>/);
|
|
80
|
+
assert.match(approvals, /<a href="\/\?view=approvals" class="active">待审批<\/a>/);
|
|
81
|
+
assert.equal(approvals.includes("run-x"), false, "待审批页只收 approvals,不串待处理条目");
|
|
82
|
+
|
|
83
|
+
const done = renderPage(state, "done");
|
|
84
|
+
assert.match(done, /<title>技能进化 · 已处理<\/title>/);
|
|
85
|
+
assert.match(done, /<a href="\/\?view=done" class="active">已处理<\/a>/);
|
|
86
|
+
assert.equal(done.includes("[待批] run-x"), false, "别的视图不能串内容");
|
|
87
|
+
|
|
88
|
+
const reports = renderPage(state, "reports");
|
|
89
|
+
assert.match(reports, /<title>技能进化 · 复盘报告<\/title>/);
|
|
90
|
+
assert.match(reports, /<a href="\/\?view=reports" class="active">复盘报告<\/a>/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("六个视图:tab 顺序是 待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告 / 技能管理", () => {
|
|
94
|
+
const html = renderPage(emptyState(), "pending");
|
|
95
|
+
// 只扫 nav:状态栏与页脚里也有 /?view=approvals 链接,整页扫会把它们算进来
|
|
96
|
+
const nav = html.slice(html.indexOf("<nav>"), html.indexOf("</nav>"));
|
|
97
|
+
const order = [...nav.matchAll(/<a href="\/\?view=(\w+)"/g)].map((match) => match[1]);
|
|
98
|
+
assert.deepEqual(order, ["pending", "approvals", "applied", "done", "reports", "skills"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("空态:每个列表视图各有一句人话", () => {
|
|
102
|
+
for (const [view, text] of [
|
|
103
|
+
["pending", "暂无待处理:所有复盘都已完成,也没有待人批的改动。"],
|
|
104
|
+
["approvals", "没有待批:auto 级不需要人批,approval 级的会出现在这里。"],
|
|
105
|
+
["applied", "还没有已采纳的提案:采纳过的会连 diff 一起留在这里。"],
|
|
106
|
+
["done", "暂无已处理记录:还没有回滚、失败或被否决的提案。"],
|
|
107
|
+
["reports", "暂无复盘报告:还没有 run 写过 01-findings.json。"],
|
|
108
|
+
] as const) {
|
|
109
|
+
const html = renderPage(emptyState(), view);
|
|
110
|
+
assert.match(html, new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
111
|
+
assert.equal(html.includes("<ul>"), false, `${view} 空态不该有列表`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("状态栏:五个数字按顺序,回滚率取整百分比,待批数字链到待审批页", () => {
|
|
116
|
+
const state = emptyState();
|
|
117
|
+
state.summary = { reviewing: 2, stale: 1, pendingApprovals: 3, appliedTotal: 4, rollbackRate: 0.256 };
|
|
118
|
+
const html = renderPage(state, "pending");
|
|
119
|
+
assert.match(
|
|
120
|
+
html,
|
|
121
|
+
/复盘中 2 · 卡死 1 · 待批 <a href="\/\?view=approvals">3<\/a> · 已采纳 <a href="\/\?view=applied">4<\/a> · 回滚率 26%/,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("报告详情:findings 条数、提案增删行、五闸对错都在展开区里", () => {
|
|
126
|
+
const state = emptyState();
|
|
127
|
+
state.reports.push({
|
|
128
|
+
runId: "run-2",
|
|
129
|
+
status: "applied",
|
|
130
|
+
trigger: "manual",
|
|
131
|
+
startedAt: 0,
|
|
132
|
+
findings: { total: 7, rows: [{ skill: "demo", kind: "未加载", reason: "B frontmatter 坏" }] },
|
|
133
|
+
proposal: { type: "improve", skill: "demo", added: 5, removed: 2 },
|
|
134
|
+
review: { status: "reviewed", verdict: "pass", blocking: 1 },
|
|
135
|
+
decision: null,
|
|
136
|
+
verify: {
|
|
137
|
+
status: "applied",
|
|
138
|
+
at: 0,
|
|
139
|
+
gates: [
|
|
140
|
+
{ gate: "gate0", name: "授权核对", ok: true },
|
|
141
|
+
{ gate: "gate3", name: "诊断", ok: false },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
const html = renderPage(state, "reports");
|
|
146
|
+
assert.match(html, /findings:7 条/);
|
|
147
|
+
assert.match(html, /…另有 6 条(见 01-findings\.json)/);
|
|
148
|
+
assert.match(html, /提案:improve demo \+5\/-2/);
|
|
149
|
+
assert.match(html, /blocking 1 条/);
|
|
150
|
+
assert.match(html, /决策:未采纳(没有 04-decision\.json)/);
|
|
151
|
+
assert.match(html, /gate0✓ gate3✗/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("页脚写清只读与采纳入口", () => {
|
|
155
|
+
const html = renderPage(emptyState(), "pending");
|
|
156
|
+
assert.match(html, /只读面板/);
|
|
157
|
+
assert.match(html, /\/skill-evolve approve <runId>/);
|
|
158
|
+
assert.match(html, /href="\/api\/state"/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("parseView:认 approvals / done / reports,其余回落待处理", () => {
|
|
162
|
+
assert.equal(parseView("approvals"), "approvals");
|
|
163
|
+
assert.equal(parseView("done"), "done");
|
|
164
|
+
assert.equal(parseView("reports"), "reports");
|
|
165
|
+
assert.equal(parseView("pending"), "pending");
|
|
166
|
+
assert.equal(parseView(null), "pending");
|
|
167
|
+
assert.equal(parseView("bogus"), "pending");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("formatStamp:null 给破折号,时间按本地 YYYY-MM-DD HH:mm", () => {
|
|
171
|
+
assert.equal(formatStamp(null), "—");
|
|
172
|
+
assert.equal(formatStamp(new Date(2026, 10, 8, 9, 5).getTime()), "2026-11-08 09:05");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("待处理页:5 秒自刷新只加在这一页,且刷完还在待处理", () => {
|
|
176
|
+
const state = emptyState();
|
|
177
|
+
assert.match(renderPage(state, "pending"), /<meta http-equiv="refresh" content="5; url=\?view=pending">/);
|
|
178
|
+
assert.equal(renderPage(state, "done").includes('http-equiv="refresh"'), false, "看档案时刷新会把页面跳回去");
|
|
179
|
+
assert.equal(renderPage(state, "reports").includes('http-equiv="refresh"'), false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("待处理页:运行中的条目有自己的徽章,不是只给个数字", () => {
|
|
183
|
+
const state = emptyState();
|
|
184
|
+
state.pending.push({ runId: "run-live", kind: "running", skill: "", at: 0, detail: "阶段:复盘产出中" });
|
|
185
|
+
const html = renderPage(state, "pending");
|
|
186
|
+
assert.match(html, /\[进行中\] run-live/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("复盘报告:卡死的 run 行首标 [卡死],detail 里带失败原因", () => {
|
|
190
|
+
const state = emptyState();
|
|
191
|
+
state.reports.push({
|
|
192
|
+
runId: "run-dead",
|
|
193
|
+
status: "stale",
|
|
194
|
+
trigger: "auto",
|
|
195
|
+
startedAt: 0,
|
|
196
|
+
findings: { total: 0, rows: [] },
|
|
197
|
+
proposal: null,
|
|
198
|
+
review: null,
|
|
199
|
+
decision: null,
|
|
200
|
+
verify: null,
|
|
201
|
+
failure: "原因:子进程已退出\n日志末行:Error: 模板缺失",
|
|
202
|
+
});
|
|
203
|
+
const html = renderPage(state, "reports");
|
|
204
|
+
assert.match(html, /\[卡死\] <a class="run" href="\/\?view=run&runId=run-dead">run-dead<\/a>/);
|
|
205
|
+
assert.match(html, /原因:子进程已退出/);
|
|
206
|
+
assert.match(html, /日志末行:Error: 模板缺失/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("待审批页:条目上是 targets / 提案增删行 / verdict 与 blocking 条数", () => {
|
|
210
|
+
const html = renderPage(withApproval(), "approvals");
|
|
211
|
+
assert.match(html, /\[approval\] run-a · demo · improve \+3\/-1 · pass\/blocking 0/);
|
|
212
|
+
assert.match(html, /目标:\.pi\/skills\/demo\/SKILL\.md/);
|
|
213
|
+
assert.match(html, /提案:improve \+3\/-1 行/);
|
|
214
|
+
assert.match(html, /评审:verdict=pass · blocking 0 条/);
|
|
215
|
+
assert.match(html, /runId:run-a/, "详情第一行是完整 runId");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("待审批页:注入回调 + csrf 时渲染批准与拒绝表单,csrf 与 runId 都是 hidden", () => {
|
|
219
|
+
const html = renderPage(withApproval(), "approvals", { canWrite: true, csrf: "tok-123" });
|
|
220
|
+
assert.match(html, /<form method="post" action="\/approve">/);
|
|
221
|
+
assert.match(html, /<form method="post" action="\/reject">/);
|
|
222
|
+
assert.equal(html.match(/name="csrf" value="tok-123"/g)?.length, 2, "两个表单各带一份 csrf");
|
|
223
|
+
assert.equal(html.match(/name="runId" value="run-a"/g)?.length, 2, "两个表单各带一份 runId");
|
|
224
|
+
assert.match(html, />批准</);
|
|
225
|
+
assert.match(html, />拒绝</);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("待审批页:没注入回调(canWrite 缺省)不渲染按钮,页脚写「本实例只读」", () => {
|
|
229
|
+
const html = renderPage(withApproval(), "approvals");
|
|
230
|
+
assert.equal(html.includes("<form"), false, "只读实例不能出现任何表单");
|
|
231
|
+
assert.equal(html.includes("/approve"), false, "连 action 都不该出现");
|
|
232
|
+
assert.match(html, /本实例只读/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("待审批页:显式 canWrite 但没有 csrf 也不渲染按钮(否则点了必 403)", () => {
|
|
236
|
+
const html = renderPage(withApproval(), "approvals", { canWrite: true });
|
|
237
|
+
assert.equal(html.includes("<form"), false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("待审批页脚:批准走同一套五道闸,拒绝不动任何技能文件", () => {
|
|
241
|
+
const html = renderPage(withApproval(), "approvals", { canWrite: true, csrf: "t" });
|
|
242
|
+
assert.match(html, /点「批准」= 在 TUI 敲/);
|
|
243
|
+
assert.match(html, /走同一套五道闸/);
|
|
244
|
+
assert.match(html, /「拒绝」只改队列状态,不动任何技能文件/);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("待处理页脚:保留只读口径,并指向待审批页", () => {
|
|
248
|
+
const html = renderPage(emptyState(), "pending");
|
|
249
|
+
assert.match(html, /只读面板/);
|
|
250
|
+
assert.match(html, /去<a href="\/\?view=approvals">待审批页<\/a>直接批/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("动作结果横幅:msg 与 err 都渲染且转义", () => {
|
|
254
|
+
const ok = renderPage(emptyState(), "approvals", { notice: { kind: "msg", text: "已采纳 run-a" } });
|
|
255
|
+
assert.match(ok, /<p class="notice">已采纳 run-a<\/p>/);
|
|
256
|
+
const bad = renderPage(emptyState(), "approvals", { notice: { kind: "err", text: "<script>x</script>" } });
|
|
257
|
+
assert.match(bad, /<p class="notice err"><script>x<\/script><\/p>/);
|
|
258
|
+
assert.equal(/<script/i.test(bad), false);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("待审批页不做 5 秒自刷新:刷新会打断正在点的按钮", () => {
|
|
262
|
+
assert.equal(renderPage(emptyState(), "approvals").includes('http-equiv="refresh"'), false);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("控制条:采集与复盘是两个独立开关,各自 post 到 /toggle?field=", () => {
|
|
266
|
+
const html = renderPage(emptyState(), "pending", {
|
|
267
|
+
controls: { telemetry: true, enabled: false, lastActivityAt: null },
|
|
268
|
+
csrf: "tok",
|
|
269
|
+
canWrite: true,
|
|
270
|
+
});
|
|
271
|
+
assert.match(html, /采集遥测:开/);
|
|
272
|
+
assert.match(html, /自动复盘:关/);
|
|
273
|
+
// 两个按钮各自打自己的字段:一个按钮同时翻两个,就回到了「捆在一起」的老问题
|
|
274
|
+
assert.equal(html.includes('action="/toggle?field=telemetry"'), true);
|
|
275
|
+
assert.equal(html.includes('action="/toggle?field=enabled"'), true);
|
|
276
|
+
assert.match(html, /只写本地日志,不花 token/);
|
|
277
|
+
assert.match(html, /会派生子进程,花 token/);
|
|
278
|
+
assert.match(html, /关闭采集遥测/, "按钮文案是动作(当前开 → 关闭),不是状态的复读");
|
|
279
|
+
assert.match(html, /开启自动复盘/);
|
|
280
|
+
assert.equal(html.includes("复盘在跑但采集关着"), false, "两个状态正常时不带警告");
|
|
281
|
+
// 只读(没 csrf)时两个开关只显示状态,不渲染表单
|
|
282
|
+
const readonly = renderPage(emptyState(), "pending", { controls: { telemetry: false, enabled: false, lastActivityAt: null } });
|
|
283
|
+
assert.match(readonly, /采集遥测:关/);
|
|
284
|
+
assert.equal(readonly.includes("/toggle?field="), false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("控制条:只开复盘不采集时给一行警告,两个都开后警告消失", () => {
|
|
288
|
+
const warn = renderPage(emptyState(), "pending", {
|
|
289
|
+
controls: { telemetry: false, enabled: true, lastActivityAt: null },
|
|
290
|
+
csrf: "tok",
|
|
291
|
+
canWrite: true,
|
|
292
|
+
});
|
|
293
|
+
assert.match(warn, /<p class="warn">⚠ 复盘在跑但采集关着:归因只能靠原始会话,加载率不可得<\/p>/);
|
|
294
|
+
const ok = renderPage(emptyState(), "pending", {
|
|
295
|
+
controls: { telemetry: true, enabled: true, lastActivityAt: null },
|
|
296
|
+
csrf: "tok",
|
|
297
|
+
canWrite: true,
|
|
298
|
+
});
|
|
299
|
+
assert.equal(ok.includes("复盘在跑但采集关着"), false);
|
|
300
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run 详情页的数据读取:六块证据各自退化、runId 越界一律 404、/diff 只回 text/plain。
|
|
3
|
+
* 全部喂临时目录 + 假 res 对象,不起子进程、不占端口。
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import type { ServerResponse } from "node:http";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import { handleDiff, readRunDetail } from "../lib/web/run-detail.ts";
|
|
12
|
+
|
|
13
|
+
const NOW = Date.UTC(2026, 10, 8, 12);
|
|
14
|
+
const RUN_ID = "354df7db-1794133942";
|
|
15
|
+
|
|
16
|
+
const DIFF_TEXT = [
|
|
17
|
+
"--- a/.pi/skills/demo/SKILL.md",
|
|
18
|
+
"+++ b/.pi/skills/demo/SKILL.md",
|
|
19
|
+
"@@ -1,2 +1,2 @@",
|
|
20
|
+
" ---",
|
|
21
|
+
"-description: 旧",
|
|
22
|
+
"+description: 新",
|
|
23
|
+
].join("\n") + "\n";
|
|
24
|
+
|
|
25
|
+
function workspace(): { evolutionDir: string; runDir: string; cwd: string } {
|
|
26
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-rundetail-"));
|
|
27
|
+
const evolutionDir = path.join(cwd, ".pi", "evolution");
|
|
28
|
+
const runDir = path.join(evolutionDir, "runs", RUN_ID);
|
|
29
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
30
|
+
return { evolutionDir, runDir, cwd };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeJson(file: string, value: unknown): void {
|
|
34
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
35
|
+
fs.writeFileSync(file, JSON.stringify(value), "utf8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeText(file: string, text: string): void {
|
|
39
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
40
|
+
fs.writeFileSync(file, text, "utf8");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 造一个「证据齐全」的 run:run.json + diff + candidate + 评审 + 决策 + 五闸 + 发现 + child.log */
|
|
44
|
+
function fullRun(): { evolutionDir: string; runDir: string } {
|
|
45
|
+
const { evolutionDir, runDir } = workspace();
|
|
46
|
+
writeJson(path.join(runDir, "run.json"), {
|
|
47
|
+
runId: RUN_ID,
|
|
48
|
+
status: "rejected",
|
|
49
|
+
trigger: "auto",
|
|
50
|
+
dispatchedAt: NOW,
|
|
51
|
+
pid: 65757,
|
|
52
|
+
sessionId: "s",
|
|
53
|
+
proposal: { type: "improve", skill: "demo", status: "ok", added: 1, removed: 1, file: "02-proposal.diff" },
|
|
54
|
+
});
|
|
55
|
+
writeText(path.join(runDir, "02-proposal.diff"), DIFF_TEXT);
|
|
56
|
+
writeText(path.join(runDir, "child.log"), "启动复盘子进程\nmcp timeout\n");
|
|
57
|
+
writeText(path.join(runDir, "candidate", "demo", "SKILL.md"), "---\nname: demo\n---\n# 改后\n");
|
|
58
|
+
writeJson(path.join(runDir, "03-review.json"), {
|
|
59
|
+
status: "rejected",
|
|
60
|
+
verdict: "revise",
|
|
61
|
+
action: "forbidden",
|
|
62
|
+
reviewerModel: "deepseek/deepseek-flash",
|
|
63
|
+
reviewedAt: NOW + 5_000,
|
|
64
|
+
notes: "证据不足",
|
|
65
|
+
blocking: [
|
|
66
|
+
{
|
|
67
|
+
issue: "描述承诺大于正文能力",
|
|
68
|
+
evidence: { file: ".pi/skills/demo/SKILL.md", line: 3, quote: "含 <script> 的引用" },
|
|
69
|
+
},
|
|
70
|
+
{ issue: "缺少证据", evidence: {} },
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
writeJson(path.join(runDir, "04-decision.json"), { status: "rejected", approver: "auto", at: NOW + 6_000 });
|
|
74
|
+
writeJson(path.join(runDir, "06-verify.json"), {
|
|
75
|
+
runId: RUN_ID,
|
|
76
|
+
status: "applied",
|
|
77
|
+
at: NOW + 7_000,
|
|
78
|
+
ms: 12,
|
|
79
|
+
wrote: [],
|
|
80
|
+
gates: [
|
|
81
|
+
{ gate: "gate0", name: "授权核对", ok: true, ms: 1 },
|
|
82
|
+
{ gate: "gate3", name: "复验", ok: false, ms: 2, detail: "复验失败" },
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
writeJson(path.join(runDir, "01-findings.json"), {
|
|
86
|
+
skills: [
|
|
87
|
+
{ name: "demo", advertised: true, loaded: false, whyNotLoaded: "G", confidence: "high", evidence: [{ line: 578 }, { line: 622 }] },
|
|
88
|
+
{ name: "other", advertised: true, loaded: true, confidence: "medium" },
|
|
89
|
+
],
|
|
90
|
+
notes: "只差两处",
|
|
91
|
+
});
|
|
92
|
+
return { evolutionDir, runDir };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface FakeResponse {
|
|
96
|
+
status: number;
|
|
97
|
+
headers: Record<string, string>;
|
|
98
|
+
body: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fakeResponse(): { res: ServerResponse; out: FakeResponse } {
|
|
102
|
+
const out: FakeResponse = { status: 0, headers: {}, body: "" };
|
|
103
|
+
const res = {
|
|
104
|
+
writeHead(status: number, headers: Record<string, string>) {
|
|
105
|
+
out.status = status;
|
|
106
|
+
out.headers = headers;
|
|
107
|
+
return res;
|
|
108
|
+
},
|
|
109
|
+
end(text: string) {
|
|
110
|
+
out.body = text;
|
|
111
|
+
return res;
|
|
112
|
+
},
|
|
113
|
+
} as unknown as ServerResponse;
|
|
114
|
+
return { res, out };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
test("readRunDetail:六块证据(概要 / diff / 候选全文 / 评审 / 五闸 / 发现)一次读全", () => {
|
|
118
|
+
const { evolutionDir } = fullRun();
|
|
119
|
+
const detail = readRunDetail(evolutionDir, RUN_ID)!;
|
|
120
|
+
assert.equal(detail.runId, RUN_ID);
|
|
121
|
+
assert.equal(detail.status, "rejected");
|
|
122
|
+
assert.equal(detail.trigger, "auto");
|
|
123
|
+
assert.equal(detail.skill, "demo");
|
|
124
|
+
assert.equal(detail.pid, 65757);
|
|
125
|
+
assert.equal(detail.startedAt, NOW);
|
|
126
|
+
assert.equal(detail.endedAt, NOW + 7_000, "结束时间取最后一个产出时刻");
|
|
127
|
+
assert.equal(detail.hasChildLog, true);
|
|
128
|
+
assert.equal(detail.diff, DIFF_TEXT);
|
|
129
|
+
assert.equal(detail.candidateSkill, "demo");
|
|
130
|
+
assert.match(detail.candidateText ?? "", /# 改后/);
|
|
131
|
+
assert.deepEqual(detail.review, {
|
|
132
|
+
status: "rejected",
|
|
133
|
+
verdict: "revise",
|
|
134
|
+
action: "forbidden",
|
|
135
|
+
reviewerModel: "deepseek/deepseek-flash",
|
|
136
|
+
});
|
|
137
|
+
assert.equal(detail.blocking.length, 2);
|
|
138
|
+
assert.equal(detail.blocking[0]!.line, 3, "证据行号要能直接跳过去");
|
|
139
|
+
assert.match(detail.blocking[1]!.issue, /缺少证据/);
|
|
140
|
+
assert.equal(detail.blocking[1]!.file, "", "缺 evidence 的文件位置不能编一个");
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
detail.gates.map((gate) => `${gate.gate}:${gate.ok}`),
|
|
143
|
+
["gate0:true", "gate3:false"],
|
|
144
|
+
);
|
|
145
|
+
assert.equal(detail.gates[1]!.detail, "复验失败");
|
|
146
|
+
assert.equal(detail.verifyStatus, "applied");
|
|
147
|
+
assert.equal(detail.findingsTotal, 2);
|
|
148
|
+
assert.deepEqual(
|
|
149
|
+
detail.findings[0],
|
|
150
|
+
// F / G 是复盘 agent 自己判的码,DISPOSITIONS 里没有(那里只有 A-E + semantic),原样显示
|
|
151
|
+
{ skill: "demo", reason: "G", confidence: "high", lines: [578, 622] },
|
|
152
|
+
);
|
|
153
|
+
assert.equal(detail.findings[1]!.reason, "已加载");
|
|
154
|
+
assert.equal(detail.notes, "只差两处");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("readRunDetail:runId 不合法(../、绝对路径、子路径)一律 null,不读盘越界", () => {
|
|
158
|
+
const { evolutionDir } = fullRun();
|
|
159
|
+
for (const runId of ["../x", "../../etc", "/etc/passwd", "a/b", "", "."]) {
|
|
160
|
+
assert.equal(readRunDetail(evolutionDir, runId), null, `runId=${JSON.stringify(runId)} 必须被挡住`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("readRunDetail:目录不存在 → null(不抛异常)", () => {
|
|
165
|
+
const { evolutionDir } = fullRun();
|
|
166
|
+
assert.equal(readRunDetail(evolutionDir, "00000000-0"), null);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("readRunDetail:只剩 run.json 时六块各自空着,不是抛错也不是编数字", () => {
|
|
170
|
+
const { evolutionDir, runDir } = workspace();
|
|
171
|
+
writeJson(path.join(runDir, "run.json"), { runId: RUN_ID, status: "dispatched", trigger: "manual", dispatchedAt: NOW });
|
|
172
|
+
const detail = readRunDetail(evolutionDir, RUN_ID)!;
|
|
173
|
+
assert.equal(detail.diff, null);
|
|
174
|
+
assert.equal(detail.review, null);
|
|
175
|
+
assert.deepEqual(detail.blocking, []);
|
|
176
|
+
assert.deepEqual(detail.gates, []);
|
|
177
|
+
assert.equal(detail.verifyStatus, null);
|
|
178
|
+
assert.equal(detail.findingsTotal, 0);
|
|
179
|
+
assert.equal(detail.hasChildLog, false);
|
|
180
|
+
assert.equal(detail.candidateText, null);
|
|
181
|
+
assert.equal(detail.endedAt, null);
|
|
182
|
+
assert.equal(detail.pid, null);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("readRunDetail:提案技能名含 ../ 时不读 candidate(拼路径越界)", () => {
|
|
186
|
+
const { evolutionDir, runDir } = workspace();
|
|
187
|
+
writeJson(path.join(runDir, "run.json"), {
|
|
188
|
+
runId: RUN_ID,
|
|
189
|
+
status: "reviewed",
|
|
190
|
+
trigger: "auto",
|
|
191
|
+
dispatchedAt: NOW,
|
|
192
|
+
proposal: { type: "improve", skill: "../../secret" },
|
|
193
|
+
});
|
|
194
|
+
writeText(path.join(evolutionDir, "secret", "SKILL.md"), "SECRET\n");
|
|
195
|
+
const detail = readRunDetail(evolutionDir, RUN_ID)!;
|
|
196
|
+
assert.equal(detail.skill, "../../secret", "展示原值即可,但不去读那个文件");
|
|
197
|
+
assert.equal(detail.candidateText, null);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("readRunDetail:发现超过 20 条只收 20 条,总数照实报", () => {
|
|
201
|
+
const { evolutionDir, runDir } = workspace();
|
|
202
|
+
writeJson(path.join(runDir, "run.json"), { runId: RUN_ID, status: "analyzed", trigger: "auto", dispatchedAt: NOW });
|
|
203
|
+
const skills = Array.from({ length: 25 }, (_unused, index) => ({
|
|
204
|
+
name: `skill-${index + 1}`,
|
|
205
|
+
advertised: true,
|
|
206
|
+
loaded: false,
|
|
207
|
+
whyNotLoaded: "G",
|
|
208
|
+
confidence: "low",
|
|
209
|
+
evidence: [{ line: index + 1 }],
|
|
210
|
+
}));
|
|
211
|
+
writeJson(path.join(runDir, "01-findings.json"), { skills, notes: [] });
|
|
212
|
+
const detail = readRunDetail(evolutionDir, RUN_ID)!;
|
|
213
|
+
assert.equal(detail.findings.length, 20);
|
|
214
|
+
assert.equal(detail.findingsTotal, 25);
|
|
215
|
+
assert.equal(detail.findings[19]!.skill, "skill-20");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("readRunDetail:run.json 没写 proposal 时用 02-proposal.json 的技能名(真实 run 就是这种)", () => {
|
|
219
|
+
const { evolutionDir } = workspace();
|
|
220
|
+
const runDir = path.join(evolutionDir, "runs", "run-real");
|
|
221
|
+
fs.mkdirSync(path.join(runDir, "candidate", "demo"), { recursive: true });
|
|
222
|
+
fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify({ runId: "run-real", status: "rejected" }), "utf8");
|
|
223
|
+
fs.writeFileSync(
|
|
224
|
+
path.join(runDir, "02-proposal.json"),
|
|
225
|
+
JSON.stringify({ type: "improve", skill: "demo", rationale: "", evidence: [], diffFile: "02-proposal.diff" }),
|
|
226
|
+
"utf8",
|
|
227
|
+
);
|
|
228
|
+
fs.writeFileSync(path.join(runDir, "candidate", "demo", "SKILL.md"), "改后全文\n", "utf8");
|
|
229
|
+
const detail = readRunDetail(evolutionDir, "run-real");
|
|
230
|
+
assert.equal(detail?.skill, "demo");
|
|
231
|
+
assert.equal(detail?.candidateSkill, "demo");
|
|
232
|
+
assert.equal(detail?.candidateText, "改后全文\n");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("handleDiff:命中时回 200 + text/plain + diff 原文", () => {
|
|
236
|
+
const { evolutionDir } = fullRun();
|
|
237
|
+
const { res, out } = fakeResponse();
|
|
238
|
+
handleDiff(evolutionDir, res, new URL(`http://x/diff?runId=${RUN_ID}`));
|
|
239
|
+
assert.equal(out.status, 200);
|
|
240
|
+
assert.equal(out.headers["content-type"], "text/plain; charset=utf-8");
|
|
241
|
+
assert.equal(out.body, DIFF_TEXT);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("handleDiff:没有 02-proposal.diff 与 runId 越界都是 404,文案说清缺什么", () => {
|
|
245
|
+
const { evolutionDir, runDir } = workspace();
|
|
246
|
+
writeJson(path.join(runDir, "run.json"), { runId: RUN_ID, status: "dispatched" });
|
|
247
|
+
const noDiff = fakeResponse();
|
|
248
|
+
handleDiff(evolutionDir, noDiff.res, new URL(`http://x/diff?runId=${RUN_ID}`));
|
|
249
|
+
assert.equal(noDiff.out.status, 404);
|
|
250
|
+
assert.match(noDiff.out.body, /404:这个 run 没写 02-proposal\.diff/);
|
|
251
|
+
|
|
252
|
+
const bad = fakeResponse();
|
|
253
|
+
handleDiff(evolutionDir, bad.res, new URL("http://x/diff?runId=../x"));
|
|
254
|
+
assert.equal(bad.out.status, 404);
|
|
255
|
+
assert.match(bad.out.body, /404:没有这个 run 的 diff/);
|
|
256
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 三个新地址的 HTTP 接线:`?view=skills`、`?view=run&runId=`、`/diff?runId=`。
|
|
3
|
+
* 用 port 0 由内核分配随机端口(与 web-server.test.ts 同一手法),不起任何子进程、不抢固定端口。
|
|
4
|
+
* 渲染与读盘的细节在各自单测里,这一层只验证「路由接对了、状态码与 content-type 对了」。
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import { startWebPanel, type WebPanel } from "../lib/web/server.ts";
|
|
12
|
+
|
|
13
|
+
const DIFF = [
|
|
14
|
+
"--- a/.pi/skills/demo/SKILL.md",
|
|
15
|
+
"+++ b/.pi/skills/demo/SKILL.md",
|
|
16
|
+
"@@ -1,1 +1,1 @@",
|
|
17
|
+
"-description: 旧",
|
|
18
|
+
"+description: 新",
|
|
19
|
+
].join("\n");
|
|
20
|
+
|
|
21
|
+
/** 工作区里放一个技能与一个带 diff 的 run,够三个地址都点得动 */
|
|
22
|
+
function workspace(): string {
|
|
23
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-views-"));
|
|
24
|
+
const evolutionDir = path.join(cwd, ".pi", "evolution");
|
|
25
|
+
const skillDir = path.join(cwd, ".pi", "skills", "demo");
|
|
26
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
27
|
+
fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: demo\n---\n# demo\n", "utf8");
|
|
28
|
+
const runDir = path.join(evolutionDir, "runs", "run-diff");
|
|
29
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
30
|
+
fs.writeFileSync(
|
|
31
|
+
path.join(runDir, "run.json"),
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
runId: "run-diff",
|
|
34
|
+
status: "reviewed",
|
|
35
|
+
trigger: "auto",
|
|
36
|
+
pid: 1234,
|
|
37
|
+
dispatchedAt: Date.UTC(2026, 10, 8, 10),
|
|
38
|
+
skills: ["demo"],
|
|
39
|
+
proposal: { type: "improve", skill: "demo", status: "ok", added: 1, removed: 1, file: "02-proposal.diff" },
|
|
40
|
+
}),
|
|
41
|
+
"utf8",
|
|
42
|
+
);
|
|
43
|
+
fs.writeFileSync(path.join(runDir, "02-proposal.diff"), DIFF, "utf8");
|
|
44
|
+
return evolutionDir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function open(evolutionDir: string): Promise<WebPanel> {
|
|
48
|
+
const result = await startWebPanel({ evolutionDir, port: 0 });
|
|
49
|
+
assert.equal(result.ok, true, `面板应该起来:${result.ok ? "" : result.message}`);
|
|
50
|
+
if (!result.ok) throw new Error("unreachable");
|
|
51
|
+
return result.panel;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("GET /?view=skills:技能管理页显式可访问,表格里有 .pi/skills 下的技能", async () => {
|
|
55
|
+
const panel = await open(workspace());
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch(`${panel.url}/?view=skills`);
|
|
58
|
+
assert.equal(response.status, 200);
|
|
59
|
+
assert.match(response.headers.get("content-type") ?? "", /text\/html/);
|
|
60
|
+
const html = await response.text();
|
|
61
|
+
assert.match(html, /<title>技能进化 · 技能管理<\/title>/);
|
|
62
|
+
assert.match(html, /<a href="\/\?view=skill&name=demo&days=30">demo<\/a>/);
|
|
63
|
+
} finally {
|
|
64
|
+
await panel.close();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("GET /?view=skill&name=:带名字进详情,不带名字回技能管理(不让 ?view=skill 空页)", async () => {
|
|
69
|
+
const panel = await open(workspace());
|
|
70
|
+
try {
|
|
71
|
+
const detail = await (await fetch(`${panel.url}/?view=skill&name=demo`)).text();
|
|
72
|
+
assert.match(detail, /<title>技能进化 · 技能 demo<\/title>/);
|
|
73
|
+
assert.match(detail, /<a href="\/\?view=run&runId=run-diff">run-diff<\/a>/);
|
|
74
|
+
const noName = await (await fetch(`${panel.url}/?view=skill`)).text();
|
|
75
|
+
assert.match(noName, /<title>技能进化 · 技能管理<\/title>/);
|
|
76
|
+
} finally {
|
|
77
|
+
await panel.close();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("GET /?view=run&runId=:真 run 出 diff 视图,不存在的 run 是 404 text/plain", async () => {
|
|
82
|
+
const panel = await open(workspace());
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch(`${panel.url}/?view=run&runId=run-diff`);
|
|
85
|
+
assert.equal(response.status, 200);
|
|
86
|
+
const html = await response.text();
|
|
87
|
+
assert.match(html, /<title>技能进化 · run run-diff<\/title>/);
|
|
88
|
+
assert.match(html, /<pre class="diff">/);
|
|
89
|
+
assert.match(html, /<span class="dl add">/);
|
|
90
|
+
for (const bad of ["run-nope", "../../etc/passwd", "a/b"]) {
|
|
91
|
+
const missing = await fetch(`${panel.url}/?view=run&runId=${encodeURIComponent(bad)}`);
|
|
92
|
+
assert.equal(missing.status, 404, `runId=${bad} 必须 404`);
|
|
93
|
+
assert.match(missing.headers.get("content-type") ?? "", /text\/plain/);
|
|
94
|
+
assert.equal((await missing.text()).includes("description"), false, "404 页不能带出 diff 内容");
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
await panel.close();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("GET /diff:完整 diff 是 text/plain 原文;越界与没有 diff 的 run 都 404", async () => {
|
|
102
|
+
const panel = await open(workspace());
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetch(`${panel.url}/diff?runId=run-diff`);
|
|
105
|
+
assert.equal(response.status, 200);
|
|
106
|
+
assert.match(response.headers.get("content-type") ?? "", /text\/plain; charset=utf-8/);
|
|
107
|
+
assert.equal(await response.text(), DIFF);
|
|
108
|
+
assert.equal((await fetch(`${panel.url}/diff?runId=../queue.json`)).status, 404);
|
|
109
|
+
assert.equal((await fetch(`${panel.url}/diff?runId=run-nope`)).status, 404);
|
|
110
|
+
} finally {
|
|
111
|
+
await panel.close();
|
|
112
|
+
}
|
|
113
|
+
});
|