software-design-test 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/cordis.patch.yml +23 -0
- package/docs/FRAMEWORK.zh-en.md +149 -0
- package/docs/INSTALL.zh-en.md +226 -0
- package/docs/USAGE.zh-en.md +295 -0
- package/icon.svg +21 -0
- package/lib/index.js +197 -0
- package/lib/self-check.js +313 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +80 -0
- package/scripts/capture.mjs +318 -0
- package/scripts/guard.mjs +311 -0
- package/scripts/report.mjs +484 -0
- package/scripts/session.mjs +709 -0
- package/scripts/verify.mjs +205 -0
- package/skills/observed-test-plan/MATRIX.md +99 -0
- package/skills/observed-test-plan/PERMISSIONS.md +109 -0
- package/skills/observed-test-plan/PLAN-TEMPLATE.md +93 -0
- package/skills/observed-test-plan/SKILL.md +135 -0
- package/skills/observed-ui-test/BANNED-INPUTS.md +161 -0
- package/skills/observed-ui-test/EVIDENCE.md +113 -0
- package/skills/observed-ui-test/FRAMEWORK.md +181 -0
- package/skills/observed-ui-test/LEVELS.md +137 -0
- package/skills/observed-ui-test/REPORT-TEMPLATE.md +111 -0
- package/skills/observed-ui-test/SKILL.md +210 -0
- package/skills/software-design-test/DEFECTS.md +205 -0
- package/skills/software-design-test/HEURISTICS.md +236 -0
- package/skills/software-design-test/PERSONAS-SCENARIOS.md +159 -0
- package/skills/software-design-test/SKILL.md +211 -0
- package/skills/software-design-test/SOURCES.md +118 -0
- package/skills/software-design-test/TEST-CONTENT.md +257 -0
- package/skills/software-design-test/WORKFLOW.md +293 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* report.mjs — assemble a bilingual observed-UI-test report from a session directory.
|
|
4
|
+
*
|
|
5
|
+
* Input: session.json (permission gate + scope) and findings.jsonl (one finding per line).
|
|
6
|
+
* Output: report.md, regenerated. Nothing here detects assertions for you: the report says
|
|
7
|
+
* "unverified" wherever the evidence is missing, exactly as R5 requires.
|
|
8
|
+
*
|
|
9
|
+
* 读取 session.json 与 findings.jsonl,生成中英对照报告。证据不足一律写成"未验证"。
|
|
10
|
+
*
|
|
11
|
+
* Usage / 用法:
|
|
12
|
+
* node scripts/report.mjs build <session> [--out report.md] [--stdout]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from 'node:fs'
|
|
16
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { join, resolve } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { parseArgs } from 'node:util'
|
|
20
|
+
|
|
21
|
+
const SEVERITIES = ['S1', 'S2', 'S3', 'S4', 'U']
|
|
22
|
+
const KINDS = ['defect', 'observation', 'suspicion', 'unverified']
|
|
23
|
+
const LEVELS = ['L1', 'L2', 'L3']
|
|
24
|
+
|
|
25
|
+
const SEVERITY_LABEL = {
|
|
26
|
+
S1: 'S1 阻断 Blocking',
|
|
27
|
+
S2: 'S2 严重 Major',
|
|
28
|
+
S3: 'S3 次要 Minor',
|
|
29
|
+
S4: 'S4 打磨 Polish',
|
|
30
|
+
U: 'U 无法判定 Undetermined',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const KIND_LABEL = {
|
|
34
|
+
defect: '缺陷 Defect',
|
|
35
|
+
observation: '观察 Observation',
|
|
36
|
+
suspicion: '疑点 Suspicion',
|
|
37
|
+
unverified: '未验证 Unverified',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isMain = () =>
|
|
41
|
+
process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
42
|
+
|
|
43
|
+
const tri = (value) => (value === null || value === undefined ? '(未答 unanswered)' : value ? 'yes' : 'no')
|
|
44
|
+
|
|
45
|
+
function countBy(items, key, keys) {
|
|
46
|
+
const result = Object.fromEntries(keys.map((k) => [k, 0]))
|
|
47
|
+
for (const item of items) {
|
|
48
|
+
const value = item[key]
|
|
49
|
+
if (value !== undefined && result[value] !== undefined) result[value] += 1
|
|
50
|
+
else result[value] = (result[value] ?? 0) + 1
|
|
51
|
+
}
|
|
52
|
+
return result
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readFindings(dir) {
|
|
56
|
+
const file = join(dir, 'findings.jsonl')
|
|
57
|
+
if (!existsSync(file)) return []
|
|
58
|
+
const rows = []
|
|
59
|
+
for (const line of (await readFile(file, 'utf8')).split('\n')) {
|
|
60
|
+
const text = line.trim()
|
|
61
|
+
if (text.length > 0) rows.push(JSON.parse(text))
|
|
62
|
+
}
|
|
63
|
+
return rows
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readEvidenceIndex(dir) {
|
|
67
|
+
const file = join(dir, 'evidence', 'index.jsonl')
|
|
68
|
+
if (!existsSync(file)) return []
|
|
69
|
+
const rows = []
|
|
70
|
+
for (const line of (await readFile(file, 'utf8')).split('\n')) {
|
|
71
|
+
const text = line.trim()
|
|
72
|
+
if (text.length > 0) rows.push(JSON.parse(text))
|
|
73
|
+
}
|
|
74
|
+
return rows
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Injection-watchdog records written by guard.mjs. */
|
|
78
|
+
async function readCompliance(dir) {
|
|
79
|
+
const file = join(dir, 'evidence', 'compliance.jsonl')
|
|
80
|
+
if (!existsSync(file)) return []
|
|
81
|
+
const rows = []
|
|
82
|
+
for (const line of (await readFile(file, 'utf8')).split('\n')) {
|
|
83
|
+
const text = line.trim()
|
|
84
|
+
if (text.length > 0) rows.push(JSON.parse(text))
|
|
85
|
+
}
|
|
86
|
+
return rows
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Count data rows in the FIRST markdown table of a file (0 when the file is absent).
|
|
91
|
+
* Stops at the first non-table line after the table body starts, so a later table in the same
|
|
92
|
+
* document (for example content.md's measurement table) is not added to the count.
|
|
93
|
+
*/
|
|
94
|
+
async function countTableRows(file) {
|
|
95
|
+
if (!existsSync(file)) return 0
|
|
96
|
+
const lines = (await readFile(file, 'utf8')).split('\n')
|
|
97
|
+
let state = 'seek'
|
|
98
|
+
let rows = 0
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
const isRow = line.trim().startsWith('|')
|
|
101
|
+
if (state === 'seek') {
|
|
102
|
+
if (isRow) state = 'header'
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
if (state === 'header') {
|
|
106
|
+
state = 'body'
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (!isRow) break
|
|
110
|
+
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue
|
|
111
|
+
rows += 1
|
|
112
|
+
}
|
|
113
|
+
return rows
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function render(session, findings, evidence, planned, compliance = []) {
|
|
117
|
+
const bySeverity = countBy(findings, 'severity', SEVERITIES)
|
|
118
|
+
const byKind = countBy(findings, 'kind', KINDS)
|
|
119
|
+
const byLevel = countBy(findings, 'level', LEVELS)
|
|
120
|
+
const gate = session.gate
|
|
121
|
+
const out = []
|
|
122
|
+
let sectionNo = 0
|
|
123
|
+
/** Push a numbered bilingual section heading plus its blank line. */
|
|
124
|
+
const section = (title) => {
|
|
125
|
+
sectionNo += 1
|
|
126
|
+
out.push(`## ${sectionNo}. ${title}`)
|
|
127
|
+
out.push('')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
out.push('# 观察式界面测试报告 / Observed UI Test Report')
|
|
131
|
+
out.push('')
|
|
132
|
+
if (!gate.confirmed) {
|
|
133
|
+
out.push(
|
|
134
|
+
'> **警告 / WARNING**:权限闸门未通过,本报告不构成测试结论。' +
|
|
135
|
+
'The permission gate is not confirmed; this report is not a test verdict.',
|
|
136
|
+
)
|
|
137
|
+
out.push('>')
|
|
138
|
+
for (const blocker of gate.blockers ?? []) out.push(`> - 阻塞 / blocker:${blocker}`)
|
|
139
|
+
out.push('')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
section('基本信息 / Basics')
|
|
143
|
+
out.push('| 项目 Item | 内容 Value |')
|
|
144
|
+
out.push('| --- | --- |')
|
|
145
|
+
out.push(`| 被测应用 App | ${session.app} |`)
|
|
146
|
+
out.push(`| 平台 Platform | ${session.platform} |`)
|
|
147
|
+
out.push(`| 级别 Levels | ${session.levels.join(', ')} |`)
|
|
148
|
+
out.push(`| 操作者 Operator | ${session.tester || '(未填 unset)'} |`)
|
|
149
|
+
out.push(`| 会话创建 Created | ${session.createdAt} |`)
|
|
150
|
+
out.push(`| 报告生成 Generated | ${new Date().toISOString()} |`)
|
|
151
|
+
out.push('')
|
|
152
|
+
|
|
153
|
+
section('权限记录 / Permission record')
|
|
154
|
+
out.push('| 权限 Permission | 用户答复 Answer |')
|
|
155
|
+
out.push('| --- | --- |')
|
|
156
|
+
out.push(`| 鼠标 Mouse | ${tri(gate.mouse)} |`)
|
|
157
|
+
out.push(`| 键盘 Keyboard | ${gate.keyboard ?? '(未答)'} |`)
|
|
158
|
+
out.push(`| 屏幕录制 Screen recording | ${tri(gate.screenRecording)} |`)
|
|
159
|
+
out.push(`| 录屏含光标 Cursor in recording | ${tri(gate.cursorIncluded)} |`)
|
|
160
|
+
out.push(`| 截屏 Screenshot | ${tri(gate.screenshot)} |`)
|
|
161
|
+
out.push(`| 麦克风 Microphone | ${tri(gate.microphone)} |`)
|
|
162
|
+
out.push(`| 系统捕获权限 OS capture permission | ${tri(gate.osPermission)} |`)
|
|
163
|
+
out.push(`| 合规确认 Compliance | ${tri(gate.complianceAcknowledged)} |`)
|
|
164
|
+
out.push(`| 数据边界 Data boundary | ${gate.dataBoundary || '(未填 unset)'} |`)
|
|
165
|
+
out.push(`| 闸门结果 Gate | ${gate.confirmed ? '通过 CONFIRMED' : '未通过 NOT CONFIRMED'} |`)
|
|
166
|
+
out.push('')
|
|
167
|
+
const gaps = []
|
|
168
|
+
if (gate.screenRecording === false) gaps.push('无录屏 → 过程类缺陷无法取证 / no recording')
|
|
169
|
+
if (gate.screenshot === false) gaps.push('无截屏 → 无法留证 / no screenshots')
|
|
170
|
+
if (gate.keyboard === 'L1') gaps.push('仅 L1 → L2/L3 未覆盖 / L1 only, L2/L3 uncovered')
|
|
171
|
+
if (gate.microphone === false) gaps.push('无麦克风 → 时间点靠手写 / no narration')
|
|
172
|
+
if (gaps.length > 0) {
|
|
173
|
+
out.push('**覆盖缺口 / Coverage gaps:**')
|
|
174
|
+
for (const gap of gaps) out.push(`- ${gap}`)
|
|
175
|
+
out.push('')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
section('覆盖 / Coverage')
|
|
179
|
+
out.push('| 维度 Dimension | 计划 Planned | 实际 Actual |')
|
|
180
|
+
out.push('| --- | --- | --- |')
|
|
181
|
+
out.push(`| 元素 Elements | ${planned.elements ?? 0} | (由观察者核对 / verify by observer) |`)
|
|
182
|
+
out.push(`| 用例 Cases | ${planned.cases ?? 0} | ${findings.length} 条发现 findings |`)
|
|
183
|
+
out.push(`| 模式 Modes | ${session.levels.join(' / ')} | ${LEVELS.map((l) => `${l}=${byLevel[l] ?? 0}`).join(' ')} |`)
|
|
184
|
+
out.push(`| 证据 Evidence | - | ${evidence.length} 个文件 files |`)
|
|
185
|
+
out.push('')
|
|
186
|
+
|
|
187
|
+
section('用户模拟覆盖 / User-simulation coverage')
|
|
188
|
+
const personas = [...new Set(findings.map((item) => item.persona).filter(Boolean))]
|
|
189
|
+
const scenarios = [...new Set(findings.map((item) => item.scenario).filter(Boolean))]
|
|
190
|
+
const outcomes = countBy(
|
|
191
|
+
findings.filter((item) => item.task_outcome),
|
|
192
|
+
'task_outcome',
|
|
193
|
+
['pass', 'partial', 'fail', 'blocked', 'unverified'],
|
|
194
|
+
)
|
|
195
|
+
out.push('| 维度 Dimension | 计划 Planned | 实际 Actual |')
|
|
196
|
+
out.push('| --- | --- | --- |')
|
|
197
|
+
out.push(`| 人物 Personas | ${planned.personas ?? 0} | ${personas.length}${personas.length > 0 ? `:${personas.join(' · ')}` : ''} |`)
|
|
198
|
+
out.push(`| 场景 Scenarios | ${planned.scenarios ?? 0} | ${scenarios.length}${scenarios.length > 0 ? `:${scenarios.join(' · ')}` : ''} |`)
|
|
199
|
+
out.push(`| 旅程检查点 Journey checkpoints | ${planned.journeySteps ?? 0} | (由观察者核对 / verify by observer) |`)
|
|
200
|
+
out.push(`| 测试内容类别 Content areas | ${planned.contentAreas ?? 0} | (见 content.md / see content.md) |`)
|
|
201
|
+
out.push('')
|
|
202
|
+
out.push(
|
|
203
|
+
`任务结果 Task outcomes: ${['pass', 'partial', 'fail', 'blocked', 'unverified']
|
|
204
|
+
.map((key) => `${key}=${outcomes[key] ?? 0}`)
|
|
205
|
+
.join(' · ')}`,
|
|
206
|
+
)
|
|
207
|
+
out.push('')
|
|
208
|
+
if (personas.length > 0) {
|
|
209
|
+
out.push('| 人物 Persona | 发现 Findings | 最坏级别 Worst severity |')
|
|
210
|
+
out.push('| --- | --- | --- |')
|
|
211
|
+
for (const persona of personas) {
|
|
212
|
+
const owned = findings.filter((item) => item.persona === persona)
|
|
213
|
+
const order = ['S1', 'S2', 'S3', 'S4', 'U']
|
|
214
|
+
const worst = order.find((severity) => owned.some((item) => item.severity === severity)) ?? '—'
|
|
215
|
+
out.push(`| ${persona} | ${owned.map((item) => item.id).join(', ')} | ${worst} |`)
|
|
216
|
+
}
|
|
217
|
+
out.push('')
|
|
218
|
+
}
|
|
219
|
+
out.push(
|
|
220
|
+
'> 人物与场景只是**观察的架子**:模拟出来的人物不能当成真实用户证据,' +
|
|
221
|
+
'一条缺陷仍然必须由画面与真实操作步骤支撑。',
|
|
222
|
+
)
|
|
223
|
+
out.push(
|
|
224
|
+
'> Personas and scenarios are **scaffolding for observation** only: a simulated persona is never ' +
|
|
225
|
+
'evidence about real users, and every defect still needs a picture and real steps.',
|
|
226
|
+
)
|
|
227
|
+
out.push('')
|
|
228
|
+
|
|
229
|
+
section('测试内容分布 / Content coverage')
|
|
230
|
+
const areas = [...new Set(findings.map((item) => item.content).filter(Boolean))]
|
|
231
|
+
if (areas.length === 0) {
|
|
232
|
+
out.push('(尚无带 content 字段的发现 / no finding carries a content area yet)')
|
|
233
|
+
out.push('')
|
|
234
|
+
out.push('> 每条违反落盘时带上 `content`(类别,如「窗口与界面尺寸 Window size」)与 `criteria`(判据),')
|
|
235
|
+
out.push('> 这一节会直接告诉你问题集中在哪一类。')
|
|
236
|
+
out.push('')
|
|
237
|
+
} else {
|
|
238
|
+
out.push('| 测试内容 Content area | 发现 Findings | 最坏级别 Worst severity | 判据 Criteria |')
|
|
239
|
+
out.push('| --- | --- | --- | --- |')
|
|
240
|
+
const order = ['S1', 'S2', 'S3', 'S4', 'U']
|
|
241
|
+
for (const area of areas) {
|
|
242
|
+
const owned = findings.filter((item) => item.content === area)
|
|
243
|
+
const worst = order.find((severity) => owned.some((item) => item.severity === severity)) ?? '—'
|
|
244
|
+
const criteria = [...new Set(owned.map((item) => item.criteria).filter(Boolean))].join(';')
|
|
245
|
+
out.push(`| ${area} | ${owned.map((item) => item.id).join(', ')} | ${worst} | ${criteria || '—'} |`)
|
|
246
|
+
}
|
|
247
|
+
out.push('')
|
|
248
|
+
const uncovered = Math.max(0, (planned.contentAreas ?? 0) - areas.length)
|
|
249
|
+
out.push(
|
|
250
|
+
`覆盖 / Coverage: ${areas.length} 类有发现,${uncovered} 类未登记(未登记 ≠ 已通过)。` +
|
|
251
|
+
' / areas with findings vs areas not logged — not logged is not a pass.',
|
|
252
|
+
)
|
|
253
|
+
out.push('')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
section('结论摘要 / Verdict summary')
|
|
257
|
+
out.push('| 级别 Severity | 数量 Count |')
|
|
258
|
+
out.push('| --- | --- |')
|
|
259
|
+
for (const severity of SEVERITIES) {
|
|
260
|
+
out.push(`| ${SEVERITY_LABEL[severity]} | ${bySeverity[severity] ?? 0} |`)
|
|
261
|
+
}
|
|
262
|
+
out.push('')
|
|
263
|
+
out.push(`类型分类 / Kinds: ${KINDS.map((k) => `${KIND_LABEL[k]}=${byKind[k] ?? 0}`).join(' · ')}`)
|
|
264
|
+
out.push('')
|
|
265
|
+
const blocking = (bySeverity.S1 ?? 0) + (bySeverity.S2 ?? 0)
|
|
266
|
+
out.push(
|
|
267
|
+
blocking > 0
|
|
268
|
+
? `一句话结论 / Verdict: 存在 ${blocking} 条 S1/S2 问题,主流程或关键功能受影响。`
|
|
269
|
+
: findings.length === 0
|
|
270
|
+
? '一句话结论 / Verdict: 本会话尚无记录;这不等于通过。 / nothing recorded yet — which is not a pass.'
|
|
271
|
+
: '一句话结论 / Verdict: 未发现 S1/S2,详见下表。 / no S1/S2 found; see the table.',
|
|
272
|
+
)
|
|
273
|
+
out.push('')
|
|
274
|
+
|
|
275
|
+
section('发现一览 / Findings at a glance')
|
|
276
|
+
if (findings.length === 0) {
|
|
277
|
+
out.push('(无记录 / none recorded)')
|
|
278
|
+
} else {
|
|
279
|
+
out.push('| ID | 级别 Sev | 类型 Kind | 模式 | 平台 | 元素/工具 Element | 标题 Title | 证据 Evidence |')
|
|
280
|
+
out.push('| --- | --- | --- | --- | --- | --- | --- | --- |')
|
|
281
|
+
for (const item of findings) {
|
|
282
|
+
out.push(
|
|
283
|
+
`| ${item.id} | ${item.severity} | ${KIND_LABEL[item.kind] ?? item.kind} | ${item.level} | ` +
|
|
284
|
+
`${item.platform} | ${item.element} | ${item.title_zh || item.title_en} | ` +
|
|
285
|
+
`${(item.evidence ?? []).join('<br>') || '—'} |`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
out.push('')
|
|
290
|
+
|
|
291
|
+
section('发现详情 / Finding details')
|
|
292
|
+
for (const item of findings) {
|
|
293
|
+
out.push(`### ${item.id} · ${item.title_zh || item.title_en}`)
|
|
294
|
+
out.push('')
|
|
295
|
+
out.push(`- ${item.title_en || item.title_zh}`)
|
|
296
|
+
out.push(`- 级别 Severity:${item.severity} · 类型 Kind:${KIND_LABEL[item.kind] ?? item.kind} · 模式 Level:${item.level} · 平台 Platform:${item.platform}`)
|
|
297
|
+
out.push(`- 元素 Element:${item.element || '—'}`)
|
|
298
|
+
if (item.content) out.push(`- 测试内容 Content area:${item.content}`)
|
|
299
|
+
if (item.criteria) out.push(`- 判据 Criteria:${item.criteria}`)
|
|
300
|
+
if (item.persona) out.push(`- 人物 Persona:${item.persona}`)
|
|
301
|
+
if (item.scenario) out.push(`- 场景 Scenario:${item.scenario}`)
|
|
302
|
+
if (item.task_outcome) out.push(`- 任务结果 Task outcome:${item.task_outcome}`)
|
|
303
|
+
if (item.heuristic) out.push(`- 启发式 Heuristic:${item.heuristic}`)
|
|
304
|
+
if (item.tour) out.push(`- 巡游 Tour:${item.tour}`)
|
|
305
|
+
if (item.repro_rate) out.push(`- 复现率 Repro rate:${item.repro_rate}`)
|
|
306
|
+
if (item.minimized) out.push(`- 最小复现 Minimized:${item.minimized}`)
|
|
307
|
+
out.push(`- 记录时间 Recorded:${item.at}`)
|
|
308
|
+
out.push('')
|
|
309
|
+
if (item.steps_zh || item.steps_en) {
|
|
310
|
+
out.push('**步骤 Steps**')
|
|
311
|
+
out.push('')
|
|
312
|
+
if (item.steps_zh) out.push(`- 中文:${item.steps_zh}`)
|
|
313
|
+
if (item.steps_en) out.push(`- EN: ${item.steps_en}`)
|
|
314
|
+
out.push('')
|
|
315
|
+
}
|
|
316
|
+
if (item.expected_zh || item.expected_en) {
|
|
317
|
+
out.push('**期望 Expected**')
|
|
318
|
+
out.push('')
|
|
319
|
+
if (item.expected_zh) out.push(`- 中文:${item.expected_zh}`)
|
|
320
|
+
if (item.expected_en) out.push(`- EN: ${item.expected_en}`)
|
|
321
|
+
out.push('')
|
|
322
|
+
}
|
|
323
|
+
if (item.actual_zh || item.actual_en) {
|
|
324
|
+
out.push('**实际 Actual**')
|
|
325
|
+
out.push('')
|
|
326
|
+
if (item.actual_zh) out.push(`- 中文:${item.actual_zh}`)
|
|
327
|
+
if (item.actual_en) out.push(`- EN: ${item.actual_en}`)
|
|
328
|
+
out.push('')
|
|
329
|
+
}
|
|
330
|
+
if (item.modes) {
|
|
331
|
+
out.push(`**跨模式差异 Cross-mode**:${item.modes}`)
|
|
332
|
+
out.push('')
|
|
333
|
+
}
|
|
334
|
+
if (item.impact) out.push(`**影响 Impact**:${item.impact}`)
|
|
335
|
+
if (item.suggestion) out.push(`**建议 Suggestion**:${item.suggestion}`)
|
|
336
|
+
if (item.impact || item.suggestion) out.push('')
|
|
337
|
+
if ((item.evidence ?? []).length > 0) {
|
|
338
|
+
out.push('**画面证据 Visual evidence**')
|
|
339
|
+
out.push('')
|
|
340
|
+
for (const file of item.evidence) out.push(`- [${file}](${file})`)
|
|
341
|
+
out.push('')
|
|
342
|
+
} else {
|
|
343
|
+
out.push('**画面证据 Visual evidence**:无 / none — R5:无画面不得写成通过。')
|
|
344
|
+
out.push('')
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const unverified = findings.filter((item) => item.kind === 'unverified' || item.severity === 'U')
|
|
349
|
+
section('未验证项 / Unverified items')
|
|
350
|
+
if (unverified.length === 0) {
|
|
351
|
+
out.push('(本会话未登记未验证项;请核对权限缺口与未覆盖元素 / none logged — check the coverage gaps above)')
|
|
352
|
+
} else {
|
|
353
|
+
out.push('| ID | 项 Item | 原因 Reason | 需要什么 What is needed |')
|
|
354
|
+
out.push('| --- | --- | --- | --- |')
|
|
355
|
+
for (const item of unverified) {
|
|
356
|
+
out.push(`| ${item.id} | ${item.element || item.title_zh || item.title_en} | ${item.actual_zh || item.impact || '—'} | ${item.suggestion || '权限/设备/时间 / permission, device or time'} |`)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
out.push('')
|
|
360
|
+
|
|
361
|
+
section('证据索引 / Evidence index')
|
|
362
|
+
if (evidence.length === 0) {
|
|
363
|
+
out.push('(无证据文件 / no evidence files)')
|
|
364
|
+
} else {
|
|
365
|
+
out.push('| 时间 Time | 类型 Kind | 标签 Label | 文件 File | 备注 Note |')
|
|
366
|
+
out.push('| --- | --- | --- | --- | --- |')
|
|
367
|
+
for (const item of evidence) {
|
|
368
|
+
out.push(`| ${item.at} | ${item.kind} | ${item.label} | [${item.file}](${item.file}) | ${item.note || ''} |`)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
out.push('')
|
|
372
|
+
|
|
373
|
+
section('输入注入监控 / Injection watch')
|
|
374
|
+
out.push('> 本会话由 `guard.mjs` 只读观察输入注入线索:它**观察**注入指令,从不**执行**注入。')
|
|
375
|
+
out.push('> This session used `guard.mjs` to observe injection only: it **watches** injection directives and never performs one.')
|
|
376
|
+
out.push('')
|
|
377
|
+
if (compliance.length === 0) {
|
|
378
|
+
out.push('(未运行监控 / watchdog not run — 建议在测试前后各跑一次 `guard.mjs scan`)')
|
|
379
|
+
} else {
|
|
380
|
+
const processHits = compliance.filter((entry) => entry.kind === 'process')
|
|
381
|
+
const textHits = compliance.filter((entry) => entry.kind === 'plan-text' || entry.kind === 'finding-text')
|
|
382
|
+
const scans = compliance.filter((entry) => entry.kind === 'scan')
|
|
383
|
+
const watches = compliance.filter((entry) => entry.kind === 'watch')
|
|
384
|
+
out.push(`- 扫描次数 scans:${scans.length} · 监视轮次 watch runs:${watches.length}`)
|
|
385
|
+
out.push(`- 疑似注入进程量 suspected injection processes:${processHits.length}`)
|
|
386
|
+
out.push(`- 测试文本中出现的被禁指令 banned directives in session text:${textHits.length}`)
|
|
387
|
+
out.push('')
|
|
388
|
+
if (processHits.length > 0 || textHits.length > 0) {
|
|
389
|
+
out.push('| 时间 Time | 类型 Kind | 线索 Clue | 说明 Detail |')
|
|
390
|
+
out.push('| --- | --- | --- | --- |')
|
|
391
|
+
for (const hit of [...processHits, ...textHits]) {
|
|
392
|
+
out.push(
|
|
393
|
+
`| ${hit.at} | ${hit.kind} | ${hit.token} | ${hit.kind === 'process' ? `pid=${hit.pid} ${hit.command ?? ''}` : `${hit.file}:${hit.line}`} |`,
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
out.push('')
|
|
397
|
+
out.push('**需人工确认 / requires human confirmation**:这些是线索,不是定罪;确认后必须重跑受影响的用例。')
|
|
398
|
+
out.push('**These are leads, not convictions** — confirm by hand and re-run the affected cases.')
|
|
399
|
+
} else {
|
|
400
|
+
out.push('未发现注入线索。注意:这**不构成"绝对没有注入"的证明**,只说明观察期间没有线索。')
|
|
401
|
+
out.push('No injection clue was observed. This is **not proof of absence**, only the absence of a clue during observation.')
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
out.push('')
|
|
405
|
+
|
|
406
|
+
section('合规声明 / Compliance statement')
|
|
407
|
+
out.push('- 本次测试使用真实的鼠标与键盘操作,录屏与截屏是唯一证据来源。')
|
|
408
|
+
out.push('- **未使用任何内部指针指令**:无指针/触摸/按键注入,无自动化框架驱动,无内部句柄调用。')
|
|
409
|
+
out.push('- This run used real mouse and keyboard input with screen recording and screenshots as the only evidence source.')
|
|
410
|
+
out.push('- **No internal pointer directives were used**: no injected pointer, touch or key events, no automation drivers, no calls into software internals.')
|
|
411
|
+
out.push('')
|
|
412
|
+
|
|
413
|
+
section('修复建议顺序 / Suggested fix order')
|
|
414
|
+
out.push('| 顺序 Order | 缺陷 ID | 理由 Rationale | 复测用例 Retest case |')
|
|
415
|
+
out.push('| --- | --- | --- | --- |')
|
|
416
|
+
for (const item of findings.filter((entry) => entry.kind === 'defect')) {
|
|
417
|
+
out.push(`| ${item.severity} | ${item.id} | ${item.impact || '—'} | ${item.element || '—'} · ${item.level} |`)
|
|
418
|
+
}
|
|
419
|
+
out.push('')
|
|
420
|
+
out.push('> 修复后用**同一用例、同一模式**复测,并把结论追加到原条目。')
|
|
421
|
+
out.push('> Retest with the same case in the same mode and append the result to the original entry.')
|
|
422
|
+
out.push('')
|
|
423
|
+
|
|
424
|
+
return out.join('\n')
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function cmdBuild(positionals, values) {
|
|
428
|
+
const dir = resolve(positionals[0] ?? '')
|
|
429
|
+
if (!existsSync(join(dir, 'session.json'))) {
|
|
430
|
+
throw new Error(`not an observed-UI-test session (no session.json): ${dir}`)
|
|
431
|
+
}
|
|
432
|
+
const session = JSON.parse(await readFile(join(dir, 'session.json'), 'utf8'))
|
|
433
|
+
const findings = await readFindings(dir)
|
|
434
|
+
const evidence = await readEvidenceIndex(dir)
|
|
435
|
+
const compliance = await readCompliance(dir)
|
|
436
|
+
const planned = {
|
|
437
|
+
elements: await countTableRows(join(dir, 'elements.md')),
|
|
438
|
+
cases: await countTableRows(join(dir, 'matrix.md')),
|
|
439
|
+
personas: await countTableRows(join(dir, 'personas.md')),
|
|
440
|
+
scenarios: await countTableRows(join(dir, 'scenarios.md')),
|
|
441
|
+
journeySteps: await countTableRows(join(dir, 'journey.md')),
|
|
442
|
+
contentAreas: await countTableRows(join(dir, 'content.md')),
|
|
443
|
+
}
|
|
444
|
+
const markdown = render(session, findings, evidence, planned, compliance)
|
|
445
|
+
const target = values.out ? resolve(values.out) : join(dir, 'report.md')
|
|
446
|
+
if (values.stdout) {
|
|
447
|
+
process.stdout.write(markdown)
|
|
448
|
+
return 0
|
|
449
|
+
}
|
|
450
|
+
await writeFile(target, markdown, 'utf8')
|
|
451
|
+
process.stdout.write(`报告已生成 / report written: ${target}\n`)
|
|
452
|
+
process.stdout.write(
|
|
453
|
+
`发现 findings: ${findings.length} · 证据 evidence: ${evidence.length} · ` +
|
|
454
|
+
`注入监控 records: ${compliance.length} · ` +
|
|
455
|
+
`闸门 gate: ${session.gate.confirmed ? 'CONFIRMED' : 'NOT CONFIRMED'}\n`,
|
|
456
|
+
)
|
|
457
|
+
return 0
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function main() {
|
|
461
|
+
const { positionals, values } = parseArgs({
|
|
462
|
+
args: process.argv.slice(2),
|
|
463
|
+
allowPositionals: true,
|
|
464
|
+
options: {
|
|
465
|
+
out: { type: 'string' },
|
|
466
|
+
stdout: { type: 'boolean', default: false },
|
|
467
|
+
},
|
|
468
|
+
})
|
|
469
|
+
const command = positionals.shift() ?? 'build'
|
|
470
|
+
try {
|
|
471
|
+
if (command === 'build') return await cmdBuild(positionals, values)
|
|
472
|
+
process.stderr.write(`unknown command: ${command} (only "build" is supported)\n`)
|
|
473
|
+
return 2
|
|
474
|
+
} catch (error) {
|
|
475
|
+
process.stderr.write(`error: ${error.message}\n`)
|
|
476
|
+
return 2
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (isMain()) {
|
|
481
|
+
process.exitCode = await main()
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export { cmdBuild, render }
|