pi-skill-evolve 0.1.4 → 0.1.6
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/aggregate.ts +153 -0
- package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
- package/extensions/skill-evolve/lib/dispatch.ts +32 -52
- package/extensions/skill-evolve/lib/evolution.ts +1 -0
- package/extensions/skill-evolve/lib/report.ts +4 -1
- package/extensions/skill-evolve/lib/runs.ts +35 -2
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
- package/extensions/skill-evolve/lib/telemetry.ts +2 -0
- package/extensions/skill-evolve/lib/web/applied.ts +4 -0
- package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
- package/extensions/skill-evolve/lib/web/data.ts +13 -226
- package/extensions/skill-evolve/lib/web/done.ts +65 -0
- package/extensions/skill-evolve/lib/web/filter.ts +4 -4
- 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 +191 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
- package/extensions/skill-evolve/lib/web/render-rows.ts +178 -205
- package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
- package/extensions/skill-evolve/lib/web/render-session.ts +138 -0
- 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/reports.ts +11 -5
- package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
- package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
- package/extensions/skill-evolve/lib/web/server.ts +21 -1
- package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
- package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
- package/extensions/skill-evolve/lib/web/session-view.ts +276 -0
- package/extensions/skill-evolve/lib/web/shell.ts +42 -11
- package/extensions/skill-evolve/lib/web/style.ts +171 -89
- package/extensions/skill-evolve/lib/web/text.ts +57 -0
- package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
- package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
- package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
- package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
- package/extensions/skill-evolve/test/session-page.test.ts +200 -0
- package/extensions/skill-evolve/test/session-render.test.ts +203 -0
- package/extensions/skill-evolve/test/session-view.test.ts +180 -0
- 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 +139 -0
- package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 列表页的「会话」标识:五个视图(待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告)都必须能看出这条 run 属于哪段对话,
|
|
3
|
+
* 且文案一致 —— 显示 sessionId 前 8 位,完整 id 放 title,点进 `?view=session`。
|
|
4
|
+
*
|
|
5
|
+
* 为什么必须每页都有(用户 2026-11-08):「面板展示的也应该是 pi sessionId 否则我都不知道怎么看对应会话」。
|
|
6
|
+
* 重做列表页后会话不再是一列:同一会话的多条 run 归到组标题(`<details>` 的 summary)里,
|
|
7
|
+
* 单条会话的 run 把会话名写在状态格的小字里 —— 两处都由 render-rows.ts 产出,本文件把这条约定钉住。
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import type { AppliedItem } from "../lib/web/applied.ts";
|
|
12
|
+
import type { ApprovalItem } from "../lib/web/approvals.ts";
|
|
13
|
+
import type { DoneItem, PendingItem, WebState } from "../lib/web/data.ts";
|
|
14
|
+
import type { RunReport } from "../lib/web/reports.ts";
|
|
15
|
+
import { renderPage } from "../lib/web/render.ts";
|
|
16
|
+
import type { SessionRef } from "../lib/web/run-session.ts";
|
|
17
|
+
|
|
18
|
+
const NOW = new Date(2026, 10, 8, 12).getTime();
|
|
19
|
+
const SESSION_ID = "01a1b90f-08c3-722c-871d-d024b083770a";
|
|
20
|
+
const SESSION: SessionRef = { sessionId: SESSION_ID, sessionFile: `/tmp/${SESSION_ID}.jsonl` };
|
|
21
|
+
const NO_SESSION: SessionRef = { sessionId: null, sessionFile: null };
|
|
22
|
+
|
|
23
|
+
function state(parts: Partial<WebState> = {}): WebState {
|
|
24
|
+
return {
|
|
25
|
+
pending: [],
|
|
26
|
+
approvals: [],
|
|
27
|
+
applied: [],
|
|
28
|
+
done: [],
|
|
29
|
+
reports: [],
|
|
30
|
+
summary: { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 },
|
|
31
|
+
...parts,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pendingItem(session: SessionRef): PendingItem {
|
|
36
|
+
return { runId: "run-p-1794133942", kind: "reviewed", skill: "demo", at: NOW, detail: "", session };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function approvalItem(session: SessionRef): ApprovalItem {
|
|
40
|
+
return {
|
|
41
|
+
runId: "run-b-1794133942",
|
|
42
|
+
session,
|
|
43
|
+
skill: "demo",
|
|
44
|
+
level: "approval",
|
|
45
|
+
targets: [".pi/skills/demo/SKILL.md"],
|
|
46
|
+
createdAt: NOW,
|
|
47
|
+
proposal: { type: "improve", added: 3, removed: 1 },
|
|
48
|
+
review: { verdict: "pass", blocking: 0 },
|
|
49
|
+
detail: "",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function appliedItem(session: SessionRef): AppliedItem {
|
|
54
|
+
return {
|
|
55
|
+
runId: "run-a-1794133942",
|
|
56
|
+
session,
|
|
57
|
+
level: "approval",
|
|
58
|
+
skill: "demo",
|
|
59
|
+
type: "improve",
|
|
60
|
+
added: 3,
|
|
61
|
+
removed: 1,
|
|
62
|
+
appliedAt: NOW,
|
|
63
|
+
gates: [],
|
|
64
|
+
gatesOk: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function doneItem(session: SessionRef): DoneItem {
|
|
69
|
+
return { runId: "run-d-1794133942", status: "rejected", source: "run", level: "-", skill: "demo", at: NOW, detail: "", session };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function reportItem(session: SessionRef): RunReport {
|
|
73
|
+
return {
|
|
74
|
+
runId: "run-r-1794133942",
|
|
75
|
+
status: "reviewed",
|
|
76
|
+
trigger: "auto",
|
|
77
|
+
session,
|
|
78
|
+
startedAt: NOW,
|
|
79
|
+
findings: { total: 0, rows: [] },
|
|
80
|
+
proposal: null,
|
|
81
|
+
review: null,
|
|
82
|
+
decision: null,
|
|
83
|
+
verify: null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 五个视图各塞一条带会话的行,返回整页 HTML */
|
|
88
|
+
function pageWithSession(): Record<string, string> {
|
|
89
|
+
const current = state({
|
|
90
|
+
pending: [pendingItem(SESSION)],
|
|
91
|
+
approvals: [approvalItem(SESSION)],
|
|
92
|
+
applied: [appliedItem(SESSION)],
|
|
93
|
+
done: [doneItem(SESSION)],
|
|
94
|
+
reports: [reportItem(SESSION)],
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
pending: renderPage(current, "pending", { now: NOW }),
|
|
98
|
+
approvals: renderPage(current, "approvals", { now: NOW }),
|
|
99
|
+
applied: renderPage(current, "applied", { now: NOW }),
|
|
100
|
+
done: renderPage(current, "done", { now: NOW }),
|
|
101
|
+
reports: renderPage(current, "reports", { now: NOW }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test("五个视图都能看到会话:链接指向 ?view=session,文案是 sessionId 前 8 位,title 是完整 id", () => {
|
|
106
|
+
const pages = pageWithSession();
|
|
107
|
+
const href = `/?view=session&sessionId=${SESSION_ID}`;
|
|
108
|
+
for (const [view, html] of Object.entries(pages)) {
|
|
109
|
+
assert.equal(html.includes(href), true, `${view} 页缺会话链接`);
|
|
110
|
+
assert.match(html, /会话 01a1b90f/, `${view} 页要显示会话前 8 位`);
|
|
111
|
+
assert.equal(html.includes(`title="${SESSION_ID}"`), true, `${view} 页 title 要放完整 sessionId`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("单条会话的 run 不进组:会话名写在状态格的小字里,runId 只出现在操作列", () => {
|
|
116
|
+
const html = renderPage(state({ pending: [pendingItem(SESSION)] }), "pending", { now: NOW });
|
|
117
|
+
assert.match(html, /<span class="sk-sess"><a class="sess"/, "会话名应在状态格的小字里");
|
|
118
|
+
assert.equal(html.includes('<details class="grp"'), false, "只有一条 run 时不该出现会话分组");
|
|
119
|
+
// runId 不再是主标识:只在操作列,显示末 8 位,title 放完整值
|
|
120
|
+
assert.match(html, /<td class="ops">.*title="run-p-1794133942"/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("没有 sessionId 的老 run:写「会话 —」占位,不造空链接、不报错", () => {
|
|
124
|
+
const html = renderPage(state({ pending: [pendingItem(NO_SESSION)], done: [doneItem(NO_SESSION)] }), "pending", {
|
|
125
|
+
now: NOW,
|
|
126
|
+
});
|
|
127
|
+
assert.match(html, /<span class="sk-sess">会话 —<\/span>/);
|
|
128
|
+
assert.equal(html.includes("view=session"), false, "缺 id 时不许出现会话链接");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("会话链接带 file 参数:页面可直接打开声明过的会话文件,不必再反查", () => {
|
|
132
|
+
const html = renderPage(state({ pending: [pendingItem(SESSION)] }), "pending", { now: NOW });
|
|
133
|
+
assert.match(html, new RegExp(`file=%2Ftmp%2F${SESSION_ID}\\.jsonl`));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("同一会话的多条 run 归到组标题:会话名出现在 details 的 summary 里", () => {
|
|
137
|
+
const html = renderPage(state({ pending: [pendingItem(SESSION), pendingItem(SESSION)] }), "pending", { now: NOW });
|
|
138
|
+
assert.match(html, /<details class="grp" open>\n<summary><a class="sess" href="\/\?view=session&sessionId=/);
|
|
139
|
+
});
|