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,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 列表筛选 + 分页:query 语义、三页各用哪个字段、翻页链接保留筛选参数。
|
|
3
|
+
* 纯函数测试,不读盘、不起服务。
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import type { ApprovalItem, DoneItem, PendingItem, RunReport, WebState } from "../lib/web/data.ts";
|
|
8
|
+
import {
|
|
9
|
+
NO_FILTER,
|
|
10
|
+
PAGE_SIZE,
|
|
11
|
+
buildViewPage,
|
|
12
|
+
isDefaultFilter,
|
|
13
|
+
pageHref,
|
|
14
|
+
paginate,
|
|
15
|
+
parseListQuery,
|
|
16
|
+
parseView,
|
|
17
|
+
rangeStart,
|
|
18
|
+
type ListFilter,
|
|
19
|
+
} from "../lib/web/filter.ts";
|
|
20
|
+
|
|
21
|
+
const NOW = Date.UTC(2026, 10, 8, 12);
|
|
22
|
+
const HOUR = 60 * 60 * 1000;
|
|
23
|
+
const DAY = 24 * HOUR;
|
|
24
|
+
|
|
25
|
+
function filter(overrides: Partial<ListFilter> = {}): ListFilter {
|
|
26
|
+
return { ...NO_FILTER, ...overrides };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pending(runId: string, kind: PendingItem["kind"], skill: string, at: number | null): PendingItem {
|
|
30
|
+
return { runId, kind, skill, at, detail: `${runId} 的详情` };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function done(runId: string, status: string, skill: string, at: number | null): DoneItem {
|
|
34
|
+
return { runId, status, source: "run", level: "auto", skill, at, detail: `${runId} 的详情` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function report(runId: string, status: string, skill: string | null, startedAt: number | null): RunReport {
|
|
38
|
+
return {
|
|
39
|
+
runId,
|
|
40
|
+
status,
|
|
41
|
+
trigger: "manual",
|
|
42
|
+
startedAt,
|
|
43
|
+
findings: { total: 0, rows: [] },
|
|
44
|
+
proposal: skill === null ? null : { type: "skill", skill, added: 1, removed: 0 },
|
|
45
|
+
review: null,
|
|
46
|
+
decision: null,
|
|
47
|
+
verify: null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function approval(runId: string, skill: string): ApprovalItem {
|
|
52
|
+
return { runId, skill, level: "approval", targets: [], createdAt: NOW, proposal: null, review: null, detail: "" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function state(parts: Partial<WebState> = {}): WebState {
|
|
56
|
+
return {
|
|
57
|
+
pending: [],
|
|
58
|
+
approvals: [],
|
|
59
|
+
applied: [],
|
|
60
|
+
done: [],
|
|
61
|
+
reports: [],
|
|
62
|
+
summary: { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 },
|
|
63
|
+
...parts,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test("parseListQuery:缺省 → 全部技能 / 全部状态 / 7d / 第 1 页", () => {
|
|
68
|
+
assert.deepEqual(parseListQuery(new URLSearchParams()), { skill: "", status: "", range: "7d", page: 1 });
|
|
69
|
+
assert.equal(parseListQuery(new URLSearchParams("skill=%20demo%20")).skill, "demo", "技能名去空格");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("parseListQuery:未知 range 与非法 page 一律回落默认(筛选不报错)", () => {
|
|
73
|
+
assert.equal(parseListQuery(new URLSearchParams("range=nope")).range, "7d");
|
|
74
|
+
assert.equal(parseListQuery(new URLSearchParams("range=all")).range, "all");
|
|
75
|
+
for (const page of ["0", "-3", "abc", ""]) {
|
|
76
|
+
assert.equal(parseListQuery(new URLSearchParams(`page=${page}`)).page, 1, `page=${page} 归 1`);
|
|
77
|
+
}
|
|
78
|
+
assert.equal(parseListQuery(new URLSearchParams("page=2")).page, 2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("paginate:25 条时 page=2 取第 21-25 条,越界夹到最后一页", () => {
|
|
82
|
+
const rows = Array.from({ length: 25 }, (_, index) => index + 1);
|
|
83
|
+
const second = paginate(rows, 2);
|
|
84
|
+
assert.equal(second.rows[0], 21);
|
|
85
|
+
assert.equal(second.rows.at(-1), 25);
|
|
86
|
+
assert.equal(second.page, 2);
|
|
87
|
+
assert.equal(second.pages, 2);
|
|
88
|
+
assert.equal(second.total, 25);
|
|
89
|
+
assert.equal(paginate(rows, 99).page, 2, "页码越界夹到最后一页而不是空列表");
|
|
90
|
+
assert.equal(paginate(rows, 0).page, 1);
|
|
91
|
+
const empty = paginate([], 1);
|
|
92
|
+
assert.deepEqual([empty.page, empty.pages, empty.total], [1, 1, 0], "空列表也要有 1 页,否则页脚消失");
|
|
93
|
+
assert.equal(PAGE_SIZE, 20);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("buildViewPage(pending):skill 精确匹配 + kind→status 映射 + range 过滤", () => {
|
|
97
|
+
const sample = state({
|
|
98
|
+
pending: [
|
|
99
|
+
pending("run-1", "stale", "demo", NOW - HOUR),
|
|
100
|
+
pending("run-2", "approval", "demo", NOW - 2 * HOUR),
|
|
101
|
+
pending("run-3", "stale", "demo2", NOW - HOUR),
|
|
102
|
+
pending("run-4", "stale", "demo", NOW - 8 * DAY),
|
|
103
|
+
pending("run-5", "stale", "demo", null),
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
const bySkill = buildViewPage(sample, "pending", filter({ skill: "demo", range: "all" }), NOW);
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
bySkill.rows.map((item) => item.runId),
|
|
109
|
+
["run-1", "run-2", "run-4", "run-5"],
|
|
110
|
+
"skill=demo 不能把 demo2 带进来",
|
|
111
|
+
);
|
|
112
|
+
const byStatus = buildViewPage(sample, "pending", filter({ status: "approval", range: "all" }), NOW);
|
|
113
|
+
assert.deepEqual(
|
|
114
|
+
byStatus.rows.map((item) => item.runId),
|
|
115
|
+
["run-2"],
|
|
116
|
+
"待批的 status 就是 approval",
|
|
117
|
+
);
|
|
118
|
+
const recent = buildViewPage(sample, "pending", filter({ range: "24h" }), NOW);
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
recent.rows.map((item) => item.runId),
|
|
121
|
+
["run-1", "run-2", "run-3"],
|
|
122
|
+
"at 为 null 的(run-5)不冒充「最近 24 小时」,8 天前的(run-4)也被挡掉",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("buildViewPage(done):status / skill / range 三个条件叠加", () => {
|
|
127
|
+
// 已采纳已经单独成 tab,所以 done 里只会有回滚 / 失败 / 否决 / 被顶替这四种终态
|
|
128
|
+
const sample = state({
|
|
129
|
+
done: [
|
|
130
|
+
done("run-a", "rolled-back", "demo", NOW - HOUR),
|
|
131
|
+
done("run-b", "rejected", "demo", NOW - HOUR),
|
|
132
|
+
done("run-c", "rolled-back", "other", NOW - HOUR),
|
|
133
|
+
done("run-d", "rolled-back", "demo", NOW - 10 * DAY),
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
const rolledBack = buildViewPage(sample, "done", filter({ status: "rolled-back", skill: "demo", range: "7d" }), NOW);
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
rolledBack.rows.map((item) => item.runId),
|
|
139
|
+
["run-a"],
|
|
140
|
+
);
|
|
141
|
+
const all = buildViewPage(sample, "done", filter(), NOW);
|
|
142
|
+
assert.equal(all.total, 4);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("buildViewPage(reports):技能名取自提案,时间用 startedAt", () => {
|
|
146
|
+
const sample = state({
|
|
147
|
+
reports: [
|
|
148
|
+
report("run-p", "analyzed", "demo", NOW - HOUR),
|
|
149
|
+
report("run-q", "failed", "other", NOW - HOUR),
|
|
150
|
+
report("run-r", "analyzed", null, NOW - HOUR),
|
|
151
|
+
report("run-s", "analyzed", "demo", NOW - 20 * DAY),
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
const demo = buildViewPage(sample, "reports", filter({ skill: "demo", range: "7d" }), NOW);
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
demo.rows.map((item) => item.runId),
|
|
157
|
+
["run-p"],
|
|
158
|
+
);
|
|
159
|
+
const noProposal = buildViewPage(sample, "reports", filter({ range: "all" }), NOW);
|
|
160
|
+
assert.equal(noProposal.total, 4, "没有提案的报告只在「全部技能」时命中");
|
|
161
|
+
const failed = buildViewPage(sample, "reports", filter({ status: "failed" }), NOW);
|
|
162
|
+
assert.deepEqual(
|
|
163
|
+
failed.rows.map((item) => item.runId),
|
|
164
|
+
["run-q"],
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("buildViewPage(approvals):不分页,rows 原样(待批本身很短)", () => {
|
|
169
|
+
const sample = state({ approvals: [approval("run-x", "demo"), approval("run-y", "other")] });
|
|
170
|
+
const page = buildViewPage(sample, "approvals", filter({ skill: "demo" }), NOW);
|
|
171
|
+
assert.equal(page.page, 1);
|
|
172
|
+
assert.equal(page.pages, 1);
|
|
173
|
+
assert.equal(page.total, 2, "待批页不受筛选影响(筛选表单只在三个列表页渲染)");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("buildViewPage(done):45 条时 page=2 拿到第 21-40 条", () => {
|
|
177
|
+
const rows = Array.from({ length: 45 }, (_, index) => done(`run-${index + 1}`, "failed", "demo", NOW - HOUR));
|
|
178
|
+
const page = buildViewPage(state({ done: rows }), "done", filter({ page: 2 }), NOW);
|
|
179
|
+
assert.equal(page.page, 2);
|
|
180
|
+
assert.equal(page.pages, 3);
|
|
181
|
+
assert.equal(page.total, 45);
|
|
182
|
+
assert.equal(page.rows.length, PAGE_SIZE);
|
|
183
|
+
assert.equal(page.rows[0]?.runId, "run-21");
|
|
184
|
+
assert.equal(page.rows.at(-1)?.runId, "run-40");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("pageHref:保留 skill / status / range,空的不写进链接", () => {
|
|
188
|
+
const href = pageHref("done", filter({ skill: "demo", status: "applied", range: "30d", page: 2 }), 3);
|
|
189
|
+
const url = new URL(`http://x${href}`);
|
|
190
|
+
assert.equal(url.searchParams.get("view"), "done");
|
|
191
|
+
assert.equal(url.searchParams.get("skill"), "demo");
|
|
192
|
+
assert.equal(url.searchParams.get("status"), "applied");
|
|
193
|
+
assert.equal(url.searchParams.get("range"), "30d");
|
|
194
|
+
assert.equal(url.searchParams.get("page"), "3");
|
|
195
|
+
const bare = new URL(`http://x${pageHref("pending", filter(), 2)}`);
|
|
196
|
+
assert.equal(bare.searchParams.get("skill"), null);
|
|
197
|
+
assert.equal(bare.searchParams.get("status"), null);
|
|
198
|
+
assert.equal(bare.searchParams.get("page"), "2");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("parseView 白名单 / isDefaultFilter / rangeStart", () => {
|
|
202
|
+
assert.equal(parseView("reports"), "reports");
|
|
203
|
+
assert.equal(parseView("done"), "done");
|
|
204
|
+
for (const bad of [null, "", "nope", "../x"]) assert.equal(parseView(bad), "pending", `parseView(${bad})`);
|
|
205
|
+
assert.equal(isDefaultFilter(filter({ range: "7d" })), true);
|
|
206
|
+
assert.equal(isDefaultFilter(filter({ range: "7d", page: 2 })), false);
|
|
207
|
+
assert.equal(isDefaultFilter(filter({ range: "7d", skill: "demo" })), false);
|
|
208
|
+
assert.equal(rangeStart("all", NOW), null);
|
|
209
|
+
assert.equal(rangeStart("24h", NOW), NOW - DAY);
|
|
210
|
+
assert.equal(rangeStart("7d", NOW), NOW - 7 * DAY);
|
|
211
|
+
assert.equal(rangeStart("30d", NOW), NOW - 30 * DAY);
|
|
212
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「面板记录」与「端口占用提示」:真监听 127.0.0.1(port=0 取随机端口),不起 node/pi 子进程。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要单一个文件:这组用例验的是 `locks/web.json` 的写入/清除与占用文案判读,
|
|
5
|
+
* 与 HTTP 路由白名单无关;放回 web-server.test.ts 会把那个文件顶过 500 行闸门。
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import { readWebPid, writeWebPid } from "../lib/web/panel-pid.ts";
|
|
13
|
+
import { startWebPanel, type WebPanel, type WebPanelOptions } from "../lib/web/server.ts";
|
|
14
|
+
|
|
15
|
+
/** 不存在的 pid:Linux 的 pid_max 远小于 INT32_MAX,这个号系统里不会有进程 */
|
|
16
|
+
const DEAD_PID = 2_147_483_646;
|
|
17
|
+
|
|
18
|
+
function workspace(): string {
|
|
19
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-webpanelpid-"));
|
|
20
|
+
const evolutionDir = path.join(cwd, ".pi", "evolution");
|
|
21
|
+
fs.mkdirSync(path.join(evolutionDir, "runs"), { recursive: true });
|
|
22
|
+
return evolutionDir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pidFile(evolutionDir: string): string {
|
|
26
|
+
return path.join(evolutionDir, "locks", "web.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function open(evolutionDir: string, extra: Partial<WebPanelOptions> = {}): Promise<WebPanel> {
|
|
30
|
+
const result = await startWebPanel({ evolutionDir, port: 0, ...extra });
|
|
31
|
+
assert.equal(result.ok, true, `面板应该起来:${result.ok ? "" : result.message}`);
|
|
32
|
+
if (!result.ok) throw new Error("unreachable");
|
|
33
|
+
return result.panel;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("起监听即落 locks/web.json:pid 是本进程、url 带实际端口,close() 后消失", async () => {
|
|
37
|
+
const evolutionDir = workspace();
|
|
38
|
+
const panel = await open(evolutionDir);
|
|
39
|
+
try {
|
|
40
|
+
const record = readWebPid(evolutionDir);
|
|
41
|
+
assert.ok(record, "起监听之后必须有记录");
|
|
42
|
+
assert.equal(record.pid, process.pid);
|
|
43
|
+
// port: 0 时写的是内核分配的实际端口,不是请求值
|
|
44
|
+
assert.equal(record.port, panel.port);
|
|
45
|
+
assert.equal(record.url, panel.url);
|
|
46
|
+
assert.equal(Number.isFinite(record.startedAt), true);
|
|
47
|
+
} finally {
|
|
48
|
+
await panel.close();
|
|
49
|
+
}
|
|
50
|
+
assert.equal(fs.existsSync(pidFile(evolutionDir)), false, "close() 后记录要删掉");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("close() 只删自己的记录:文件被换成别的 pid 时保留", async () => {
|
|
54
|
+
const evolutionDir = workspace();
|
|
55
|
+
const panel = await open(evolutionDir);
|
|
56
|
+
writeWebPid(evolutionDir, { pid: DEAD_PID, host: "127.0.0.1", port: panel.port, url: panel.url, startedAt: Date.now() });
|
|
57
|
+
await panel.close();
|
|
58
|
+
assert.equal(fs.existsSync(pidFile(evolutionDir)), true, "别人的记录不能被本实例抹掉");
|
|
59
|
+
assert.equal(readWebPid(evolutionDir)?.pid, DEAD_PID);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("端口被占且记录活着:提示「已在 … 运行」带 pid,而不是让人去 kill", async () => {
|
|
63
|
+
const evolutionDir = workspace();
|
|
64
|
+
const first = await open(evolutionDir);
|
|
65
|
+
try {
|
|
66
|
+
const second = await startWebPanel({ evolutionDir, port: first.port });
|
|
67
|
+
assert.equal(second.ok, false);
|
|
68
|
+
if (second.ok) throw new Error("同端口不该起来第二份");
|
|
69
|
+
assert.equal(second.code, "EADDRINUSE");
|
|
70
|
+
assert.equal(second.message.includes(`已在 ${first.url} 运行`), true);
|
|
71
|
+
assert.equal(second.message.includes(`pid ${process.pid}`), true);
|
|
72
|
+
assert.match(second.message, /起于 \d{4}-\d{2}-\d{2}T/);
|
|
73
|
+
assert.match(second.message, /\/skill-evolve web --port 7799/);
|
|
74
|
+
assert.equal(second.message.includes("不要直接 kill"), false, "认出是本机面板就不该建议 kill 排查");
|
|
75
|
+
} finally {
|
|
76
|
+
await first.close();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("端口被占但记录是死的:维持占用文案,提醒先 lsof 看 COMMAND 再动手", async () => {
|
|
81
|
+
const evolutionDir = workspace();
|
|
82
|
+
const first = await open(evolutionDir);
|
|
83
|
+
const port = first.port;
|
|
84
|
+
try {
|
|
85
|
+
// 记录换成已死 pid:说明占端口的不是本机面板记录的那个实例
|
|
86
|
+
writeWebPid(evolutionDir, { pid: DEAD_PID, host: "127.0.0.1", port, url: first.url, startedAt: Date.now() });
|
|
87
|
+
const second = await startWebPanel({ evolutionDir, port });
|
|
88
|
+
assert.equal(second.ok, false);
|
|
89
|
+
if (second.ok) throw new Error("同端口不该起来第二份");
|
|
90
|
+
assert.equal(second.message.includes("已在 "), false, "记录是死的就不能说面板在跑");
|
|
91
|
+
assert.match(second.message, /已被占用/);
|
|
92
|
+
assert.match(second.message, new RegExp(`lsof -nP -iTCP:${port} -sTCP:LISTEN`));
|
|
93
|
+
assert.match(second.message, /不要直接 kill/);
|
|
94
|
+
} finally {
|
|
95
|
+
await first.close();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run 详情页的渲染:概要 / diff 着色与截断 / 评审证据表 / 五闸 / 缺块空态。
|
|
3
|
+
* 直接喂 RunDetail 对象(不读盘):这一层只负责拼 HTML,读盘由 run-detail.ts 的单测覆盖。
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import type { WebState } from "../lib/web/data.ts";
|
|
8
|
+
import type { RunDetail } from "../lib/web/run-detail.ts";
|
|
9
|
+
import { renderRunPage } from "../lib/web/render-run.ts";
|
|
10
|
+
|
|
11
|
+
const RUN_ID = "354df7db-1794133942";
|
|
12
|
+
|
|
13
|
+
const DIFF_TEXT = [
|
|
14
|
+
"--- a/.pi/skills/demo/SKILL.md",
|
|
15
|
+
"+++ b/.pi/skills/demo/SKILL.md",
|
|
16
|
+
"@@ -1,2 +1,2 @@",
|
|
17
|
+
" ---",
|
|
18
|
+
"-description: 旧",
|
|
19
|
+
"+description: 新",
|
|
20
|
+
].join("\n");
|
|
21
|
+
|
|
22
|
+
function state(): WebState {
|
|
23
|
+
return {
|
|
24
|
+
pending: [],
|
|
25
|
+
approvals: [],
|
|
26
|
+
applied: [],
|
|
27
|
+
done: [],
|
|
28
|
+
reports: [],
|
|
29
|
+
summary: { reviewing: 1, stale: 0, pendingApprovals: 2, appliedTotal: 3, rollbackRate: 0.25 },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function detail(parts: Partial<RunDetail> = {}): RunDetail {
|
|
34
|
+
return {
|
|
35
|
+
runId: RUN_ID,
|
|
36
|
+
status: "rejected",
|
|
37
|
+
trigger: "auto",
|
|
38
|
+
skill: "demo",
|
|
39
|
+
startedAt: 1794133942865,
|
|
40
|
+
endedAt: 1794134158913,
|
|
41
|
+
pid: 65757,
|
|
42
|
+
hasChildLog: true,
|
|
43
|
+
diff: DIFF_TEXT,
|
|
44
|
+
candidateSkill: "demo",
|
|
45
|
+
candidateText: "---\nname: demo\n---\n# 改后\n",
|
|
46
|
+
review: { status: "rejected", verdict: "revise", action: "forbidden", reviewerModel: "deepseek/deepseek-flash" },
|
|
47
|
+
blocking: [
|
|
48
|
+
{
|
|
49
|
+
issue: "描述承诺大于正文能力",
|
|
50
|
+
file: ".pi/skills/demo/SKILL.md",
|
|
51
|
+
line: 3,
|
|
52
|
+
quote: "<script> 引用",
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
gates: [
|
|
56
|
+
{ gate: "gate0", name: "授权核对", ok: true, detail: "" },
|
|
57
|
+
{ gate: "gate3", name: "复验", ok: false, detail: "复验失败" },
|
|
58
|
+
],
|
|
59
|
+
verifyStatus: "applied",
|
|
60
|
+
findings: [{ skill: "demo", reason: "G", confidence: "high", lines: [578, 622] }],
|
|
61
|
+
findingsTotal: 1,
|
|
62
|
+
notes: "",
|
|
63
|
+
...parts,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test("概要:runId / 状态 / 技能 / 触发 / 开始结束 / pid / child.log 一次列全", () => {
|
|
68
|
+
const html = renderRunPage(detail(), { state: state() });
|
|
69
|
+
assert.match(html, /<title>技能进化 · run 354df7db-1794133942<\/title>/);
|
|
70
|
+
assert.match(html, /<span class="k">runId<\/span><span class="v"><code>354df7db-1794133942<\/code><\/span>/);
|
|
71
|
+
assert.match(html, /<span class="k">状态<\/span><span class="v">rejected<\/span>/);
|
|
72
|
+
assert.match(html, /<span class="k">技能<\/span><span class="v">demo<\/span>/);
|
|
73
|
+
assert.match(html, /<span class="k">触发<\/span><span class="v">auto<\/span>/);
|
|
74
|
+
assert.match(html, /<span class="k">pid<\/span><span class="v">65757<\/span>/);
|
|
75
|
+
assert.match(html, /<a href="\/log\?runId=354df7db-1794133942">child\.log<\/a>/);
|
|
76
|
+
assert.match(html, /已采纳 <a href="\/\?view=applied">3<\/a>/, "详情页也带同一份状态栏");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("diff:+ 绿底 / - 红底 / @@ 灰字三类 class 与两列行号都在", () => {
|
|
80
|
+
const html = renderRunPage(detail(), { state: state() });
|
|
81
|
+
assert.match(html, /<pre class="diff">/);
|
|
82
|
+
assert.match(html, /<span class="dl file">/);
|
|
83
|
+
assert.match(html, /<span class="dl hunk">/);
|
|
84
|
+
assert.match(html, /<span class="dl del"><span class="ln old">2<\/span><span class="ln new"> {1}<\/span>/);
|
|
85
|
+
assert.match(html, /<span class="dl add"><span class="ln old"> {1}<\/span><span class="ln new">2<\/span>/);
|
|
86
|
+
assert.match(html, /diff 共 6 行。/);
|
|
87
|
+
assert.equal(html.includes("完整 diff"), false, "没截断就不给下载链接");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("diff 超 400 行:只渲前 400 行,并给 /diff 完整下载链接", () => {
|
|
91
|
+
const body = Array.from({ length: 500 }, (_unused, index) => ` line ${index + 1}`);
|
|
92
|
+
const diff = ["--- a/x", "+++ b/x", "@@ -1,500 +1,500 @@", ...body].join("\n");
|
|
93
|
+
const html = renderRunPage(detail({ diff }), { state: state() });
|
|
94
|
+
assert.match(html, /diff 共 503 行,页面只渲染前 400 行/);
|
|
95
|
+
assert.match(html, /<a class="btn" href="\/diff\?runId=354df7db-1794133942">完整 diff<\/a>/);
|
|
96
|
+
assert.equal(html.match(/class="dl /g)?.length, 400);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("评审结论:verdict/action/模型 + blocking 表格(file:line 与引用),不是把 JSON 倒出来", () => {
|
|
100
|
+
const html = renderRunPage(detail(), { state: state() });
|
|
101
|
+
assert.match(html, /verdict <b class="fail">revise<\/b>/);
|
|
102
|
+
assert.match(html, /action <b>forbidden<\/b>/);
|
|
103
|
+
assert.match(html, /<code>deepseek\/deepseek-flash<\/code>/);
|
|
104
|
+
assert.match(html, /<td>描述承诺大于正文能力<\/td><td><code>\.pi\/skills\/demo\/SKILL\.md:3<\/code><\/td>/);
|
|
105
|
+
assert.match(html, /<script> 引用/, "引用原文也要转义");
|
|
106
|
+
assert.equal(html.includes('"blocking"'), false, "blocking 要做成表格,不许把 JSON 直接倒出来");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("五闸:逐条 ok/fail 上色,失败那条带说明", () => {
|
|
110
|
+
const html = renderRunPage(detail(), { state: state() });
|
|
111
|
+
assert.match(html, /<td><code>gate0<\/code><\/td><td>授权核对<\/td><td class="ok">ok<\/td>/);
|
|
112
|
+
assert.match(html, /<td><code>gate3<\/code><\/td><td>复验<\/td><td class="fail">fail<\/td><td>复验失败<\/td>/);
|
|
113
|
+
assert.match(html, /验证状态:applied/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("缺块空态:diff / 评审 / 五闸 / 候选全文各说清缺的是哪个文件", () => {
|
|
117
|
+
const html = renderRunPage(
|
|
118
|
+
detail({ diff: null, review: null, gates: [], verifyStatus: null, candidateText: null, candidateSkill: null, findingsTotal: 0, findings: [] }),
|
|
119
|
+
{ state: state() },
|
|
120
|
+
);
|
|
121
|
+
assert.match(html, /这个 run 没有 02-proposal\.diff/);
|
|
122
|
+
assert.match(html, /没有 03-review\.json:这一轮还没评审完,或被跳过。/);
|
|
123
|
+
assert.match(html, /没有 06-verify\.json:五闸未执行(这个 run 没被采纳)。/);
|
|
124
|
+
assert.match(html, /没有 candidate\/demo\/SKILL\.md/);
|
|
125
|
+
assert.match(html, /01-findings\.json 里没有条目/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("详情页不高亮任何 tab:点上去回不到这一页,highligh 就是在骗人", () => {
|
|
129
|
+
const html = renderRunPage(detail(), { state: state() });
|
|
130
|
+
const nav = html.slice(html.indexOf("<nav>"), html.indexOf("</nav>"));
|
|
131
|
+
assert.equal(nav.includes('class="active"'), false);
|
|
132
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能管理页与技能详情页的渲染:六列表格、加载率的「—」、两种遥测空态、历史提案表。
|
|
3
|
+
* 直接喂 SkillsView / SkillDetail(不读盘)。
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import type { WebState } from "../lib/web/data.ts";
|
|
8
|
+
import { renderSkillPage, renderSkillsPage } from "../lib/web/render-skills.ts";
|
|
9
|
+
import type { SkillDetail, SkillRow, SkillsView } from "../lib/web/skills.ts";
|
|
10
|
+
|
|
11
|
+
function state(): WebState {
|
|
12
|
+
return {
|
|
13
|
+
pending: [],
|
|
14
|
+
approvals: [],
|
|
15
|
+
applied: [],
|
|
16
|
+
done: [],
|
|
17
|
+
reports: [],
|
|
18
|
+
summary: { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function row(name: string, parts: Partial<SkillRow> = {}): SkillRow {
|
|
23
|
+
return {
|
|
24
|
+
name,
|
|
25
|
+
exposed: 0,
|
|
26
|
+
loaded: 0,
|
|
27
|
+
rate: null,
|
|
28
|
+
lastRunId: null,
|
|
29
|
+
lastRunAt: null,
|
|
30
|
+
hasPendingProposal: false,
|
|
31
|
+
hasBaseline: false,
|
|
32
|
+
missingCursorStub: false,
|
|
33
|
+
...parts,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function view(rows: SkillRow[], parts: Partial<SkillsView> = {}): SkillsView {
|
|
38
|
+
return { days: 30, rows, telemetryOn: true, events: 10, ...parts };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function detail(parts: Partial<SkillDetail> = {}): SkillDetail {
|
|
42
|
+
return { name: "demo", days: 30, row: row("demo"), runs: [], telemetryOn: true, events: 10, ...parts };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("技能列表:六列齐全,技能名与最近 run 都能点进详情", () => {
|
|
46
|
+
const html = renderSkillsPage(
|
|
47
|
+
view([
|
|
48
|
+
row("demo", {
|
|
49
|
+
exposed: 4,
|
|
50
|
+
loaded: 1,
|
|
51
|
+
rate: 0.25,
|
|
52
|
+
lastRunId: "354df7db-1794133942",
|
|
53
|
+
lastRunAt: 1794133942865,
|
|
54
|
+
hasPendingProposal: true,
|
|
55
|
+
missingCursorStub: true,
|
|
56
|
+
}),
|
|
57
|
+
row("quiet"),
|
|
58
|
+
]),
|
|
59
|
+
state(),
|
|
60
|
+
);
|
|
61
|
+
assert.match(html, /<title>技能进化 · 技能管理<\/title>/);
|
|
62
|
+
for (const head of ["技能", "曝光", "加载", "加载率", "最近一次相关 run", "状态"]) {
|
|
63
|
+
assert.match(html, new RegExp(`<th>${head}</th>`));
|
|
64
|
+
}
|
|
65
|
+
assert.match(html, /<h2>技能(2 个,窗口 30 天)<\/h2>/);
|
|
66
|
+
assert.match(html, /<a href="\/\?view=skill&name=demo&days=30">demo<\/a><\/td><td>4<\/td><td>1<\/td><td>25%<\/td>/);
|
|
67
|
+
assert.match(html, /<a href="\/\?view=run&runId=354df7db-1794133942">354df7db…3942<\/a>/);
|
|
68
|
+
assert.match(html, /<span class="tag warn">有待批提案<\/span>/);
|
|
69
|
+
assert.match(html, /<span class="tag">缺 \.cursor stub<\/span>/);
|
|
70
|
+
// 没曝光的技能写「—」而不是 0%:0% 会被当成「一百次曝光一次没加载」
|
|
71
|
+
assert.match(html, /<td><a href="\/\?view=skill&name=quiet&days=30">quiet<\/a><\/td><td>0<\/td><td>0<\/td><td>—<\/td>/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("空遥测两种说法:采集关着 vs 采集开着但窗口内没事件", () => {
|
|
75
|
+
const off = renderSkillsPage(view([row("demo")], { telemetryOn: false, events: 0 }), state());
|
|
76
|
+
assert.match(off, /没有遥测数据(采集关着)/);
|
|
77
|
+
assert.match(off, /曝光与加载都是 0 是因为没采集/);
|
|
78
|
+
// 采集关着时数值是「不知道」而不是 0:一屏 0 会被读成「这些技能没人用」
|
|
79
|
+
assert.match(off, /<td><a href="\/\?view=skill&name=demo&days=30">demo<\/a><\/td><td>—<\/td><td>—<\/td><td>—<\/td>/);
|
|
80
|
+
const on = renderSkillsPage(view([row("demo")], { telemetryOn: true, events: 0, days: 7 }), state());
|
|
81
|
+
assert.match(on, /最近 7 天没有遥测事件/);
|
|
82
|
+
assert.equal(on.includes("采集关着"), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("窗口切换:当前窗口是字不是链接,另外两个可点", () => {
|
|
86
|
+
const html = renderSkillsPage(view([row("demo")], { days: 7 }), state());
|
|
87
|
+
assert.match(html, /<span class="on">近 7 天<\/span>/);
|
|
88
|
+
assert.match(html, /<a href="\/\?view=skills&days=30">近 30 天<\/a>/);
|
|
89
|
+
assert.match(html, /近 90 天/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("技能详情:统计一行 + 历史提案表(run / 状态 / 时间 / 提案 / verdict / action)", () => {
|
|
93
|
+
const html = renderSkillPage(
|
|
94
|
+
detail({
|
|
95
|
+
row: row("demo", { exposed: 4, loaded: 2, rate: 0.5, hasBaseline: true }),
|
|
96
|
+
runs: [
|
|
97
|
+
{
|
|
98
|
+
runId: "354df7db-1794133942",
|
|
99
|
+
status: "rejected",
|
|
100
|
+
at: 1794133942865,
|
|
101
|
+
type: "improve",
|
|
102
|
+
added: 3,
|
|
103
|
+
removed: 1,
|
|
104
|
+
verdict: "revise",
|
|
105
|
+
action: "forbidden",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
}),
|
|
109
|
+
state(),
|
|
110
|
+
);
|
|
111
|
+
assert.match(html, /<title>技能进化 · 技能 demo<\/title>/);
|
|
112
|
+
assert.match(html, /曝光 <b>4<\/b> · 加载 <b>2<\/b> · 加载率 <b>50%<\/b> · 窗口 30 天/);
|
|
113
|
+
assert.match(html, /<span class="tag fail">存量违规<\/span>/);
|
|
114
|
+
assert.match(html, /<a href="\/\?view=run&runId=354df7db-1794133942">354df7db…3942<\/a><\/td><td>rejected<\/td>/);
|
|
115
|
+
assert.match(html, /improve \+3\/-1<\/td><td>revise<\/td><td>forbidden<\/td>/);
|
|
116
|
+
assert.match(html, /<a href="\/\?view=skills">← 回技能管理<\/a>/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("技能详情空态:没有提案时说明是 runs/ 里没这个技能的提案,不是页面坏了", () => {
|
|
120
|
+
const html = renderSkillPage(detail(), state());
|
|
121
|
+
assert.match(html, /还没有针对 demo 的提案:runs\/ 里没有 skill=demo 的 02-proposal\.json。/);
|
|
122
|
+
const gone = renderSkillPage(detail({ name: "gone", row: null, runs: [] }), state());
|
|
123
|
+
assert.match(gone, /gone 不在 \.pi\/skills 目录里(可能已改名或删除)/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("技能详情也守同一条:采集关着时曝光/加载写「—」,不写 0", () => {
|
|
127
|
+
const html = renderSkillPage(detail({ telemetryOn: false, events: 0 }), state());
|
|
128
|
+
assert.match(html, /曝光 <b>—<\/b> · 加载 <b>—<\/b> · 加载率 <b>—<\/b> · 窗口 30 天/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("技能列表空目录:说清是工作区根路径推断错了,不是「没有技能」", () => {
|
|
132
|
+
const html = renderSkillsPage(view([], { events: 0 }), state());
|
|
133
|
+
assert.match(html, /\.pi\/skills 下没有技能目录/);
|
|
134
|
+
});
|