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.
- package/extensions/skill-evolve/lib/runs.ts +4 -1
- package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
- package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
- package/extensions/skill-evolve/lib/web/pending.ts +5 -5
- package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
- package/extensions/skill-evolve/lib/web/render-rows.ts +178 -235
- package/extensions/skill-evolve/lib/web/render-session.ts +105 -34
- package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
- package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
- package/extensions/skill-evolve/lib/web/render.ts +116 -54
- package/extensions/skill-evolve/lib/web/server.ts +9 -1
- package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
- package/extensions/skill-evolve/lib/web/session-view.ts +100 -84
- package/extensions/skill-evolve/lib/web/shell.ts +42 -11
- package/extensions/skill-evolve/lib/web/style.ts +171 -106
- package/extensions/skill-evolve/lib/web/text.ts +57 -0
- package/extensions/skill-evolve/test/pending-kinds.test.ts +142 -0
- package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
- package/extensions/skill-evolve/test/session-page.test.ts +11 -5
- package/extensions/skill-evolve/test/session-render.test.ts +203 -0
- package/extensions/skill-evolve/test/session-view.test.ts +75 -87
- package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
- package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
- package/extensions/skill-evolve/test/web-render.test.ts +57 -41
- package/extensions/skill-evolve/test/web-server.test.ts +4 -1
- package/extensions/skill-evolve/test/web-session-column.test.ts +30 -32
- package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
- package/package.json +1 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 「已采纳」单独成 tab:列表只列 applied,已处理页一条 applied 都不许有。
|
|
3
|
-
*
|
|
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("
|
|
49
|
-
const
|
|
50
|
-
assert.
|
|
51
|
-
assert.
|
|
52
|
-
assert.
|
|
53
|
-
assert.
|
|
54
|
-
assert.
|
|
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
|
|
59
|
-
|
|
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
|
|
68
|
-
assert.
|
|
69
|
-
|
|
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", "
|
|
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"
|
|
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&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, /<script>x\(\)<\/script>/);
|
|
50
54
|
});
|
|
51
55
|
|
|
52
|
-
test("
|
|
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, /<img src=x onerror=alert\(1\)>
|
|
74
|
+
assert.match(html, /<img src=x onerror=alert\(1\)>/, "转义后的原文仍在,便于核对");
|
|
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"
|
|
76
|
-
|
|
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"
|
|
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"
|
|
86
|
-
assert.equal(done.includes("
|
|
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"
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
assert.match(html,
|
|
148
|
-
assert.match(html,
|
|
149
|
-
assert.match(html,
|
|
150
|
-
assert.match(html,
|
|
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&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,
|
|
158
|
-
|
|
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("
|
|
185
|
+
test("自动刷新默认关闭:只有显式 &live=1 的待处理页才 5 秒重载", () => {
|
|
176
186
|
const state = emptyState();
|
|
177
|
-
assert.
|
|
178
|
-
assert.
|
|
179
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
212
|
-
assert.match(html,
|
|
213
|
-
assert.match(html,
|
|
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&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(
|
|
249
|
-
assert.match(html,
|
|
250
|
-
assert.match(html,
|
|
263
|
+
test("待审批页脚仍然写清「批准」等价于哪条 TUI 命令", () => {
|
|
264
|
+
const html = renderPage(withApproval(), "approvals", { canWrite: true, csrf: "t" });
|
|
265
|
+
assert.match(html, /在 TUI 敲 <code>\/skill-evolve approve <runId><\/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
|
-
|
|
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&runId=run-web"[^>]*>详情<\/a>/);
|
|
62
65
|
} finally {
|
|
63
66
|
await panel.close();
|
|
64
67
|
}
|