pi-skill-evolve 0.1.5 → 0.1.7

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.
Files changed (28) hide show
  1. package/extensions/skill-evolve/lib/runs.ts +4 -1
  2. package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
  3. package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
  4. package/extensions/skill-evolve/lib/web/pending.ts +5 -5
  5. package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
  6. package/extensions/skill-evolve/lib/web/render-rows.ts +178 -235
  7. package/extensions/skill-evolve/lib/web/render-session.ts +105 -34
  8. package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
  9. package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
  10. package/extensions/skill-evolve/lib/web/render.ts +116 -54
  11. package/extensions/skill-evolve/lib/web/server.ts +9 -1
  12. package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
  13. package/extensions/skill-evolve/lib/web/session-view.ts +100 -84
  14. package/extensions/skill-evolve/lib/web/shell.ts +42 -11
  15. package/extensions/skill-evolve/lib/web/style.ts +171 -106
  16. package/extensions/skill-evolve/lib/web/text.ts +57 -0
  17. package/extensions/skill-evolve/test/pending-kinds.test.ts +142 -0
  18. package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
  19. package/extensions/skill-evolve/test/session-page.test.ts +11 -5
  20. package/extensions/skill-evolve/test/session-render.test.ts +203 -0
  21. package/extensions/skill-evolve/test/session-view.test.ts +75 -87
  22. package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
  23. package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
  24. package/extensions/skill-evolve/test/web-render.test.ts +57 -41
  25. package/extensions/skill-evolve/test/web-server.test.ts +4 -1
  26. package/extensions/skill-evolve/test/web-session-column.test.ts +30 -32
  27. package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
  28. package/package.json +1 -1
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * 「已采纳」单独成 tab:列表只列 applied,已处理页一条 applied 都不许有。
3
- * 行里的五闸要分三种说法:全 ok / N 项失败 / 无记录(判不了 ≠ 没过)。
3
+ * 五闸结论分三种说法:全 ok / N 项失败 / 无记录(判不了 ≠ 没过)。
4
+ *
5
+ * 重做列表页后五闸不再单占一列,而是写进状态徽章的 `title`(鼠标停一下才展开):
6
+ * 表格一行只放五个字段,五闸属于「点进去之前想看」的次要信息。
4
7
  */
5
8
  import assert from "node:assert/strict";
6
9
  import test from "node:test";
@@ -10,10 +13,12 @@ import { renderPage } from "../lib/web/render.ts";
10
13
  import { appliedRows } from "../lib/web/render-rows.ts";
11
14
 
12
15
  const NOW = new Date(2026, 10, 8, 12).getTime();
16
+ const NO_SESSION = { sessionId: null, sessionFile: null };
13
17
 
14
18
  function item(parts: Partial<AppliedItem> = {}): AppliedItem {
15
19
  return {
16
20
  runId: "run-a-1794133942",
21
+ session: NO_SESSION,
17
22
  level: "approval",
18
23
  skill: "demo",
19
24
  type: "improve",
@@ -30,7 +35,7 @@ function item(parts: Partial<AppliedItem> = {}): AppliedItem {
30
35
  }
31
36
 
32
37
  function done(runId: string, status: string, skill: string): DoneItem {
33
- return { runId, status, source: "run", level: "-", skill, at: NOW, detail: "" };
38
+ return { runId, status, source: "run", level: "-", skill, at: NOW, detail: "", session: NO_SESSION };
34
39
  }
35
40
 
36
41
  function state(parts: Partial<WebState> = {}): WebState {
@@ -45,39 +50,40 @@ function state(parts: Partial<WebState> = {}): WebState {
45
50
  };
46
51
  }
47
52
 
48
- test("已采纳行:技能 · 提案类型 · +N/-M · 采纳时间 · 五闸全 ok", () => {
49
- const html = appliedRows([item()]);
50
- assert.match(html, /\[applied\] <a class="run" href="\/\?view=run&amp;runId=run-a-1794133942">run-a-1794133942<\/a>/);
51
- assert.match(html, / · demo · improve \+3\/-1 · 2026-11-08 12:00 · 五闸:全 ok/);
52
- assert.match(html, /gate0 授权核对 ✓/);
53
- assert.match(html, /完整 diff:点上方 runId 进详情页/);
54
- assert.match(html, /看 diff/);
53
+ test("已采纳行:状态徽章 + 时间 + 技能 + 提案增删 + 五闸结论(在 title 里)", () => {
54
+ const [row] = appliedRows([item()]);
55
+ assert.equal(row.badge.text, "已采纳");
56
+ assert.equal(row.badge.tone, "green");
57
+ assert.equal(row.badge.title, "approval 级 · 五闸:全 ok");
58
+ assert.equal(row.skill, "demo");
59
+ assert.deepEqual(row.delta, { type: "improve", added: 3, removed: 1 });
60
+ assert.equal(row.at, NOW);
61
+ // 表格里一眼能看到的:`+3 / -1`
62
+ const html = renderPage(state({ applied: [item()] }), "applied", { now: NOW });
63
+ assert.match(html, /<td class="delta">\+3 \/ -1<\/td>/);
64
+ assert.match(html, /<td class="skill"><code>demo<\/code><\/td>/);
55
65
  });
56
66
 
57
- test("已采纳行:有一闸没过要说几项失败,并提示这是异常", () => {
58
- const html = appliedRows([
59
- item({ gates: [{ gate: "gate3", name: "复验", ok: false }], gatesOk: false }),
60
- ]);
61
- assert.match(html, /五闸:1 项失败/);
62
- assert.match(html, /gate3 复验 ✗/);
63
- assert.match(html, /注意:有闸没过却仍被采纳,先看 06-verify\.json/);
67
+ test("已采纳行:有一闸没过要说几项失败(提示这是异常)", () => {
68
+ const [row] = appliedRows([item({ gates: [{ gate: "gate3", name: "复验", ok: false }], gatesOk: false })]);
69
+ assert.equal(row.badge.title, "approval 级 · 五闸:1 项失败");
64
70
  });
65
71
 
66
72
  test("已采纳行:没有 06-verify.json 时写「无记录」,不写成「失败」", () => {
67
- const html = appliedRows([item({ gates: [], gatesOk: null })]);
68
- assert.match(html, /五闸:无记录/);
69
- assert.match(html, /五闸:没有 06-verify\.json(无法判定)/);
73
+ const [row] = appliedRows([item({ gates: [], gatesOk: null })]);
74
+ assert.equal(row.badge.title, "approval 级 · 五闸:无记录");
75
+ const html = renderPage(state({ applied: [item({ gates: [], gatesOk: null })] }), "applied", { now: NOW });
70
76
  assert.equal(html.includes("项失败"), false);
71
77
  });
72
78
 
73
79
  test("已采纳页只列 applied;已处理页一条 applied 都没有(两页不再混)", () => {
74
80
  const shared = state({
75
81
  applied: [item({ runId: "run-applied" })],
76
- done: [done("run-rolled", "rolled-back", "demo"), done("run-rejected", "rejected", "other")],
82
+ done: [done("run-rolled", "rolledback", "demo"), done("run-rejected", "rejected", "other")],
77
83
  });
78
84
  const appliedPage = renderPage(shared, "applied", { now: NOW });
79
85
  assert.match(appliedPage, /<title>技能进化 · 已采纳<\/title>/);
80
- assert.match(appliedPage, /<a href="\/\?view=applied" class="active">已采纳<\/a>/);
86
+ assert.match(appliedPage, /<a href="\/\?view=applied" class="active">已采纳<span class="cnt">1<\/span><\/a>/);
81
87
  assert.match(appliedPage, /run-applied/);
82
88
  assert.equal(appliedPage.includes("run-rolled"), false, "回滚的不算已采纳");
83
89
 
@@ -88,13 +94,15 @@ test("已采纳页只列 applied;已处理页一条 applied 都没有(两页
88
94
  assert.equal(donePage.includes("run-applied"), false, "applied 已经移出已处理页");
89
95
  });
90
96
 
91
- test("已采纳页空态:说清采纳过的连 diff 一起留在这里", () => {
97
+ test("已采纳页空态:说清采纳过的连 diff 一起留在这里,并给下一步", () => {
92
98
  const html = renderPage(state(), "applied", { now: NOW });
93
99
  assert.match(html, /<p class="empty">还没有已采纳的提案:采纳过的会连 diff 一起留在这里。<\/p>/);
100
+ assert.match(html, /<p class="next">.*\/skill-evolve dispatch on/);
94
101
  });
95
102
 
96
103
  test("已采纳页有筛选与分页,但没有自动刷新(历史页刷新会把人弹回顶部)", () => {
97
104
  const html = renderPage(state({ applied: [item()] }), "applied", { now: NOW });
98
105
  assert.match(html, /<form class="filter"/);
106
+ assert.match(html, /<p class="page">/);
99
107
  assert.equal(html.includes('http-equiv="refresh"'), false);
100
108
  });
@@ -0,0 +1,264 @@
1
+ /**
2
+ * 列表页重做的验收测试(node --test):分组、表格五列、时间与提案格式、徽章、空态、分页,
3
+ * 以及用户现场报过的「待处理一直帮我收起」(默认不自动刷新、分组默认展开、`open=none` 才收起)。
4
+ *
5
+ * 纪律:全部走 `renderPage` 的纯字符串渲染 + 本文件现造的 state,不读真实 `.pi/evolution`。
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+
10
+ import type { DoneItem, PendingItem, WebState } from "../lib/web/data.ts";
11
+ import { renderPage } from "../lib/web/render.ts";
12
+ import { STYLE } from "../lib/web/style.ts";
13
+ import { formatClock, formatIso } from "../lib/web/text.ts";
14
+
15
+ const NOW = new Date(2026, 10, 8, 21, 57, 3).getTime();
16
+ const T_LATE = new Date(2026, 10, 8, 19, 35).getTime();
17
+ const T_EARLY = new Date(2026, 10, 7, 18, 24).getTime();
18
+ const SID = "01a1b90f-08c3-722c-871d-d024b083770a";
19
+ const SESSION = { sessionId: SID, sessionFile: "/tmp/demo.jsonl" };
20
+ const NO_SESSION = { sessionId: null, sessionFile: null };
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 pending(runId: string, at: number, parts: Partial<PendingItem> = {}): PendingItem {
34
+ return { runId, kind: "approval", session: SESSION, skill: "demo", at, detail: "", ...parts };
35
+ }
36
+
37
+ function doneItem(runId: string, status: string, at: number): DoneItem {
38
+ return { runId, status, source: "run", level: "-", skill: "demo", at, detail: "", session: NO_SESSION };
39
+ }
40
+
41
+ function withPending(items: PendingItem[]): WebState {
42
+ const state = emptyState();
43
+ state.pending = items;
44
+ return state;
45
+ }
46
+
47
+ test("分组:同一会话的多条 run 合成一个 <details open>,标题写「条数 · 最近 · 几条要处理」", () => {
48
+ const html = renderPage(withPending([pending("run-1", T_EARLY), pending("run-2", T_LATE)]), "pending", { now: NOW });
49
+ assert.match(html, /<details class="grp" open>\n<summary><a class="sess" [^>]*>会话 01a1b90f<\/a> · 2 条 · 最近 19:35 · 2 条需要处理<\/summary>/);
50
+ });
51
+
52
+ test("分组标题:全部已处理时写「无待办」,不写「0 条需要处理」", () => {
53
+ const html = renderPage(
54
+ withPending([pending("run-1", T_EARLY, { kind: "reviewed" }), pending("run-2", T_LATE, { kind: "reviewed" })]),
55
+ "pending",
56
+ { now: NOW },
57
+ );
58
+ assert.match(html, /· 2 条 · 最近 19:35 · 无待办<\/summary>/);
59
+ });
60
+
61
+ test("单条会话的 run 不进组:直接一行表格,会话名写在状态格里", () => {
62
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", { now: NOW });
63
+ assert.equal(html.includes('<details class="grp"'), false, "一条没必要折叠");
64
+ assert.match(html, /<span class="sk-sess"><a class="sess" href="[^"]+" title="01a1b90f[^"]*">会话 01a1b90f<\/a><\/span>/);
65
+ });
66
+
67
+ test("操作列只写「详情 · 日志」,runId 只在链接 title 与详情页里", () => {
68
+ const runId = "354df7db-1794133942";
69
+ const html = renderPage(withPending([pending(runId, T_LATE)]), "pending", { now: NOW });
70
+ assert.match(html, new RegExp(`<a class="rid" href="/\\?view=run&amp;runId=${runId}" title="${runId}">详情</a>`));
71
+ // 「详情 …94133942」这种半截 runId 不再出现在页面上(只读面板没有重跑,操作就是 详情 · 日志)
72
+ assert.equal(html.includes(`…${runId.slice(-8)}`), false, "操作列不再显示 runId 尾巴");
73
+ const cells = html.slice(html.indexOf('<td class="status">'), html.indexOf('<td class="ops">'));
74
+ assert.equal(cells.includes(runId.slice(0, 8)), false, "runId 前缀不该占着行首");
75
+ });
76
+
77
+ test("操作列文案:只读「详情 · 日志」,可写「详情 · 重跑 · 日志」", () => {
78
+ const readonly = renderPage(withPending([pending("run-ops", T_LATE)]), "pending", { now: NOW });
79
+ assert.match(readonly, /<td class="ops"><a class="rid"[^>]*>详情<\/a> · <a href="\/log\?runId=run-ops">日志<\/a><\/td>/);
80
+ const writable = renderPage(withPending([pending("run-ops", T_LATE)]), "pending", {
81
+ now: NOW,
82
+ canWrite: true,
83
+ csrf: "t",
84
+ });
85
+ assert.match(
86
+ writable,
87
+ /<td class="ops"><a class="rid"[^>]*>详情<\/a> · <form[\s\S]*?value="rerun"[\s\S]*?<\/form> · <a href="\/log\?runId=run-ops">日志<\/a><\/td>/,
88
+ );
89
+ });
90
+
91
+ test("tab 计数是 .cnt 徽章,当前 tab 的计数用 --blue 底", () => {
92
+ const html = renderPage(withPending([pending("run-1", T_LATE), pending("run-2", T_EARLY)]), "pending", { now: NOW });
93
+ assert.match(html, /<a href="\/\?view=pending" class="active">待处理<span class="cnt">2<\/span><\/a>/);
94
+ assert.match(html, /<a href="\/\?view=done">已处理<span class="cnt">0<\/span><\/a>/);
95
+ assert.match(html, /\.tabs \.cnt \{[^}]*border-radius: 999px[^}]*\}/);
96
+ assert.match(html, /\.tabs a\.active \.cnt \{ background: var\(--blue\)/);
97
+ });
98
+
99
+ test("时间:今天写 HH:mm,更早写 MM-DD HH:mm,title 一律是完整 ISO", () => {
100
+ const state = emptyState();
101
+ state.done = [doneItem("run-today", "applied", T_LATE), doneItem("run-old", "applied", T_EARLY)];
102
+ const html = renderPage(state, "done", { now: NOW });
103
+ const today = formatClock(T_LATE, NOW);
104
+ const early = formatClock(T_EARLY, NOW);
105
+ assert.equal(today.length, 5, "今天只给时分");
106
+ assert.match(today, /^\d\d:\d\d$/);
107
+ assert.match(early, /^\d\d-\d\d \d\d:\d\d$/);
108
+ assert.match(html, new RegExp(`<td class="time" title="${formatIso(T_LATE)}">${today}</td>`));
109
+ assert.match(html, new RegExp(`<td class="time" title="${formatIso(T_EARLY)}">${early}</td>`));
110
+ });
111
+
112
+ test("提案列:有增删写「+3 / -1」,没有就写占位符(不是空白)", () => {
113
+ const state = emptyState();
114
+ state.applied = [
115
+ {
116
+ runId: "run-a",
117
+ session: NO_SESSION,
118
+ level: "auto",
119
+ skill: "demo",
120
+ type: "improve",
121
+ added: 3,
122
+ removed: 1,
123
+ appliedAt: T_LATE,
124
+ gates: [],
125
+ gatesOk: null,
126
+ },
127
+ ];
128
+ assert.match(renderPage(state, "applied", { now: NOW }), /<td class="delta">\+3 \/ -1<\/td>/);
129
+ // 待处理行没有提案数字(还没生成或还没采纳)
130
+ assert.match(renderPage(withPending([pending("run-p", T_LATE)]), "pending", { now: NOW }), /<td class="delta"><span class="off">—<\/span><\/td>/);
131
+ });
132
+
133
+ test("徽章:七种状态各一色(待入队灰 / 待审批蓝 / 复盘中黄 / 已采纳绿 / 卡死红 / 已拒绝暗红 / 已回滚淡)", () => {
134
+ const pendingCase = (kind: PendingItem["kind"], detail: string): string =>
135
+ renderPage(withPending([pending("r1", T_LATE, { kind, detail })]), "pending", { now: NOW });
136
+ const doneCase = (status: string): string => {
137
+ const state = emptyState();
138
+ state.done = [doneItem("r1", status, T_LATE)];
139
+ return renderPage(state, "done", { now: NOW });
140
+ };
141
+ assert.match(pendingCase("reviewed", "详情"), /<span class="badge gray" title="详情">待入队<\/span>/);
142
+ assert.match(pendingCase("approval", "详情"), /<span class="badge blue" title="详情">待审批<\/span>/);
143
+ assert.match(pendingCase("running", "阶段:复盘产出中"), /<span class="badge yellow" title="阶段:复盘产出中">复盘中<\/span>/);
144
+ assert.match(pendingCase("stale", "跑了 10 小时"), /<span class="badge red" title="跑了 10 小时">卡死<\/span>/);
145
+ assert.match(doneCase("applied"), /<span class="badge green" title="">已采纳<\/span>/);
146
+ assert.match(doneCase("rejected"), /<span class="badge darkred" title="">已拒绝<\/span>/);
147
+ assert.match(doneCase("rolledback"), /<span class="badge faint" title="">已回滚<\/span>/);
148
+ assert.match(doneCase("failed"), /<span class="badge red" title="">失败<\/span>/);
149
+ });
150
+
151
+ test("表格:五列固定表头,行是 <tr> 而不是卡片", () => {
152
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", { now: NOW });
153
+ assert.match(html, /<table class="rows">/);
154
+ assert.match(html, /<thead><tr><th class="status">状态<\/th><th class="time">时间<\/th><th class="skill">技能<\/th><th class="delta">提案<\/th><th class="ops">操作<\/th><\/tr><\/thead>/);
155
+ assert.match(html, /<th class="ops">操作<\/th>/);
156
+ });
157
+
158
+ test("操作列:只读实例给「详情 · 日志」两个链接(不再是每行两个大按钮)", () => {
159
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", { now: NOW });
160
+ assert.match(html, /<\/a> · <a href="[^"]*">日志<\/a>/);
161
+ assert.equal(html.includes('name="name" value="rerun"'), false, "只读实例不该出现重跑表单");
162
+ });
163
+
164
+ test("操作列:可写实例才给「重跑」,卡死的才给「清理」", () => {
165
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", {
166
+ now: NOW,
167
+ canWrite: true,
168
+ csrf: "tok",
169
+ });
170
+ assert.match(html, /name="name" value="rerun"/);
171
+ assert.equal(html.includes('value="cleanup"'), false, "没卡死就不给清理");
172
+
173
+ const staleHtml = renderPage(withPending([pending("run-1", T_LATE, { kind: "stale", detail: "跑了 10 小时" })]), "pending", {
174
+ now: NOW,
175
+ canWrite: true,
176
+ csrf: "tok",
177
+ });
178
+ assert.match(staleHtml, /name="name" value="cleanup"/);
179
+ });
180
+
181
+ test("空态:一句话说清为什么空,再给一句下一步(带具体命令)", () => {
182
+ const html = renderPage(emptyState(), "pending", { now: NOW });
183
+ assert.match(html, /<p class="empty">暂无待处理:所有复盘都已完成,也没有待人批的改动。<\/p>/);
184
+ assert.match(html, /<p class="next">.*\/skill-evolve dispatch on/);
185
+ });
186
+
187
+ test("分页:写「第 1 / 2 页 · 共 23 条」,下一页链接保留筛选参数", () => {
188
+ const items = Array.from({ length: 23 }, (_, index) => pending(`run-${index}`, T_LATE - index * 1000));
189
+ const state = withPending(items);
190
+ const filter = { skill: "demo", status: "", range: "7d" as const, page: 1 };
191
+ const html = renderPage(state, "pending", { now: NOW, filter });
192
+ assert.match(html, /<p class="page"><span class="off">← 上一页<\/span> <span class="count">第 1 \/ 2 页 · 共 23 条<\/span> <a href="[^"]*">下一页 →<\/a><\/p>/);
193
+ const next = /<a href="([^"]*)">下一页 →<\/a>/.exec(html)?.[1] ?? "";
194
+ assert.ok(next.includes("skill=demo"), "技能筛选要留住");
195
+ assert.ok(next.includes("range=7d"), "时间范围要留住");
196
+ assert.ok(next.includes("page=2"), "翻到第 2 页");
197
+ });
198
+
199
+ test("默认不自动刷新:没有 meta refresh,只有「更新于 … · 刷新」", () => {
200
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", { now: NOW });
201
+ assert.equal(html.includes('http-equiv="refresh"'), false, "默认整页重载会把展开态清掉");
202
+ assert.match(html, /更新于 21:57:03 · <a href="[^"]+">刷新<\/a>/);
203
+ });
204
+
205
+ test("&live=1 才 5 秒重载,并在页头写清代价 + 给「关掉」", () => {
206
+ const html = renderPage(withPending([pending("run-1", T_LATE)]), "pending", {
207
+ now: NOW,
208
+ extras: { live: true, open: "all", only: null },
209
+ });
210
+ assert.match(html, /<meta http-equiv="refresh" content="5; url=\/\?view=pending/);
211
+ assert.match(html, /自动刷新中(每 5 秒整页重载,展开态会重置)· <a href="[^"]*">关掉<\/a>/);
212
+ const off = /自动刷新中[^<]*· <a href="([^"]*)">关掉<\/a>/.exec(html)?.[1] ?? "";
213
+ assert.equal(off.includes("live=1"), false, "关掉链接不能还带着 live=1");
214
+ });
215
+
216
+ test("live=1 只对待处理页生效:其它视图给了也忽略(数据不会自己变)", () => {
217
+ const html = renderPage(emptyState(), "done", { now: NOW, extras: { live: true, open: "all", only: null } });
218
+ assert.equal(html.includes('http-equiv="refresh"'), false);
219
+ assert.equal(html.includes("自动刷新中"), false);
220
+ });
221
+
222
+ test("open=none 时分组不带 open 属性(用户点过「收起」,就靠 URL 记住)", () => {
223
+ const items = [pending("run-1", T_EARLY), pending("run-2", T_LATE)];
224
+ const open = renderPage(withPending(items), "pending", { now: NOW });
225
+ assert.match(open, /<details class="grp" open>/);
226
+ const closed = renderPage(withPending(items), "pending", { now: NOW, extras: { live: false, open: "none", only: null } });
227
+ assert.match(closed, /<details class="grp">/);
228
+ assert.equal(closed.includes('<details class="grp" open>'), false);
229
+ });
230
+
231
+ test("刷新链接保留原始 query:页码、时间范围、技能筛选一个都不丢", () => {
232
+ const items = Array.from({ length: 23 }, (_, index) => pending(`run-${index}`, T_LATE - index * 1000));
233
+ const filter = { skill: "demo", status: "", range: "30d" as const, page: 2 };
234
+ const html = renderPage(withPending(items), "pending", { now: NOW, filter });
235
+ const refresh = /更新于 [^·]*· <a href="([^"]+)">刷新<\/a>/.exec(html)?.[1] ?? "";
236
+ assert.ok(refresh.includes("page=2"), "刷新不能把人弹回第 1 页");
237
+ assert.ok(refresh.includes("range=30d"));
238
+ assert.ok(refresh.includes("skill=demo"));
239
+ });
240
+
241
+ test("分组开关:有组才给「收起全部分组」,没有组不给点了没反应的链接", () => {
242
+ const grouped = renderPage(withPending([pending("run-1", T_EARLY), pending("run-2", T_LATE)]), "pending", { now: NOW });
243
+ assert.match(grouped, /<a href="[^"]*open=none">收起全部分组<\/a>/);
244
+ const single = renderPage(withPending([pending("run-1", T_LATE)]), "pending", { now: NOW });
245
+ assert.equal(single.includes("收起全部分组"), false);
246
+ });
247
+
248
+ test("顶部粘性区:摘要行四个数字 + 每个 tab 带计数徽章", () => {
249
+ const state = withPending([pending("run-1", T_LATE), pending("run-2", T_EARLY)]);
250
+ state.summary = { reviewing: 2, stale: 1, pendingApprovals: 0, appliedTotal: 5, rollbackRate: 0.2 };
251
+ const html = renderPage(state, "pending", { now: NOW });
252
+ assert.match(html, /<p class="sum">复盘中 2 · 卡死 1 · 待批 <a href="\/\?view=approvals">0<\/a> · 已采纳 <a href="\/\?view=applied">5<\/a>/);
253
+ assert.match(html, /<a href="\/\?view=pending" class="active">待处理<span class="cnt">2<\/span><\/a>/);
254
+ });
255
+
256
+ test("窄屏靠 CSS 让位:≤900px 隐藏时间/提案列,宽屏才给左侧目录", () => {
257
+ assert.match(STYLE, /\.hd \{ position: sticky; top: 0/);
258
+ assert.ok(STYLE.includes("@media (max-width: 900px)"), "要有窄屏断点");
259
+ assert.ok(STYLE.includes(".rows .time, .rows .delta { display: none }"), "窄屏让时间/提案列让位");
260
+ assert.match(STYLE, /@media \(min-width: 1200px\)/);
261
+ const html = renderPage(emptyState(), "pending", { now: NOW });
262
+ assert.match(html, /<meta name="viewport" content="width=device-width, initial-scale=1">/);
263
+ assert.match(html, /max-width: 980px/);
264
+ });
@@ -4,10 +4,14 @@ import test from "node:test";
4
4
  import type { WebState } from "../lib/web/data.ts";
5
5
  import { escapeHtml, formatStamp, parseView, renderPage } from "../lib/web/render.ts";
6
6
 
7
+ const NOW = new Date(2026, 10, 8, 12).getTime();
8
+ const NO_SESSION = { sessionId: null, sessionFile: null };
9
+
7
10
  function withApproval(): WebState {
8
11
  const state = emptyState();
9
12
  state.approvals.push({
10
13
  runId: "run-a",
14
+ session: NO_SESSION,
11
15
  skill: "demo",
12
16
  level: "approval",
13
17
  targets: [".pi/skills/demo/SKILL.md"],
@@ -32,7 +36,7 @@ function emptyState(): WebState {
32
36
 
33
37
  function withPending(head: string, detail: string): WebState {
34
38
  const state = emptyState();
35
- state.pending.push({ runId: "run-x", kind: "approval", skill: head, at: 0, detail });
39
+ state.pending.push({ runId: "run-x", kind: "approval", skill: head, at: 0, detail, session: NO_SESSION });
36
40
  return state;
37
41
  }
38
42
 
@@ -49,51 +53,55 @@ test("渲染:技能名与详情里的 <script> 一律转义,页面里没有
49
53
  assert.match(html, /&lt;script&gt;x\(\)&lt;\/script&gt;/);
50
54
  });
51
55
 
52
- test("渲染:findings 里的 HTML 也被转义(报告页同样不可注入)", () => {
56
+ test("渲染:报告页的徽章 title 也被转义(summary 里能塞任意文本)", () => {
53
57
  const state = emptyState();
54
58
  state.reports.push({
55
59
  runId: "run-1",
56
60
  status: "applied",
57
61
  trigger: "auto",
62
+ session: NO_SESSION,
58
63
  startedAt: 0,
59
64
  findings: { total: 1, rows: [{ skill: "<img src=x onerror=alert(1)>", kind: "未加载", reason: "<b>没读</b>" }] },
60
65
  proposal: { type: "improve", skill: "demo", added: 3, removed: 1 },
61
66
  review: { status: "reviewed", verdict: "pass", blocking: 0 },
62
67
  decision: { status: "applied", approver: "auto", at: 0, detail: "<script>d</script>" },
63
68
  verify: { status: "applied", at: 0, gates: [{ gate: "gate0", name: "授权核对", ok: true }] },
69
+ failure: "<img src=x onerror=alert(1)>",
64
70
  });
65
71
  const html = renderPage(state, "reports");
66
- assert.equal(/<img/i.test(html), false);
72
+ assert.equal(/<img/i.test(html), false, "徽章 title 里的标签必须转义");
67
73
  assert.equal(/<script/i.test(html), false);
68
- assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/);
74
+ assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/, "转义后的原文仍在,便于核对");
69
75
  });
70
76
 
71
77
  test("四个视图:标题、active 标签与列表内容各就各位", () => {
72
78
  const state = withPending("demo", "详情");
73
79
  const pending = renderPage(state, "pending");
74
80
  assert.match(pending, /<title>技能进化 · 待处理<\/title>/);
75
- assert.match(pending, /<a href="\/\?view=pending" class="active">待处理<\/a>/);
76
- assert.match(pending, /\[待批\] run-x · demo/);
81
+ assert.match(pending, /<a href="\/\?view=pending" class="active">待处理<span class="cnt">1<\/span><\/a>/);
82
+ // 列表里不再有「[待批] run-x · demo」这种行头:状态是徽章、runId 只在操作列
83
+ assert.match(pending, /<span class="badge blue" title="详情">待审批<\/span>/);
84
+ assert.match(pending, /<td class="skill"><code>demo<\/code><\/td>/);
77
85
 
78
86
  const approvals = renderPage(withApproval(), "approvals");
79
87
  assert.match(approvals, /<title>技能进化 · 待审批<\/title>/);
80
- assert.match(approvals, /<a href="\/\?view=approvals" class="active">待审批<\/a>/);
88
+ assert.match(approvals, /<a href="\/\?view=approvals" class="active">待审批<span class="cnt">1<\/span><\/a>/);
81
89
  assert.equal(approvals.includes("run-x"), false, "待审批页只收 approvals,不串待处理条目");
82
90
 
83
91
  const done = renderPage(state, "done");
84
92
  assert.match(done, /<title>技能进化 · 已处理<\/title>/);
85
- assert.match(done, /<a href="\/\?view=done" class="active">已处理<\/a>/);
86
- assert.equal(done.includes("[待批] run-x"), false, "别的视图不能串内容");
93
+ assert.match(done, /<a href="\/\?view=done" class="active">已处理<span class="cnt">0<\/span><\/a>/);
94
+ assert.equal(done.includes("run-x"), false, "别的视图不能串内容");
87
95
 
88
96
  const reports = renderPage(state, "reports");
89
97
  assert.match(reports, /<title>技能进化 · 复盘报告<\/title>/);
90
- assert.match(reports, /<a href="\/\?view=reports" class="active">复盘报告<\/a>/);
98
+ assert.match(reports, /<a href="\/\?view=reports" class="active">复盘报告<span class="cnt">0<\/span><\/a>/);
91
99
  });
92
100
 
93
101
  test("六个视图:tab 顺序是 待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告 / 技能管理", () => {
94
102
  const html = renderPage(emptyState(), "pending");
95
103
  // 只扫 nav:状态栏与页脚里也有 /?view=approvals 链接,整页扫会把它们算进来
96
- const nav = html.slice(html.indexOf("<nav>"), html.indexOf("</nav>"));
104
+ const nav = html.slice(html.indexOf('<nav class="tabs">'), html.indexOf("</nav>"));
97
105
  const order = [...nav.matchAll(/<a href="\/\?view=(\w+)"/g)].map((match) => match[1]);
98
106
  assert.deepEqual(order, ["pending", "approvals", "applied", "done", "reports", "skills"]);
99
107
  });
@@ -112,13 +120,13 @@ test("空态:每个列表视图各有一句人话", () => {
112
120
  }
113
121
  });
114
122
 
115
- test("状态栏:五个数字按顺序,回滚率取整百分比,待批数字链到待审批页", () => {
123
+ test("摘要行:四个数字按顺序,待批数字链到待审批页(回滚率不在这一行)", () => {
116
124
  const state = emptyState();
117
125
  state.summary = { reviewing: 2, stale: 1, pendingApprovals: 3, appliedTotal: 4, rollbackRate: 0.256 };
118
- const html = renderPage(state, "pending");
126
+ const html = renderPage(state, "pending", { now: new Date(2026, 10, 8, 21, 57, 3).getTime() });
119
127
  assert.match(
120
128
  html,
121
- /复盘中 2 · 卡死 1 · 待批 <a href="\/\?view=approvals">3<\/a> · 已采纳 <a href="\/\?view=applied">4<\/a> · 回滚率 26%/,
129
+ /<p class="sum">复盘中 2 · 卡死 1 · 待批 <a href="\/\?view=approvals">3<\/a> · 已采纳 <a href="\/\?view=applied">4<\/a> · 更新于 21:57:03 · <a href="[^"]+">刷新<\/a><\/p>/,
122
130
  );
123
131
  });
124
132
 
@@ -128,6 +136,7 @@ test("报告详情:findings 条数、提案增删行、五闸对错都在展
128
136
  runId: "run-2",
129
137
  status: "applied",
130
138
  trigger: "manual",
139
+ session: NO_SESSION,
131
140
  startedAt: 0,
132
141
  findings: { total: 7, rows: [{ skill: "demo", kind: "未加载", reason: "B frontmatter 坏" }] },
133
142
  proposal: { type: "improve", skill: "demo", added: 5, removed: 2 },
@@ -143,19 +152,20 @@ test("报告详情:findings 条数、提案增删行、五闸对错都在展
143
152
  },
144
153
  });
145
154
  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✗/);
155
+ // 列表行只放五个字段:findings / 评审 / 五闸这类细节进徽章 title 与 run 详情页
156
+ assert.match(html, /<span class="badge green" title="[^"]*findings:7 条[^"]*">已采纳<\/span>/);
157
+ assert.match(html, /<td class="skill"><code>demo<\/code><\/td>/);
158
+ assert.match(html, /<td class="delta">\+5 \/ -2<\/td>/);
159
+ assert.match(html, /<a class="rid" href="\/\?view=run&amp;runId=run-2" title="run-2">详情<\/a>/);
152
160
  });
153
161
 
154
- test("页脚写清只读与采纳入口", () => {
162
+ test("页脚是本机面板口径:只绑 127.0.0.1、无鉴权、机器可读副本见 /api/state", () => {
155
163
  const html = renderPage(emptyState(), "pending");
156
- assert.match(html, /只读面板/);
157
- assert.match(html, /\/skill-evolve approve &lt;runId&gt;/);
158
- assert.match(html, /href="\/api\/state"/);
164
+ assert.match(html, /本机面板 · 只绑 127\.0\.0\.1 · 无鉴权/);
165
+ assert.match(html, /机器可读副本见 <a href="\/api\/state">\/api\/state<\/a>/);
166
+ // 面板已能开关 / 重跑 / 清理 / 批准,再说「只读、不能在这里点批准」就是过时文案
167
+ assert.equal(html.includes("只读面板"), false);
168
+ assert.equal(html.includes("不能在这里点批准"), false);
159
169
  });
160
170
 
161
171
  test("parseView:认 approvals / done / reports,其余回落待处理", () => {
@@ -172,18 +182,23 @@ test("formatStamp:null 给破折号,时间按本地 YYYY-MM-DD HH:mm", () =>
172
182
  assert.equal(formatStamp(new Date(2026, 10, 8, 9, 5).getTime()), "2026-11-08 09:05");
173
183
  });
174
184
 
175
- test("待处理页:5 秒自刷新只加在这一页,且刷完还在待处理", () => {
185
+ test("自动刷新默认关闭:只有显式 &live=1 的待处理页才 5 秒重载", () => {
176
186
  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);
187
+ assert.equal(renderPage(state, "pending").includes('http-equiv="refresh"'), false, "默认不刷新(刷新会丢展开态)");
188
+ assert.match(renderPage(state, "pending", { extras: { live: true, open: "all", only: null } }), /http-equiv="refresh" content="5; url=\/\?view=pending/);
189
+ // live 只对待处理页有意义:别的页给了也忽略
190
+ assert.equal(
191
+ renderPage(state, "done", { extras: { live: true, open: "all", only: null } }).includes('http-equiv="refresh"'),
192
+ false,
193
+ "看档案时刷新会把页面跳回去",
194
+ );
180
195
  });
181
196
 
182
197
  test("待处理页:运行中的条目有自己的徽章,不是只给个数字", () => {
183
198
  const state = emptyState();
184
- state.pending.push({ runId: "run-live", kind: "running", skill: "", at: 0, detail: "阶段:复盘产出中" });
199
+ state.pending.push({ runId: "run-live", kind: "running", skill: "", at: 0, detail: "阶段:复盘产出中", session: NO_SESSION });
185
200
  const html = renderPage(state, "pending");
186
- assert.match(html, /\[进行中\] run-live/);
201
+ assert.match(html, /<span class="badge yellow" title="阶段:复盘产出中">复盘中<\/span>/);
187
202
  });
188
203
 
189
204
  test("复盘报告:卡死的 run 行首标 [卡死],detail 里带失败原因", () => {
@@ -192,6 +207,7 @@ test("复盘报告:卡死的 run 行首标 [卡死],detail 里带失败原
192
207
  runId: "run-dead",
193
208
  status: "stale",
194
209
  trigger: "auto",
210
+ session: NO_SESSION,
195
211
  startedAt: 0,
196
212
  findings: { total: 0, rows: [] },
197
213
  proposal: null,
@@ -200,19 +216,19 @@ test("复盘报告:卡死的 run 行首标 [卡死],detail 里带失败原
200
216
  verify: null,
201
217
  failure: "原因:子进程已退出\n日志末行:Error: 模板缺失",
202
218
  });
203
- const html = renderPage(state, "reports");
204
- assert.match(html, /\[卡死\] <a class="run" href="\/\?view=run&amp;runId=run-dead">run-dead<\/a>/);
219
+ const html = renderPage(state, "reports", { canWrite: true, csrf: "t", now: NOW });
220
+ assert.match(html, /<span class="badge red" title="[^"]*">卡死<\/span>/);
205
221
  assert.match(html, /原因:子进程已退出/);
206
222
  assert.match(html, /日志末行:Error: 模板缺失/);
223
+ // 卡死的 run 才有「清理」(移到 runs/.trash/,不删数据)
224
+ assert.match(html, /name="name" value="cleanup"/);
207
225
  });
208
226
 
209
227
  test("待审批页:条目上是 targets / 提案增删行 / verdict 与 blocking 条数", () => {
210
228
  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");
229
+ assert.match(html, /<span class="badge blue" title="approval 级(必须人批) · 目标:\.pi\/skills\/demo\/SKILL\.md · 评审:verdict=pass · blocking 0 条">待审批<\/span>/);
230
+ assert.match(html, /<td class="delta">\+3 \/ -1<\/td>/);
231
+ assert.match(html, /<a class="rid" href="\/\?view=run&amp;runId=run-a" title="run-a">详情<\/a>/);
216
232
  });
217
233
 
218
234
  test("待审批页:注入回调 + csrf 时渲染批准与拒绝表单,csrf 与 runId 都是 hidden", () => {
@@ -244,10 +260,10 @@ test("待审批页脚:批准走同一套五道闸,拒绝不动任何技能
244
260
  assert.match(html, /「拒绝」只改队列状态,不动任何技能文件/);
245
261
  });
246
262
 
247
- test("待处理页脚:保留只读口径,并指向待审批页", () => {
248
- const html = renderPage(emptyState(), "pending");
249
- assert.match(html, /只读面板/);
250
- assert.match(html, /去<a href="\/\?view=approvals">待审批页<\/a>直接批/);
263
+ test("待审批页脚仍然写清「批准」等价于哪条 TUI 命令", () => {
264
+ const html = renderPage(withApproval(), "approvals", { canWrite: true, csrf: "t" });
265
+ assert.match(html, /在 TUI 敲 <code>\/skill-evolve approve &lt;runId&gt;<\/code>/);
266
+ assert.match(html, /机器可读副本:<a href="\/api\/state">/);
251
267
  });
252
268
 
253
269
  test("动作结果横幅:msg 与 err 都渲染且转义", () => {
@@ -58,7 +58,10 @@ test("GET /:真监听 127.0.0.1,返回四个 tab 与队列里的待批条目
58
58
  assert.match(html, /待审批/);
59
59
  assert.match(html, /已处理/);
60
60
  assert.match(html, /复盘报告/);
61
- assert.match(html, /\[待批\] run-web · demo/);
61
+ // 列表页重做后行内不再有「[待批] run-web · demo」:状态是徽章,runId 在操作列
62
+ assert.match(html, /<span class="badge blue"[^>]*>待审批<\/span>/);
63
+ assert.match(html, /<td class="skill"><code>demo<\/code><\/td>/);
64
+ assert.match(html, /<a class="rid" href="\/\?view=run&amp;runId=run-web"[^>]*>详情<\/a>/);
62
65
  } finally {
63
66
  await panel.close();
64
67
  }