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,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 只读 Web 面板的数据聚合:空目录 / 坏 JSON / 三类待处理 / 已处理合并 / 报告字段 / 上限截断。
|
|
3
|
+
* 全部喂临时目录,不起 server、不碰真仓库。
|
|
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 test from "node:test";
|
|
10
|
+
import { collectControls } from "../lib/web/activity.ts";
|
|
11
|
+
import { collectWebState, DONE_LIMIT } from "../lib/web/data.ts";
|
|
12
|
+
import { APPLIED_LIMIT } from "../lib/web/applied.ts";
|
|
13
|
+
|
|
14
|
+
const NOW = Date.UTC(2026, 10, 8, 12);
|
|
15
|
+
|
|
16
|
+
function workspace(): string {
|
|
17
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-webdata-"));
|
|
18
|
+
const evolutionDir = path.join(cwd, ".pi", "evolution");
|
|
19
|
+
fs.mkdirSync(path.join(evolutionDir, "runs"), { recursive: true });
|
|
20
|
+
return evolutionDir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeRun(evolutionDir: string, runId: string, record: Record<string, unknown> = {}): string {
|
|
24
|
+
const runDir = path.join(evolutionDir, "runs", runId);
|
|
25
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
26
|
+
const base = { runId, status: "dispatched", dispatchedAt: NOW, sessionId: "s", trigger: "auto" };
|
|
27
|
+
fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify({ ...base, ...record }), "utf8");
|
|
28
|
+
return runDir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeQueue(evolutionDir: string, items: Record<string, unknown>[]): void {
|
|
32
|
+
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(evolutionDir, "queue.json"), JSON.stringify(items), "utf8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeJson(file: string, value: unknown): void {
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
38
|
+
fs.writeFileSync(file, JSON.stringify(value), "utf8");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function item(runId: string, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
|
42
|
+
return {
|
|
43
|
+
runId,
|
|
44
|
+
level: "approval",
|
|
45
|
+
skill: "demo",
|
|
46
|
+
targets: [".pi/skills/demo/SKILL.md"],
|
|
47
|
+
createdAt: NOW,
|
|
48
|
+
status: "pending",
|
|
49
|
+
...extra,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test("collectWebState:空目录返回五个空集合与全零 summary", () => {
|
|
54
|
+
const state = collectWebState({ evolutionDir: workspace() });
|
|
55
|
+
assert.deepEqual(state.pending, []);
|
|
56
|
+
assert.deepEqual(state.approvals, []);
|
|
57
|
+
assert.deepEqual(state.applied, []);
|
|
58
|
+
assert.deepEqual(state.done, []);
|
|
59
|
+
assert.deepEqual(state.reports, []);
|
|
60
|
+
assert.deepEqual(state.summary, { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("collectWebState:队列 / run.json / findings 全是坏 JSON 也不抛,一律当空", () => {
|
|
64
|
+
const evolutionDir = workspace();
|
|
65
|
+
const runDir = path.join(evolutionDir, "runs", "bad");
|
|
66
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
67
|
+
fs.writeFileSync(path.join(runDir, "run.json"), "{ 坏掉", "utf8");
|
|
68
|
+
fs.writeFileSync(path.join(runDir, "01-findings.json"), "{ 也坏", "utf8");
|
|
69
|
+
fs.writeFileSync(path.join(evolutionDir, "queue.json"), "not json", "utf8");
|
|
70
|
+
const state = collectWebState({ evolutionDir });
|
|
71
|
+
assert.deepEqual(state.pending, [], "坏队列不产生待处理");
|
|
72
|
+
assert.equal(state.reports.length, 0, "坏 findings 不进报告");
|
|
73
|
+
assert.equal(state.summary.appliedTotal, 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("pending:只收 approval 级 pending,detail 里带采纳命令", () => {
|
|
77
|
+
const evolutionDir = workspace();
|
|
78
|
+
writeRun(evolutionDir, "r1", { status: "reviewed" });
|
|
79
|
+
writeQueue(evolutionDir, [item("r1")]);
|
|
80
|
+
const state = collectWebState({ evolutionDir });
|
|
81
|
+
assert.equal(state.pending.length, 1);
|
|
82
|
+
assert.equal(state.pending[0]!.kind, "approval");
|
|
83
|
+
assert.equal(state.pending[0]!.skill, "demo");
|
|
84
|
+
assert.match(state.pending[0]!.detail, /\/skill-evolve approve r1/);
|
|
85
|
+
assert.equal(state.summary.pendingApprovals, 1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("pending:auto 级 pending 不算待批(不需要人),也不进待处理列表", () => {
|
|
89
|
+
const evolutionDir = workspace();
|
|
90
|
+
writeRun(evolutionDir, "r2", { status: "reviewed" });
|
|
91
|
+
writeQueue(evolutionDir, [item("r2", { level: "auto" })]);
|
|
92
|
+
const state = collectWebState({ evolutionDir });
|
|
93
|
+
assert.deepEqual(state.pending, []);
|
|
94
|
+
assert.equal(state.summary.pendingApprovals, 0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("pending:stale 的 run 单列一类,带原因与日志末行", () => {
|
|
98
|
+
const evolutionDir = workspace();
|
|
99
|
+
const runDir = writeRun(evolutionDir, "dead", { status: "stale", pid: 4242 });
|
|
100
|
+
writeJson(path.join(runDir, "run-error.json"), {
|
|
101
|
+
reason: "timeout",
|
|
102
|
+
at: NOW + 1_000,
|
|
103
|
+
childLogTail: ["启动复盘子进程", "mcp timeout"],
|
|
104
|
+
});
|
|
105
|
+
const state = collectWebState({ evolutionDir });
|
|
106
|
+
assert.equal(state.pending.length, 1);
|
|
107
|
+
assert.equal(state.pending[0]!.kind, "stale");
|
|
108
|
+
assert.match(state.pending[0]!.detail, /超时/);
|
|
109
|
+
assert.match(state.pending[0]!.detail, /mcp timeout/);
|
|
110
|
+
assert.equal(state.summary.stale, 1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("pending:reviewed 且未入队算待入队,已在队列里的(含 superseded)不重复出现", () => {
|
|
114
|
+
const evolutionDir = workspace();
|
|
115
|
+
writeRun(evolutionDir, "r4", { status: "reviewed" });
|
|
116
|
+
writeRun(evolutionDir, "r5", { status: "reviewed" });
|
|
117
|
+
writeQueue(evolutionDir, [item("r5", { status: "superseded" })]);
|
|
118
|
+
const state = collectWebState({ evolutionDir });
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
state.pending.map((entry) => `${entry.runId}:${entry.kind}`),
|
|
121
|
+
["r4:reviewed"],
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("done:只剩回滚 / 失败 / 否决 / 被顶替四种终态,applied 不再进 done", () => {
|
|
126
|
+
const evolutionDir = workspace();
|
|
127
|
+
writeRun(evolutionDir, "r5", { status: "applied" });
|
|
128
|
+
writeRun(evolutionDir, "r7", { status: "rejected", dispatchedAt: NOW + 4_000 });
|
|
129
|
+
writeQueue(evolutionDir, [
|
|
130
|
+
item("r5", { level: "auto", status: "applied", createdAt: NOW + 2_000, detail: "闸门全绿" }),
|
|
131
|
+
item("r6", { status: "superseded", createdAt: NOW + 3_000 }),
|
|
132
|
+
]);
|
|
133
|
+
const state = collectWebState({ evolutionDir });
|
|
134
|
+
assert.deepEqual(
|
|
135
|
+
state.done.map((entry) => `${entry.runId}:${entry.status}`),
|
|
136
|
+
["r7:rejected", "r6:superseded"],
|
|
137
|
+
"已采纳单独成 tab,混进已处理就分不清「留下了什么」和「撤了什么」",
|
|
138
|
+
);
|
|
139
|
+
assert.deepEqual(
|
|
140
|
+
state.applied.map((entry) => entry.runId),
|
|
141
|
+
["r5"],
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("applied:读 04-decision.at 当采纳时间,带五闸结论,按时间倒序", () => {
|
|
146
|
+
const evolutionDir = workspace();
|
|
147
|
+
const older = writeRun(evolutionDir, "a-old", { status: "applied" });
|
|
148
|
+
const newer = writeRun(evolutionDir, "a-new", { status: "applied" });
|
|
149
|
+
for (const [runId, runDir, at] of [
|
|
150
|
+
["a-old", older, NOW - 60_000],
|
|
151
|
+
["a-new", newer, NOW],
|
|
152
|
+
] as const) {
|
|
153
|
+
writeJson(path.join(runDir, "02-proposal.json"), {
|
|
154
|
+
type: "improve",
|
|
155
|
+
skill: "demo",
|
|
156
|
+
status: "ok",
|
|
157
|
+
added: 3,
|
|
158
|
+
removed: 1,
|
|
159
|
+
file: "02-proposal.diff",
|
|
160
|
+
});
|
|
161
|
+
// 增删行数是数 diff 来的,不是读 JSON 里的数字
|
|
162
|
+
fs.writeFileSync(
|
|
163
|
+
path.join(runDir, "02-proposal.diff"),
|
|
164
|
+
"--- a/.pi/skills/demo/SKILL.md\n+++ b/.pi/skills/demo/SKILL.md\n@@ -1,2 +1,4 @@\n ---\n-old\n+new1\n+new2\n+new3\n",
|
|
165
|
+
"utf8",
|
|
166
|
+
);
|
|
167
|
+
writeJson(path.join(runDir, "04-decision.json"), { status: "applied", approver: "auto", at, detail: "ok" });
|
|
168
|
+
writeJson(path.join(runDir, "06-verify.json"), {
|
|
169
|
+
runId,
|
|
170
|
+
status: "applied",
|
|
171
|
+
at,
|
|
172
|
+
ms: 3,
|
|
173
|
+
wrote: [],
|
|
174
|
+
gates: [
|
|
175
|
+
{ gate: "gate0", name: "授权核对", ok: true, ms: 1 },
|
|
176
|
+
{ gate: "gate3", name: "复验", ok: false, ms: 1, detail: "复验失败" },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
writeQueue(evolutionDir, [
|
|
181
|
+
item("a-old", { status: "applied", createdAt: NOW - 60_000 }),
|
|
182
|
+
item("a-new", { status: "applied", createdAt: NOW }),
|
|
183
|
+
// 还没采纳的不该出现在已采纳页
|
|
184
|
+
item("a-pending", { status: "pending" }),
|
|
185
|
+
]);
|
|
186
|
+
const state = collectWebState({ evolutionDir });
|
|
187
|
+
assert.deepEqual(
|
|
188
|
+
state.applied.map((entry) => entry.runId),
|
|
189
|
+
["a-new", "a-old"],
|
|
190
|
+
);
|
|
191
|
+
const [newest] = state.applied;
|
|
192
|
+
assert.equal(newest!.appliedAt, NOW, "采纳时间取 04-decision.at");
|
|
193
|
+
assert.equal(newest!.type, "improve");
|
|
194
|
+
assert.equal(newest!.added, 3);
|
|
195
|
+
assert.equal(newest!.removed, 1);
|
|
196
|
+
assert.equal(newest!.gatesOk, false, "有一闸失败就不是全 ok");
|
|
197
|
+
assert.equal(state.applied[1]!.gatesOk, false);
|
|
198
|
+
assert.deepEqual(newest!.gates.map((gate) => gate.gate), ["gate0", "gate3"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("reports:只收有 01-findings.json 的 run,前 5 条归因 + 提案/评审/决策/五闸", () => {
|
|
202
|
+
const evolutionDir = workspace();
|
|
203
|
+
const runDir = writeRun(evolutionDir, "r8", {
|
|
204
|
+
status: "applied",
|
|
205
|
+
trigger: "manual",
|
|
206
|
+
proposal: { type: "improve", skill: "demo", status: "ok", added: 5, removed: 2, file: "02-proposal.diff" },
|
|
207
|
+
review: { status: "reviewed", verdict: "pass", action: "auto", reviewerModel: "m", proposerModel: "m", sameModel: true },
|
|
208
|
+
});
|
|
209
|
+
writeJson(path.join(runDir, "01-findings.json"), {
|
|
210
|
+
skills: [
|
|
211
|
+
{ name: "demo", advertised: true, loaded: false, whyNotLoaded: "B", confidence: "high" },
|
|
212
|
+
{ name: "s2", advertised: true, loaded: true },
|
|
213
|
+
{ name: "s3", advertised: false, loaded: false },
|
|
214
|
+
{ name: "s4", advertised: true, loaded: false, whyNotLoaded: "G" },
|
|
215
|
+
{ name: "s5", advertised: true, loaded: false, whyNotLoaded: "F" },
|
|
216
|
+
{ name: "s6", advertised: true, loaded: false },
|
|
217
|
+
],
|
|
218
|
+
notes: [],
|
|
219
|
+
});
|
|
220
|
+
writeJson(path.join(runDir, "03-review.json"), { status: "reviewed", verdict: "pass", blocking: [{ issue: "x" }] });
|
|
221
|
+
writeJson(path.join(runDir, "04-decision.json"), { status: "applied", approver: "auto", at: NOW, detail: "ok" });
|
|
222
|
+
writeJson(path.join(runDir, "06-verify.json"), {
|
|
223
|
+
runId: "r8",
|
|
224
|
+
status: "applied",
|
|
225
|
+
at: NOW,
|
|
226
|
+
ms: 12,
|
|
227
|
+
wrote: [".pi/skills/demo/SKILL.md"],
|
|
228
|
+
gates: [
|
|
229
|
+
{ gate: "gate0", name: "授权核对", ok: true, ms: 1 },
|
|
230
|
+
{ gate: "gate1", name: "备份", ok: true, ms: 1 },
|
|
231
|
+
{ gate: "gate2", name: "原子写", ok: true, ms: 1 },
|
|
232
|
+
{ gate: "gate3", name: "诊断", ok: false, ms: 1 },
|
|
233
|
+
{ gate: "gate4", name: "生效边界", ok: true, ms: 0 },
|
|
234
|
+
],
|
|
235
|
+
});
|
|
236
|
+
// 另一个没有 findings 的 run:不产生报告
|
|
237
|
+
writeRun(evolutionDir, "r9", { status: "reviewed" });
|
|
238
|
+
|
|
239
|
+
const state = collectWebState({ evolutionDir });
|
|
240
|
+
assert.equal(state.reports.length, 1);
|
|
241
|
+
const report = state.reports[0]!;
|
|
242
|
+
assert.equal(report.runId, "r8");
|
|
243
|
+
assert.equal(report.trigger, "manual");
|
|
244
|
+
assert.equal(report.findings.total, 6);
|
|
245
|
+
assert.equal(report.findings.rows.length, 5, "只列前 5 条");
|
|
246
|
+
assert.deepEqual(report.findings.rows[0], { skill: "demo", kind: "未加载", reason: "B frontmatter 坏" });
|
|
247
|
+
assert.deepEqual(report.findings.rows[1], { skill: "s2", kind: "已加载", reason: "—" });
|
|
248
|
+
assert.equal(report.findings.rows[2]!.kind, "未曝光");
|
|
249
|
+
assert.equal(report.proposal?.added, 5);
|
|
250
|
+
assert.equal(report.proposal?.removed, 2);
|
|
251
|
+
assert.equal(report.review?.blocking, 1);
|
|
252
|
+
assert.equal(report.decision?.approver, "auto");
|
|
253
|
+
assert.equal(report.verify?.gates.length, 5);
|
|
254
|
+
assert.equal(report.verify?.gates[3]!.ok, false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("done:超过 50 条只留最近 50 条", () => {
|
|
258
|
+
const evolutionDir = workspace();
|
|
259
|
+
const items = Array.from({ length: DONE_LIMIT + 10 }, (_unused, index) =>
|
|
260
|
+
item(`run-${index}`, { status: "superseded", createdAt: NOW + index * 1_000 }),
|
|
261
|
+
);
|
|
262
|
+
writeQueue(evolutionDir, items);
|
|
263
|
+
const state = collectWebState({ evolutionDir });
|
|
264
|
+
assert.equal(state.done.length, DONE_LIMIT);
|
|
265
|
+
assert.equal(state.done[0]!.runId, `run-${DONE_LIMIT + 9}`, "第一条是最新的");
|
|
266
|
+
assert.equal(state.done.at(-1)!.runId, "run-10", "最早的十条被截掉");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("applied:超过 50 条只留最近 50 条(看不过来的历史不当成待处理)", () => {
|
|
270
|
+
const evolutionDir = workspace();
|
|
271
|
+
const items = Array.from({ length: APPLIED_LIMIT + 10 }, (_unused, index) =>
|
|
272
|
+
item(`run-${index}`, { status: "applied", createdAt: NOW + index * 1_000 }),
|
|
273
|
+
);
|
|
274
|
+
writeQueue(evolutionDir, items);
|
|
275
|
+
const state = collectWebState({ evolutionDir });
|
|
276
|
+
assert.equal(state.applied.length, APPLIED_LIMIT);
|
|
277
|
+
assert.equal(state.applied[0]!.runId, `run-${APPLIED_LIMIT + 9}`, "第一条是最新的");
|
|
278
|
+
assert.equal(state.applied.at(-1)!.runId, "run-10", "最早的十条被截掉");
|
|
279
|
+
assert.equal(state.applied[0]!.gatesOk, null, "没有 06-verify.json 就是「无记录」,不是「全 ok」");
|
|
280
|
+
assert.equal(state.applied[0]!.appliedAt, NOW + (APPLIED_LIMIT + 9) * 1_000, "没有 04-decision 就退回入队时刻");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("summary:已采纳数取队列 applied,回滚率 = 回滚 / (采纳 + 回滚)", () => {
|
|
284
|
+
const evolutionDir = workspace();
|
|
285
|
+
writeQueue(evolutionDir, [
|
|
286
|
+
item("a1", { status: "applied" }),
|
|
287
|
+
item("a2", { status: "applied" }),
|
|
288
|
+
item("a3", { status: "applied" }),
|
|
289
|
+
item("b1", { status: "rolled-back" }),
|
|
290
|
+
]);
|
|
291
|
+
const state = collectWebState({ evolutionDir });
|
|
292
|
+
assert.equal(state.summary.appliedTotal, 3);
|
|
293
|
+
assert.equal(state.summary.rollbackRate, 0.25);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
/** 造 child.log 并回拨 mtime:模拟「子进程上次输出在 N 秒前」 */
|
|
297
|
+
function writeChildLog(runDir: string, lines: string[], mtimeMs: number): void {
|
|
298
|
+
const file = path.join(runDir, "child.log");
|
|
299
|
+
fs.writeFileSync(file, `${lines.join("\n")}\n`, "utf8");
|
|
300
|
+
const at = new Date(mtimeMs);
|
|
301
|
+
fs.utimesSync(file, at, at);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
test("pending:正在跑的 run 排最前,阶段 / 进程 / 最近输出 / 看日志四行齐全", () => {
|
|
305
|
+
const evolutionDir = workspace();
|
|
306
|
+
writeRun(evolutionDir, "live", { status: "dispatched", pid: 1234, dispatchedAt: NOW - 90_000 });
|
|
307
|
+
writeQueue(evolutionDir, [item("q1")]);
|
|
308
|
+
const state = collectWebState({ evolutionDir, now: NOW, alive: (pid) => pid === 1234, maxRuntimeMs: 20 * 60_000 });
|
|
309
|
+
assert.equal(state.pending[0]!.kind, "running", "正在跑的排最前,不排在待批后面");
|
|
310
|
+
assert.equal(state.pending[0]!.runId, "live");
|
|
311
|
+
const detail = state.pending[0]!.detail;
|
|
312
|
+
assert.match(detail, /阶段:复盘产出中/);
|
|
313
|
+
assert.match(detail, /进程:pid 1234 · 已跑 1m 30s(上限 20 分钟)/);
|
|
314
|
+
assert.match(detail, /最近输出:(还没有输出)/, "child.log 还没生成时明确说没有输出");
|
|
315
|
+
assert.match(detail, /日志:tail -f \.pi\/evolution\/runs\/live\/child\.log/);
|
|
316
|
+
assert.equal(state.pending[1]!.kind, "approval", "待批排在运行中之后");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("pending:督导活着 → 阶段写评审中,child.log 的 mtime 与末行都渲染出来", () => {
|
|
320
|
+
const evolutionDir = workspace();
|
|
321
|
+
const runDir = writeRun(evolutionDir, "live", {
|
|
322
|
+
status: "dispatched",
|
|
323
|
+
pid: 1234,
|
|
324
|
+
supervisorPid: 5678,
|
|
325
|
+
dispatchedAt: NOW - 5_000,
|
|
326
|
+
});
|
|
327
|
+
writeChildLog(runDir, ["第一行", "最后一行"], NOW - 12_000);
|
|
328
|
+
const state = collectWebState({ evolutionDir, now: NOW, alive: (pid) => pid === 1234 || pid === 5678 });
|
|
329
|
+
const detail = state.pending[0]!.detail;
|
|
330
|
+
assert.match(detail, /阶段:评审中/, "督导在跑就是评审阶段");
|
|
331
|
+
assert.match(detail, /末行:最后一行/);
|
|
332
|
+
const seconds = Number(detail.match(/最近输出:(\d+)s 前/)![1]);
|
|
333
|
+
assert.ok(Math.abs(seconds - 12) <= 1, `mtime 距今应按秒渲染(实际 ${seconds}s)`);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("pending:pid 已死且没超时 → 不出现在待处理页(不当成在跑)", () => {
|
|
337
|
+
const evolutionDir = workspace();
|
|
338
|
+
writeRun(evolutionDir, "dead", { status: "dispatched", pid: 4321, dispatchedAt: NOW });
|
|
339
|
+
const state = collectWebState({ evolutionDir, now: NOW, alive: () => false, maxRuntimeMs: 20 * 60_000 });
|
|
340
|
+
assert.deepEqual(state.pending, []);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("reports:卡死且没有 findings 的 run 也进档案,标 stale 并写明原因与日志末行", () => {
|
|
344
|
+
const evolutionDir = workspace();
|
|
345
|
+
const runDir = writeRun(evolutionDir, "stuck", { status: "stale", pid: 999, dispatchedAt: NOW - 600_000 });
|
|
346
|
+
writeJson(path.join(runDir, "run-error.json"), {
|
|
347
|
+
reason: "child-exited",
|
|
348
|
+
at: NOW,
|
|
349
|
+
childLogTail: ["跑了一半", "Error: 模板缺失"],
|
|
350
|
+
});
|
|
351
|
+
// reviewed 且无 findings 的 run 仍然不进档案(不是「失败」,只是没产出)
|
|
352
|
+
writeRun(evolutionDir, "r9", { status: "reviewed" });
|
|
353
|
+
const state = collectWebState({ evolutionDir });
|
|
354
|
+
assert.equal(state.reports.length, 1, "每条 run 在档案里都要能找到一行,但不能把 reviewed 也算进来");
|
|
355
|
+
const report = state.reports[0]!;
|
|
356
|
+
assert.equal(report.runId, "stuck");
|
|
357
|
+
assert.equal(report.status, "stale");
|
|
358
|
+
assert.equal(report.findings.total, 0);
|
|
359
|
+
assert.match(report.failure ?? "", /原因:子进程已退出/);
|
|
360
|
+
assert.match(report.failure ?? "", /日志末行:Error: 模板缺失/);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("approvals:只收 pending + approval 级,auto 级与已处理的行都不出现", () => {
|
|
364
|
+
const evolutionDir = workspace();
|
|
365
|
+
writeQueue(evolutionDir, [
|
|
366
|
+
item("run-b", { level: "approval", createdAt: NOW + 2 }),
|
|
367
|
+
item("run-a", { level: "approval", createdAt: NOW + 1 }),
|
|
368
|
+
item("run-auto", { level: "auto", createdAt: NOW + 3 }),
|
|
369
|
+
item("run-applied", { createdAt: NOW + 4, status: "applied" }),
|
|
370
|
+
item("run-rejected", { createdAt: NOW + 5, status: "rejected" }),
|
|
371
|
+
]);
|
|
372
|
+
const state = collectWebState({ evolutionDir });
|
|
373
|
+
assert.deepEqual(
|
|
374
|
+
state.approvals.map((row) => row.runId),
|
|
375
|
+
["run-a", "run-b"],
|
|
376
|
+
"只留 approval 且 pending,按 createdAt 升序(先来的先批)",
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("approvals:带 proposal 增删行与 review 的 verdict/blocking 条数", () => {
|
|
381
|
+
const evolutionDir = workspace();
|
|
382
|
+
const runDir = writeRun(evolutionDir, "run-full");
|
|
383
|
+
// 增删行数是数 02-proposal.diff 的 +/- 行,不是读 json 字段
|
|
384
|
+
writeJson(path.join(runDir, "02-proposal.json"), { type: "improve", skill: "demo" });
|
|
385
|
+
fs.writeFileSync(
|
|
386
|
+
path.join(runDir, "02-proposal.diff"),
|
|
387
|
+
"--- a/.pi/skills/demo/SKILL.md\n+++ b/.pi/skills/demo/SKILL.md\n@@ -1,1 +1,2 @@\n-旧\n+新\n+又一行\n",
|
|
388
|
+
"utf8",
|
|
389
|
+
);
|
|
390
|
+
writeJson(path.join(runDir, "03-review.json"), {
|
|
391
|
+
verdict: "pass",
|
|
392
|
+
blocking: [{ id: "P1", text: "缺成功征兆" }, { id: "P2", text: "步骤不完整" }],
|
|
393
|
+
});
|
|
394
|
+
writeQueue(evolutionDir, [item("run-full", { detail: "正文改动,需人批" })]);
|
|
395
|
+
const row = collectWebState({ evolutionDir }).approvals[0]!;
|
|
396
|
+
assert.equal(row.skill, "demo");
|
|
397
|
+
assert.deepEqual(row.targets, [".pi/skills/demo/SKILL.md"]);
|
|
398
|
+
assert.equal(row.proposal?.type, "improve");
|
|
399
|
+
assert.equal(row.proposal?.added, 2);
|
|
400
|
+
assert.equal(row.proposal?.removed, 1);
|
|
401
|
+
assert.deepEqual(row.review, { verdict: "pass", blocking: 2 });
|
|
402
|
+
assert.equal(row.detail, "正文改动,需人批");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("approvals:缺 proposal / review 文件时给 null,不抛也不编造数字", () => {
|
|
406
|
+
const evolutionDir = workspace();
|
|
407
|
+
writeQueue(evolutionDir, [item("run-bare")]);
|
|
408
|
+
const row = collectWebState({ evolutionDir }).approvals[0]!;
|
|
409
|
+
assert.equal(row.proposal, null);
|
|
410
|
+
assert.equal(row.review, null);
|
|
411
|
+
assert.equal(row.createdAt, NOW);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("collectControls:两个开关各读各的字段(采集关、复盘开这个中间态要能显示出来)", () => {
|
|
415
|
+
const evolutionDir = workspace();
|
|
416
|
+
fs.writeFileSync(path.join(evolutionDir, "config.json"), JSON.stringify({ enabled: true, telemetry: false }), "utf8");
|
|
417
|
+
const controls = collectControls(evolutionDir);
|
|
418
|
+
assert.deepEqual([controls.telemetry, controls.enabled], [false, true]);
|
|
419
|
+
assert.equal(controls.lastActivityAt, null, "没有任何遥测 / run 时最后活动为 null,不编造时间");
|
|
420
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一 diff 的解析与渲染:行号推进、着色 class、转义、400 行截断。
|
|
3
|
+
* 全部是字符串进出,不读盘、不起子进程、不占端口。
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import {
|
|
8
|
+
DIFF_PREVIEW_LINES,
|
|
9
|
+
parseDiffLines,
|
|
10
|
+
renderDiff,
|
|
11
|
+
renderDiffLines,
|
|
12
|
+
} from "../lib/web/diff-lines.ts";
|
|
13
|
+
|
|
14
|
+
/** 一份最小的单 hunk diff:文件头 2 行 + hunk 头 1 行 + 上下文/删/增/增/上下文 */
|
|
15
|
+
const SAMPLE = [
|
|
16
|
+
"--- a/.pi/skills/demo/SKILL.md",
|
|
17
|
+
"+++ b/.pi/skills/demo/SKILL.md",
|
|
18
|
+
"@@ -1,3 +1,4 @@",
|
|
19
|
+
" ---",
|
|
20
|
+
"-old line",
|
|
21
|
+
"+new line",
|
|
22
|
+
"+another new line",
|
|
23
|
+
" tail",
|
|
24
|
+
"",
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
27
|
+
test("parseDiffLines:文件头 / hunk / 增 / 删 / 上下文各自归类", () => {
|
|
28
|
+
const lines = parseDiffLines(SAMPLE);
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
lines.map((line) => line.kind),
|
|
31
|
+
["file", "file", "hunk", "ctx", "del", "add", "add", "ctx"],
|
|
32
|
+
);
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
lines.slice(0, 3).map((line) => [line.oldLine, line.newLine]),
|
|
35
|
+
[
|
|
36
|
+
[null, null],
|
|
37
|
+
[null, null],
|
|
38
|
+
[null, null],
|
|
39
|
+
],
|
|
40
|
+
"文件头与 hunk 头不给行号",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("parseDiffLines:@@ -1,3 +1,4 @@ 之后删行只推旧号、增行只推新号", () => {
|
|
45
|
+
const lines = parseDiffLines(SAMPLE);
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
lines.slice(3).map((line) => [line.text, line.oldLine, line.newLine]),
|
|
48
|
+
[
|
|
49
|
+
[" ---", 1, 1],
|
|
50
|
+
["-old line", 2, null],
|
|
51
|
+
["+new line", null, 2],
|
|
52
|
+
["+another new line", null, 3],
|
|
53
|
+
[" tail", 3, 4],
|
|
54
|
+
],
|
|
55
|
+
"算错这里,用户就会对着行号去改文件、改错行",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("parseDiffLines:多个 hunk 各自从自己的起点重新计数", () => {
|
|
60
|
+
const text = [
|
|
61
|
+
"--- a/x",
|
|
62
|
+
"+++ b/x",
|
|
63
|
+
"@@ -1,1 +1,1 @@",
|
|
64
|
+
"-a",
|
|
65
|
+
"+b",
|
|
66
|
+
"@@ -10,1 +20,2 @@",
|
|
67
|
+
" keep",
|
|
68
|
+
"+added",
|
|
69
|
+
].join("\n");
|
|
70
|
+
const lines = parseDiffLines(text);
|
|
71
|
+
const second = lines.slice(lines.findIndex((line) => line.text.startsWith("@@ -10")));
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
second.map((line) => [line.text, line.oldLine, line.newLine]),
|
|
74
|
+
[
|
|
75
|
+
["@@ -10,1 +20,2 @@", null, null],
|
|
76
|
+
[" keep", 10, 20],
|
|
77
|
+
["+added", null, 21],
|
|
78
|
+
],
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("parseDiffLines:hunk 外的行(diff --git / index)与 \\ No newline 都不占行号", () => {
|
|
83
|
+
const text = [
|
|
84
|
+
"diff --git a/x b/x",
|
|
85
|
+
"index 123..456 100644",
|
|
86
|
+
"--- a/x",
|
|
87
|
+
"+++ b/x",
|
|
88
|
+
"@@ -1,1 +1,1 @@",
|
|
89
|
+
"-a",
|
|
90
|
+
"\",
|
|
91
|
+
"+b",
|
|
92
|
+
].join("\n");
|
|
93
|
+
const lines = parseDiffLines(text);
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
lines.map((line) => [line.kind, line.oldLine, line.newLine]),
|
|
96
|
+
[
|
|
97
|
+
["meta", null, null],
|
|
98
|
+
["meta", null, null],
|
|
99
|
+
["file", null, null],
|
|
100
|
+
["file", null, null],
|
|
101
|
+
["hunk", null, null],
|
|
102
|
+
["del", 1, null],
|
|
103
|
+
["meta", null, null],
|
|
104
|
+
["add", null, 1],
|
|
105
|
+
],
|
|
106
|
+
"「\\ No newline」不是一行内容,推进它会把它后面的行号整体错一位",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("parseDiffLines:末尾换行不额外造出一个空行", () => {
|
|
111
|
+
assert.equal(parseDiffLines(SAMPLE).length, 8);
|
|
112
|
+
assert.equal(parseDiffLines("one\n").length, 1, "只有一个换行不是两行");
|
|
113
|
+
assert.equal(parseDiffLines("one\n\n").length, 2, "真正的空行仍然保留");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("renderDiffLines:+ / - / @@ 三类 class 与两列行号都渲染出来", () => {
|
|
117
|
+
const html = renderDiffLines(parseDiffLines(SAMPLE));
|
|
118
|
+
assert.match(html, /^<pre class="diff">/);
|
|
119
|
+
assert.match(html, /<span class="dl file"><span class="ln old"> {1}<\/span><span class="ln new"> {1}<\/span><span class="tx">--- a\/\.pi\/skills\/demo\/SKILL\.md<\/span><\/span>/);
|
|
120
|
+
assert.match(html, /<span class="dl hunk">/);
|
|
121
|
+
assert.match(html, /<span class="dl add"><span class="ln old"> {1}<\/span><span class="ln new">2<\/span>/);
|
|
122
|
+
assert.match(html, /<span class="dl del"><span class="ln old">2<\/span><span class="ln new"> {1}<\/span>/);
|
|
123
|
+
assert.match(html, /<span class="dl ctx"><span class="ln old">3<\/span><span class="ln new">4<\/span>/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("renderDiffLines:diff 里的 <script> 与 & 一律转义(这里最容易被当成可信内容)", () => {
|
|
127
|
+
const text = [
|
|
128
|
+
"--- a/x",
|
|
129
|
+
"+++ b/x",
|
|
130
|
+
"@@ -1,1 +1,1 @@",
|
|
131
|
+
"-<b>old</b>",
|
|
132
|
+
"+<script>alert('a&b')</script>",
|
|
133
|
+
].join("\n");
|
|
134
|
+
const html = renderDiffLines(parseDiffLines(text));
|
|
135
|
+
assert.equal(html.includes("<script>"), false, "页面里不许出现裸 <script>");
|
|
136
|
+
assert.equal(html.includes("<b>old</b>"), false);
|
|
137
|
+
assert.match(html, /<script>alert\('a&b'\)<\/script>/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("renderDiff:超过 400 行只渲前 400 行并标 truncated", () => {
|
|
141
|
+
const body = Array.from({ length: 500 }, (_unused, index) => ` line ${index + 1}`);
|
|
142
|
+
const text = ["--- a/x", "+++ b/x", "@@ -1,500 +1,500 @@", ...body].join("\n");
|
|
143
|
+
const view = renderDiff(text);
|
|
144
|
+
assert.equal(view.total, 503);
|
|
145
|
+
assert.equal(view.truncated, true);
|
|
146
|
+
assert.equal(view.html.match(/class="dl /g)?.length, DIFF_PREVIEW_LINES);
|
|
147
|
+
assert.equal(view.html.includes("line 401"), false, "第 401 行不渲染");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("renderDiff:不到 400 行不截断,也不加下载提示的依据(truncated=false)", () => {
|
|
151
|
+
const view = renderDiff(SAMPLE);
|
|
152
|
+
assert.equal(view.total, 8);
|
|
153
|
+
assert.equal(view.truncated, false);
|
|
154
|
+
assert.equal(view.html.match(/class="dl /g)?.length, 8);
|
|
155
|
+
});
|