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,709 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* session.mjs — observed-UI-test session scaffolding, permission gate and findings log.
|
|
4
|
+
*
|
|
5
|
+
* This script only reads and writes files inside the session directory you name.
|
|
6
|
+
* It contains no input injection of any kind: no pointer, touch or key events, no
|
|
7
|
+
* accessibility control, no automation driver. It exists so the permission gate
|
|
8
|
+
* is answered *before* testing and every finding is on disk the moment it is seen.
|
|
9
|
+
*
|
|
10
|
+
* 只做三件事:建会话目录、记录权限闸门、落盘发现。绝不注入任何输入事件。
|
|
11
|
+
*
|
|
12
|
+
* Usage / 用法:
|
|
13
|
+
* node scripts/session.mjs init <dir> --platform macos --app "Name" [--levels L1,L2,L3]
|
|
14
|
+
* [--tester "name"] [--force]
|
|
15
|
+
* node scripts/session.mjs gate <dir> --mouse yes --keyboard L2 --screen-recording yes
|
|
16
|
+
* --screenshot yes [--microphone no] [--os-permission yes]
|
|
17
|
+
* [--cursor yes] [--data-boundary "..."]
|
|
18
|
+
* [--compliance yes]
|
|
19
|
+
* node scripts/session.mjs status <dir>
|
|
20
|
+
* node scripts/session.mjs finding <dir> --json '<json>' | --json @file.json | --stdin
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync } from 'node:fs'
|
|
24
|
+
import { appendFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
25
|
+
import { join, resolve } from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { parseArgs } from 'node:util'
|
|
28
|
+
|
|
29
|
+
const SEVERITIES = ['S1', 'S2', 'S3', 'S4', 'U']
|
|
30
|
+
const KINDS = ['defect', 'observation', 'suspicion', 'unverified']
|
|
31
|
+
const LEVELS = ['L1', 'L2', 'L3']
|
|
32
|
+
const PLATFORMS = ['macos', 'windows', 'ios', 'ipados', 'android', 'other']
|
|
33
|
+
|
|
34
|
+
const QUESTIONS_ZH = `## 权限问卷(先问,未答复不得开始测试)/ Permission questionnaire
|
|
35
|
+
|
|
36
|
+
1. **鼠标操作**:允许使用真实鼠标操作并据此判定吗?\`yes / no\`
|
|
37
|
+
2. **键盘操作**:允许使用键盘吗?到哪一级?\`L1 仅鼠标 / L2 鼠标+键盘禁快捷键 / L3 可用快捷键\`
|
|
38
|
+
3. **屏幕录制**:允许录屏吗?录屏是否包含光标?\`yes / no\`(含光标 / 不含)
|
|
39
|
+
4. **截屏**:允许随时截屏取证吗?\`yes / no\`
|
|
40
|
+
5. **麦克风**:允许口述操作解说以便对齐录屏时间点吗?\`yes / no\`
|
|
41
|
+
6. **系统捕获权限**:
|
|
42
|
+
- macOS:系统设置 → 隐私与安全性 → 屏幕录制(授予后需重启应用)
|
|
43
|
+
- Windows:设置 → 隐私和安全性 → 屏幕截图和录制 / 游戏栏
|
|
44
|
+
- iPhone / iPad:控制中心屏幕录制(长按可开麦克风)
|
|
45
|
+
7. **被测对象**:应用名、版本、平台、设备。
|
|
46
|
+
8. **范围与数据边界**:测哪些功能、哪些不测、哪些内容绝对不能录进画面。
|
|
47
|
+
9. **退出条件**:出现什么情况立即停止(崩溃 / 数据损坏 / 隐私泄露 / 误改生产数据)。
|
|
48
|
+
10. **合规确认**:本次测试不使用任何内部指针指令——不注入鼠标、触摸、按键事件,不驱动自动化
|
|
49
|
+
框架,不调用软件内部句柄;输入只来自你的真实外设,证据只来自录屏与截屏。\`yes / no\`
|
|
50
|
+
|
|
51
|
+
## English
|
|
52
|
+
|
|
53
|
+
1. **Mouse**: may I base findings on your real mouse actions? \`yes / no\`
|
|
54
|
+
2. **Keyboard**: allowed, and up to which mode? \`L1 mouse only / L2 no shortcuts / L3 shortcuts\`
|
|
55
|
+
3. **Screen recording**: allowed? Include the cursor? \`yes / no\` (with / without)
|
|
56
|
+
4. **Screenshots**: allowed as evidence at any time? \`yes / no\`
|
|
57
|
+
5. **Microphone**: allowed for spoken narration to line up the timeline? \`yes / no\`
|
|
58
|
+
6. **OS capture permission**: macOS Screen Recording (restart the app) / Windows Screenshots and
|
|
59
|
+
recording / iOS Control Centre screen recording.
|
|
60
|
+
7. **App under test**: name, version, platform, devices.
|
|
61
|
+
8. **Scope and data boundary**: features in, features out, anything that must never be recorded.
|
|
62
|
+
9. **Exit criteria**: crash, data loss, privacy leak, production damage.
|
|
63
|
+
10. **Compliance**: no internal pointer directives are used — no injected pointer, touch or key
|
|
64
|
+
events, no automation drivers, no calls into software internals. Input comes only from your real
|
|
65
|
+
peripherals; evidence comes only from screen recording and screenshots. \`yes / no\`
|
|
66
|
+
`
|
|
67
|
+
|
|
68
|
+
function usage() {
|
|
69
|
+
return `observed-ui-test session tool
|
|
70
|
+
|
|
71
|
+
init <dir> --platform <p> --app <name> [--levels L1,L2,L3] [--tester <name>] [--force]
|
|
72
|
+
gate <dir> --mouse yes|no --keyboard L1|L2|L3 --screen-recording yes|no --screenshot yes|no
|
|
73
|
+
[--microphone yes|no] [--os-permission yes|no] [--cursor yes|no]
|
|
74
|
+
[--data-boundary "..."] [--compliance yes|no]
|
|
75
|
+
status <dir>
|
|
76
|
+
finding <dir> --json '<json>' | --json @file | --stdin
|
|
77
|
+
help
|
|
78
|
+
|
|
79
|
+
Platforms: ${PLATFORMS.join(' | ')}
|
|
80
|
+
Severities: ${SEVERITIES.join(' | ')} Kinds: ${KINDS.join(' | ')}
|
|
81
|
+
`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const nowIso = () => new Date().toISOString()
|
|
85
|
+
const stamp = () => nowIso().replace(/[:.]/g, '-')
|
|
86
|
+
const sessionPath = (dir) => join(dir, 'session.json')
|
|
87
|
+
const findingsPath = (dir) => join(dir, 'findings.jsonl')
|
|
88
|
+
|
|
89
|
+
async function loadSession(dir) {
|
|
90
|
+
const file = sessionPath(dir)
|
|
91
|
+
if (!existsSync(file)) {
|
|
92
|
+
throw new Error(`not an observed-UI-test session (no session.json): ${dir}`)
|
|
93
|
+
}
|
|
94
|
+
return JSON.parse(await readFile(file, 'utf8'))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function saveSession(dir, session) {
|
|
98
|
+
session.updatedAt = nowIso()
|
|
99
|
+
await writeFile(sessionPath(dir), JSON.stringify(session, null, 2) + '\n', 'utf8')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function yesNo(value, flag) {
|
|
103
|
+
if (value === undefined) return undefined
|
|
104
|
+
const text = String(value).trim().toLowerCase()
|
|
105
|
+
if (['yes', 'y', 'true', '1', '是', '允许'].includes(text)) return true
|
|
106
|
+
if (['no', 'n', 'false', '0', '否', '拒绝'].includes(text)) return false
|
|
107
|
+
throw new Error(`--${flag} expects yes or no, got "${value}"`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeLevel(value) {
|
|
111
|
+
if (value === undefined) return undefined
|
|
112
|
+
const text = String(value).trim().toUpperCase()
|
|
113
|
+
const level = text.startsWith('L') ? text : `L${text}`
|
|
114
|
+
if (!LEVELS.includes(level)) throw new Error(`--keyboard expects L1, L2 or L3, got "${value}"`)
|
|
115
|
+
return level
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function elementsTemplate() {
|
|
119
|
+
return `# 元素清单 / Element inventory
|
|
120
|
+
|
|
121
|
+
> 只看画面建立:打开软件,逐个界面截图,抄下看得见的功能元素与工具。
|
|
122
|
+
> 不许打开代码、DOM 检查器或数据库来补全清单。
|
|
123
|
+
> Screen only: screenshot each surface and transcribe what is visible. Do not open code, a DOM
|
|
124
|
+
> inspector or a database to complete this list.
|
|
125
|
+
|
|
126
|
+
| ID | 界面 Surface | 元素/工具 Element or tool | 类型 Type | 期望状态 Expected states(默认·悬停·按下·选中·禁用·加载·错误·空) | 优先级 Priority | 证据 Evidence |
|
|
127
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
128
|
+
| E-01 | | | | | | |
|
|
129
|
+
`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function matrixTemplate(session) {
|
|
133
|
+
const platform = session.platform
|
|
134
|
+
return `# 用例矩阵 / Case matrix
|
|
135
|
+
|
|
136
|
+
> 矩阵 = 元素 × 平台 × 模式。P0/P1 必须三级全跑;跨模式差异本身就是结论。
|
|
137
|
+
> Matrix = element x platform x mode. P0/P1 must run at all three levels; the difference is a finding.
|
|
138
|
+
|
|
139
|
+
| 用例 ID | 元素 | 平台 Platform | 模式 Mode | 优先级 Priority | 前置 Preconditions | 操作步骤 Steps(该模式下) | 期望(画面上可见)Expected (on screen) |
|
|
140
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
141
|
+
| C-001 | E-01 | ${platform} | L1 | P0 | | | |
|
|
142
|
+
| C-001 | E-01 | ${platform} | L2 | P1 | | | |
|
|
143
|
+
| C-001 | E-01 | ${platform} | L3 | P1 | | | |
|
|
144
|
+
|
|
145
|
+
## 覆盖率 / Coverage
|
|
146
|
+
|
|
147
|
+
- 元素覆盖率 Element coverage:待填
|
|
148
|
+
- 模式覆盖率 Mode coverage:待填
|
|
149
|
+
- 平台覆盖率 Platform coverage:待填
|
|
150
|
+
- 用例执行率 Case execution:待填
|
|
151
|
+
`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function personasTemplate() {
|
|
155
|
+
return `# 人物 / Personas
|
|
156
|
+
|
|
157
|
+
> 人物是**观察的架子**,不是证据。每一个都要写出依据(访谈、工单、数据、设计目标),
|
|
158
|
+
> 不许凭空编造"典型用户"。模拟人物得出的结论不能当成真实用户结论。
|
|
159
|
+
> A persona is **scaffolding for observation**, not evidence. Every persona needs a stated source
|
|
160
|
+
> (interview, ticket, analytics, design goal). Never invent a "typical user", and never present a
|
|
161
|
+
> simulated persona's outcome as a fact about real users.
|
|
162
|
+
|
|
163
|
+
| ID | 人物 Persona | 目标/动机 Goal | 熟练度 Expertise | 设备与习惯 Device & habits | 可访问性需求 Accessibility needs | 依据 Source of evidence |
|
|
164
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
165
|
+
| P-01 | | | 新手/普通/专家 novice/regular/expert | | 无 / 键盘-only / 屏幕阅读器 / 动态字体 | |
|
|
166
|
+
|
|
167
|
+
## 人物纪律 / Persona discipline
|
|
168
|
+
|
|
169
|
+
- 至少覆盖:一个新手、一个高频专家、一个"只剩一只手/键盘-only"或视障用户。
|
|
170
|
+
- 不许把人物写成"什么都会、什么都不生气"的完人——那样找不到任何缺陷。
|
|
171
|
+
- 每个人物写一句"他会在哪里放弃"。
|
|
172
|
+
`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function scenariosTemplate() {
|
|
176
|
+
return `# 场景 / Scenarios
|
|
177
|
+
|
|
178
|
+
> 场景 = 人物 + 目标 + 触发条件 + 成功标准。场景要能被拆成**可执行的任务**。
|
|
179
|
+
> A scenario is persona + goal + trigger + success criteria, and it must decompose into executable
|
|
180
|
+
> tasks.
|
|
181
|
+
|
|
182
|
+
| ID | 人物 Persona | 场景 Story | 触发 Trigger | 成功标准 Success criteria | 途中检查点 Checkpoints |
|
|
183
|
+
| --- | --- | --- | --- | --- | --- |
|
|
184
|
+
| S-01 | P-01 | | | | |
|
|
185
|
+
|
|
186
|
+
## 场景来源 / Where scenarios come from
|
|
187
|
+
|
|
188
|
+
真实会话与工单 · 支持邮件 · 埋点里的失败漏斗 · 设计目标("用户应该能在 30 秒内导出")·
|
|
189
|
+
竞品对照 · 你自己的第一次使用体验。
|
|
190
|
+
`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function journeyTemplate() {
|
|
194
|
+
return `# 用户旅程 / User journey
|
|
195
|
+
|
|
196
|
+
> 沿时间轴走一遍,标出每一步的"期望 vs 实际"。断头路、绕路、反复、被迫记住的东西,
|
|
197
|
+
> 都是缺陷候选。
|
|
198
|
+
> Walk the timeline once and mark expectation vs observation at every step. Dead ends, detours,
|
|
199
|
+
> loops and things the user is forced to memorise are all defect candidates.
|
|
200
|
+
|
|
201
|
+
| # | 阶段 Stage | 用户目标 Goal | 触点/界面 Touchpoint | 期望 Expectation | 实际观察 Observed | 摩擦/缺陷 Friction or defect | 证据 Evidence |
|
|
202
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
203
|
+
| 1 | 进入 Entry | | | | | | |
|
|
204
|
+
| 2 | 首次成功 First success | | | | | | |
|
|
205
|
+
| 3 | 熟练使用 Routine use | | | | | | |
|
|
206
|
+
| 4 | 出错与恢复 Error and recovery | | | | | | |
|
|
207
|
+
| 5 | 退出与再进入 Exit and return | | | | | | |
|
|
208
|
+
`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function heuristicsTemplate() {
|
|
212
|
+
return `# 启发式检查 / Heuristic sweep
|
|
213
|
+
|
|
214
|
+
> 用启发式当"透镜"逐条扫,每条都要能指到画面上的具体位置。完整清单见
|
|
215
|
+
> \`software-design-test/HEURISTICS.md\`(Nielsen 十项 + HICCUPPS(F) 一致性启发式 + SFDIPOT 结构透镜 + 巡游)。
|
|
216
|
+
> Sweep with heuristics as lenses; every line must point at a place on the screen. The full list
|
|
217
|
+
> lives in \`software-design-test/HEURISTICS.md\`.
|
|
218
|
+
|
|
219
|
+
| ID | 启发式 Heuristic | 适用处 Where applied | 结论 Verdict | 证据 Evidence |
|
|
220
|
+
| --- | --- | --- | --- | --- |
|
|
221
|
+
| H-01 | 系统状态可见 Visibility of system status | | 通过 / 违反 / 未验证 | |
|
|
222
|
+
`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function contentTemplate() {
|
|
226
|
+
return `# 测试内容覆盖 / Content coverage
|
|
227
|
+
|
|
228
|
+
> 清单本体在 \`software-design-test/TEST-CONTENT.md\`(15 类,每类含"检查项 / 怎么看 / 判据")。
|
|
229
|
+
> 每确认一类,把结论写进"结论"列;违反项单独落盘成 finding,并带上 content 与 criteria 字段。
|
|
230
|
+
> The checklist lives in \`TEST-CONTENT.md\`. Record a verdict per category here; file violations as
|
|
231
|
+
> findings with the \`content\` and \`criteria\` fields.
|
|
232
|
+
|
|
233
|
+
| # | 类别 Category | 结论 Verdict(通过 / 违反 / 未验证) | 违反数 Findings | 证据 Evidence |
|
|
234
|
+
| --- | --- | --- | --- | --- |
|
|
235
|
+
| §1 | 窗口与界面尺寸 Window & screen size | | | |
|
|
236
|
+
| §2 | 鼠标速度与指针 Pointer dynamics & speed | | | |
|
|
237
|
+
| §3 | 目标尺寸与间距 Target size & spacing | | | |
|
|
238
|
+
| §4 | 工具栏与菜单 Toolbar & menus | | | |
|
|
239
|
+
| §5 | 文字与排版 Legibility | | | |
|
|
240
|
+
| §6 | 布局与层级 Layout & hierarchy | | | |
|
|
241
|
+
| §7 | 反馈与状态 Feedback & state | | | |
|
|
242
|
+
| §8 | 效率与流程 Efficiency & flow | | | |
|
|
243
|
+
| §9 | 键盘与焦点 Keyboard & focus | | | |
|
|
244
|
+
| §10 | 可访问性 Accessibility | | | |
|
|
245
|
+
| §11 | 性能与响应 Performance | | | |
|
|
246
|
+
| §12 | 错误与恢复 Errors & recovery | | | |
|
|
247
|
+
| §13 | 数据与输入 Data & input | | | |
|
|
248
|
+
| §14 | 跨设备一致性 Cross-device consistency | | | |
|
|
249
|
+
| §15 | 视觉打磨 Polish | | | |
|
|
250
|
+
|
|
251
|
+
## 十分钟快扫 / Ten-minute sweep
|
|
252
|
+
|
|
253
|
+
- [ ] 默认窗口完整可见 · [ ] 最小尺寸可用 · [ ] 缩放 200% 不破版 · [ ] 纯鼠标走主任务
|
|
254
|
+
- [ ] 纯键盘走主任务 · [ ] 记录指针总行程 · [ ] 工具栏图标可识别 · [ ] 长操作有反馈且可取消
|
|
255
|
+
- [ ] 故意输错看提示 · [ ] 深色模式 + 系统字号
|
|
256
|
+
|
|
257
|
+
## 尺寸与速度专项记录 / Size and pointer measurements
|
|
258
|
+
|
|
259
|
+
| 项 Item | 数值 Value | 结论 Verdict |
|
|
260
|
+
| --- | --- | --- |
|
|
261
|
+
| 默认窗口尺寸 / 最小可用尺寸 | | |
|
|
262
|
+
| 分屏(窄栏)下的可用性 | | |
|
|
263
|
+
| 系统缩放 125% / 150% / 200% | | |
|
|
264
|
+
| 主任务指针总行程(估) | | |
|
|
265
|
+
| 最远两点距离 | | |
|
|
266
|
+
| 双击速度(系统最慢 / 最快) | | |
|
|
267
|
+
| 悬停提示出现时间 | | |
|
|
268
|
+
`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function reportDraft(session) {
|
|
272
|
+
return `# 观察式界面测试报告(初稿)/ Observed UI Test Report (draft)
|
|
273
|
+
|
|
274
|
+
- 应用 App:${session.app}
|
|
275
|
+
- 平台 Platform:${session.platform}
|
|
276
|
+
- 级别 Levels:${session.levels.join(', ')}
|
|
277
|
+
- 操作者 Operator:${session.tester || '(待填)'}
|
|
278
|
+
- 创建于 Created:${session.createdAt}
|
|
279
|
+
- 权限闸门 Gate:${session.gate.confirmed ? '已通过 confirmed' : '**未通过 NOT confirmed**'}
|
|
280
|
+
|
|
281
|
+
## 合规声明 / Compliance
|
|
282
|
+
|
|
283
|
+
本次测试使用真实的鼠标与键盘操作,使用录屏/截屏作为唯一证据,
|
|
284
|
+
**未使用任何内部指针指令**(无指针/触摸/按键注入、无自动化框架、无内部句柄调用)。
|
|
285
|
+
This run uses real mouse and keyboard input with screen recording and screenshots as the only
|
|
286
|
+
evidence, and uses **no internal pointer directives** of any kind.
|
|
287
|
+
|
|
288
|
+
> 用 \`node scripts/report.mjs build <dir>\` 依据 findings.jsonl 重新生成本报告。
|
|
289
|
+
> Regenerate from findings.jsonl with \`node scripts/report.mjs build <dir>\`.
|
|
290
|
+
`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const PROTECTED_FILES = [
|
|
294
|
+
'session.json',
|
|
295
|
+
'permissions.md',
|
|
296
|
+
'elements.md',
|
|
297
|
+
'matrix.md',
|
|
298
|
+
'content.md',
|
|
299
|
+
'personas.md',
|
|
300
|
+
'scenarios.md',
|
|
301
|
+
'journey.md',
|
|
302
|
+
'heuristics.md',
|
|
303
|
+
'findings.jsonl',
|
|
304
|
+
'report.md',
|
|
305
|
+
'evidence',
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
async function cmdInit(positionals, values) {
|
|
309
|
+
const target = positionals[0]
|
|
310
|
+
if (!target) throw new Error('init needs a session directory')
|
|
311
|
+
const dir = resolve(target)
|
|
312
|
+
|
|
313
|
+
const platform = (values.platform ?? 'macos').toLowerCase()
|
|
314
|
+
if (!PLATFORMS.includes(platform)) {
|
|
315
|
+
throw new Error(`--platform must be one of ${PLATFORMS.join(', ')}`)
|
|
316
|
+
}
|
|
317
|
+
const app = values.app ?? '(unnamed app)'
|
|
318
|
+
const levels = (values.levels ?? 'L1,L2,L3')
|
|
319
|
+
.split(',')
|
|
320
|
+
.map((part) => normalizeLevel(part.trim()))
|
|
321
|
+
for (const level of levels) {
|
|
322
|
+
if (!LEVELS.includes(level)) throw new Error(`--levels must be a subset of ${LEVELS.join(',')}`)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (existsSync(dir)) {
|
|
326
|
+
const entries = (await readdir(dir)).filter((name) => !name.startsWith('.'))
|
|
327
|
+
if (entries.length > 0 && !values.force) {
|
|
328
|
+
const clash = entries.filter((name) => PROTECTED_FILES.includes(name))
|
|
329
|
+
if (clash.length > 0) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`${dir} already holds a session (${clash.join(', ')}); pass --force to overwrite`,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await mkdir(join(dir, 'evidence'), { recursive: true })
|
|
338
|
+
|
|
339
|
+
const session = {
|
|
340
|
+
schema: 'software-design-test/session@1',
|
|
341
|
+
createdAt: nowIso(),
|
|
342
|
+
updatedAt: nowIso(),
|
|
343
|
+
app,
|
|
344
|
+
platform,
|
|
345
|
+
levels,
|
|
346
|
+
tester: values.tester ?? '',
|
|
347
|
+
gate: {
|
|
348
|
+
confirmed: false,
|
|
349
|
+
answeredAt: null,
|
|
350
|
+
mouse: null,
|
|
351
|
+
keyboard: levels[0],
|
|
352
|
+
screenRecording: null,
|
|
353
|
+
screenshot: null,
|
|
354
|
+
cursorIncluded: null,
|
|
355
|
+
microphone: null,
|
|
356
|
+
osPermission: null,
|
|
357
|
+
complianceAcknowledged: null,
|
|
358
|
+
dataBoundary: '',
|
|
359
|
+
blockers: ['权限问卷尚未回答 / the permission questionnaire is unanswered'],
|
|
360
|
+
},
|
|
361
|
+
scope: { devices: '', environment: '', outOfScope: '', exitCriteria: '' },
|
|
362
|
+
counts: { findings: 0 },
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await saveSession(dir, session)
|
|
366
|
+
const permissionsHeader = `# 权限记录 / Permission record
|
|
367
|
+
|
|
368
|
+
- 会话 Session:${dir}
|
|
369
|
+
- 应用 App:${app}
|
|
370
|
+
- 平台 Platform:${platform}
|
|
371
|
+
- 创建于 Created:${session.createdAt}
|
|
372
|
+
|
|
373
|
+
> 先原样把下面的问题发给用户,等逐条答复后再执行 \`session.mjs gate\`。
|
|
374
|
+
> Send the questions below verbatim first, then record the answers with \`session.mjs gate\`.
|
|
375
|
+
|
|
376
|
+
`
|
|
377
|
+
const permissions =
|
|
378
|
+
permissionsHeader +
|
|
379
|
+
QUESTIONS_ZH +
|
|
380
|
+
`
|
|
381
|
+
|
|
382
|
+
## 用户答复 / Answers
|
|
383
|
+
|
|
384
|
+
(由 \`session.mjs gate\` 写入 / written by \`session.mjs gate\`)
|
|
385
|
+
`
|
|
386
|
+
await writeFile(join(dir, 'permissions.md'), permissions, 'utf8')
|
|
387
|
+
await writeFile(join(dir, 'content.md'), contentTemplate(), 'utf8')
|
|
388
|
+
await writeFile(join(dir, 'elements.md'), elementsTemplate(), 'utf8')
|
|
389
|
+
await writeFile(join(dir, 'matrix.md'), matrixTemplate(session), 'utf8')
|
|
390
|
+
await writeFile(join(dir, 'personas.md'), personasTemplate(), 'utf8')
|
|
391
|
+
await writeFile(join(dir, 'scenarios.md'), scenariosTemplate(), 'utf8')
|
|
392
|
+
await writeFile(join(dir, 'journey.md'), journeyTemplate(), 'utf8')
|
|
393
|
+
await writeFile(join(dir, 'heuristics.md'), heuristicsTemplate(), 'utf8')
|
|
394
|
+
await writeFile(join(dir, 'report.md'), reportDraft(session), 'utf8')
|
|
395
|
+
await writeFile(findingsPath(dir), '', 'utf8')
|
|
396
|
+
|
|
397
|
+
process.stdout.write(`已创建会话 / session created: ${dir}\n`)
|
|
398
|
+
process.stdout.write(` 平台 platform : ${platform}\n`)
|
|
399
|
+
process.stdout.write(` 级别 levels : ${levels.join(', ')}\n`)
|
|
400
|
+
process.stdout.write(` 闸门 gate : 未通过 not confirmed\n\n`)
|
|
401
|
+
process.stdout.write('下一步 / next:\n')
|
|
402
|
+
process.stdout.write(` 1. 把 permissions.md 里的问卷发给用户 / send the questionnaire\n`)
|
|
403
|
+
process.stdout.write(
|
|
404
|
+
` 2. node scripts/session.mjs gate ${target} --mouse yes --keyboard ${levels[0]} ` +
|
|
405
|
+
`--screen-recording yes --screenshot yes\n`,
|
|
406
|
+
)
|
|
407
|
+
process.stdout.write(` 3. 开始前用 capture.mjs check 验证取证可用 / verify capture first\n`)
|
|
408
|
+
return 0
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function cmdGate(positionals, values) {
|
|
412
|
+
const dir = resolve(positionals[0] ?? '')
|
|
413
|
+
const session = await loadSession(dir)
|
|
414
|
+
const gate = session.gate
|
|
415
|
+
|
|
416
|
+
const mouse = yesNo(values.mouse, 'mouse')
|
|
417
|
+
const keyboard = normalizeLevel(values.keyboard)
|
|
418
|
+
const screenRecording = yesNo(values['screen-recording'], 'screen-recording')
|
|
419
|
+
const screenshot = yesNo(values.screenshot, 'screenshot')
|
|
420
|
+
const microphone = yesNo(values.microphone, 'microphone')
|
|
421
|
+
const osPermission = yesNo(values['os-permission'], 'os-permission')
|
|
422
|
+
const cursorIncluded = yesNo(values.cursor, 'cursor')
|
|
423
|
+
const compliance = yesNo(values.compliance, 'compliance')
|
|
424
|
+
|
|
425
|
+
if (values['data-boundary'] !== undefined) gate.dataBoundary = values['data-boundary']
|
|
426
|
+
|
|
427
|
+
const blockers = []
|
|
428
|
+
if (mouse === false) blockers.push('鼠标操作被拒绝 / mouse operation denied — 无法测试 cannot test')
|
|
429
|
+
if (mouse === undefined && gate.mouse !== true) {
|
|
430
|
+
blockers.push('鼠标权限未回答 / mouse permission unanswered')
|
|
431
|
+
}
|
|
432
|
+
if (screenRecording === false && screenshot === false) {
|
|
433
|
+
blockers.push(
|
|
434
|
+
'录屏与截屏都被拒绝 / both recording and screenshots denied — 无画面即无证据 no visual evidence',
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
if (screenRecording === undefined && screenshot === undefined &&
|
|
438
|
+
gate.screenRecording !== true && gate.screenshot !== true) {
|
|
439
|
+
blockers.push('取证权限未回答 / capture permission unanswered')
|
|
440
|
+
}
|
|
441
|
+
if (compliance === false) {
|
|
442
|
+
blockers.push('合规声明未确认 / compliance statement not confirmed')
|
|
443
|
+
}
|
|
444
|
+
if (values.keyboard !== undefined && keyboard === 'L1' && mouse === false) {
|
|
445
|
+
blockers.push('仅鼠标模式下鼠标被拒绝 / mouse-only mode with mouse denied')
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (mouse !== undefined) gate.mouse = mouse
|
|
449
|
+
if (keyboard !== undefined) gate.keyboard = keyboard
|
|
450
|
+
if (screenRecording !== undefined) gate.screenRecording = screenRecording
|
|
451
|
+
if (screenshot !== undefined) gate.screenshot = screenshot
|
|
452
|
+
if (microphone !== undefined) gate.microphone = microphone
|
|
453
|
+
if (osPermission !== undefined) gate.osPermission = osPermission
|
|
454
|
+
if (cursorIncluded !== undefined) gate.cursorIncluded = cursorIncluded
|
|
455
|
+
if (compliance !== undefined) gate.complianceAcknowledged = compliance
|
|
456
|
+
|
|
457
|
+
const gateOk =
|
|
458
|
+
gate.mouse === true &&
|
|
459
|
+
(gate.screenRecording === true || gate.screenshot === true) &&
|
|
460
|
+
gate.complianceAcknowledged !== false
|
|
461
|
+
gate.confirmed = gateOk
|
|
462
|
+
gate.answeredAt = nowIso()
|
|
463
|
+
gate.blockers = gateOk
|
|
464
|
+
? []
|
|
465
|
+
: blockers.length > 0
|
|
466
|
+
? blockers
|
|
467
|
+
: ['闸门条件不完整 / gate conditions incomplete']
|
|
468
|
+
|
|
469
|
+
await saveSession(dir, session)
|
|
470
|
+
|
|
471
|
+
const lines = [
|
|
472
|
+
`闸门 gate: ${gate.confirmed ? '已通过 CONFIRMED' : '未通过 NOT CONFIRMED'}`,
|
|
473
|
+
` 鼠标 mouse : ${fmtTri(gate.mouse)}`,
|
|
474
|
+
` 键盘 keyboard : ${gate.keyboard}`,
|
|
475
|
+
` 屏幕录制 screenRecording : ${fmtTri(gate.screenRecording)}` +
|
|
476
|
+
(gate.cursorIncluded === null ? '' : ` (光标 cursor: ${fmtTri(gate.cursorIncluded)})`),
|
|
477
|
+
` 截屏 screenshot : ${fmtTri(gate.screenshot)}`,
|
|
478
|
+
` 麦克风 microphone : ${fmtTri(gate.microphone)}`,
|
|
479
|
+
` 系统权限 osPermission : ${fmtTri(gate.osPermission)}`,
|
|
480
|
+
` 合规 compliance : ${fmtTri(gate.complianceAcknowledged)}`,
|
|
481
|
+
` 数据边界 dataBoundary : ${gate.dataBoundary || '(未填 unset)'}`,
|
|
482
|
+
]
|
|
483
|
+
if (!gate.confirmed) {
|
|
484
|
+
lines.push(' 阻塞 blockers:')
|
|
485
|
+
for (const blocker of gate.blockers) lines.push(` - ${blocker}`)
|
|
486
|
+
lines.push(' 在闸门通过前不要开始测试 / do NOT start testing until the gate passes.')
|
|
487
|
+
} else {
|
|
488
|
+
lines.push(' 可以开始 L1(仅鼠标)/ you may start L1 (mouse only).')
|
|
489
|
+
}
|
|
490
|
+
process.stdout.write(lines.join('\n') + '\n')
|
|
491
|
+
|
|
492
|
+
const answers = `
|
|
493
|
+
## 用户答复 / Answers
|
|
494
|
+
|
|
495
|
+
- 记录时间 Recorded:${gate.answeredAt}
|
|
496
|
+
- 鼠标 Mouse:${fmtTri(gate.mouse)}
|
|
497
|
+
- 键盘 Keyboard:${gate.keyboard}
|
|
498
|
+
- 屏幕录制 Screen recording:${fmtTri(gate.screenRecording)}${
|
|
499
|
+
gate.cursorIncluded === null ? '' : `(光标 cursor:${fmtTri(gate.cursorIncluded)})`
|
|
500
|
+
}
|
|
501
|
+
- 截屏 Screenshot:${fmtTri(gate.screenshot)}
|
|
502
|
+
- 麦克风 Microphone:${fmtTri(gate.microphone)}
|
|
503
|
+
- 系统捕获权限 OS capture permission:${fmtTri(gate.osPermission)}
|
|
504
|
+
- 合规确认 Compliance:${fmtTri(gate.complianceAcknowledged)}
|
|
505
|
+
- 数据边界 Data boundary:${gate.dataBoundary || '(未填 unset)'}
|
|
506
|
+
- 闸门结果 Gate result:${gate.confirmed ? '通过 CONFIRMED' : '未通过 NOT CONFIRMED'}
|
|
507
|
+
${
|
|
508
|
+
gate.blockers.length > 0
|
|
509
|
+
? gate.blockers.map((blocker) => ` - 阻塞 / blocker:${blocker}`).join('\n') + '\n'
|
|
510
|
+
: ''
|
|
511
|
+
}
|
|
512
|
+
> 未获得的权限必须出现在报告首页的覆盖缺口里。
|
|
513
|
+
> Every permission not granted must appear as a coverage gap on page one of the report.
|
|
514
|
+
`
|
|
515
|
+
await appendFile(join(dir, 'permissions.md'), answers, 'utf8')
|
|
516
|
+
return gate.confirmed ? 0 : 1
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const fmtTri = (value) => (value === null || value === undefined ? '(未答 unanswered)' : value ? 'yes' : 'no')
|
|
520
|
+
|
|
521
|
+
async function cmdStatus(positionals) {
|
|
522
|
+
const dir = resolve(positionals[0] ?? '')
|
|
523
|
+
const session = await loadSession(dir)
|
|
524
|
+
const findings = await readFindings(dir)
|
|
525
|
+
const bySeverity = count(findings.map((item) => item.severity ?? 'U'))
|
|
526
|
+
const byKind = count(findings.map((item) => item.kind ?? 'defect'))
|
|
527
|
+
const byLevel = count(findings.map((item) => item.level ?? '?'))
|
|
528
|
+
const out = [
|
|
529
|
+
`会话 session : ${dir}`,
|
|
530
|
+
`应用 app : ${session.app} @ ${session.platform}`,
|
|
531
|
+
`级别 levels : ${session.levels.join(', ')}`,
|
|
532
|
+
`闸门 gate : ${session.gate.confirmed ? '已通过 CONFIRMED' : '未通过 NOT CONFIRMED'}`,
|
|
533
|
+
` 鼠标/键盘 : ${fmtTri(session.gate.mouse)} / ${session.gate.keyboard}`,
|
|
534
|
+
` 录屏/截屏 : ${fmtTri(session.gate.screenRecording)} / ${fmtTri(session.gate.screenshot)}`,
|
|
535
|
+
`发现 findings: ${findings.length}`,
|
|
536
|
+
` 级别 severity: ${SEVERITIES.map((s) => `${s}=${bySeverity[s] ?? 0}`).join(' ')}`,
|
|
537
|
+
` 类型 kind : ${KINDS.map((k) => `${k}=${byKind[k] ?? 0}`).join(' ')}`,
|
|
538
|
+
` 模式 level : ${LEVELS.map((l) => `${l}=${byLevel[l] ?? 0}`).join(' ')}`,
|
|
539
|
+
]
|
|
540
|
+
if (!session.gate.confirmed) {
|
|
541
|
+
out.push('提醒 reminder: 闸门未通过,不得开始测试。gate not confirmed, do not start testing.')
|
|
542
|
+
}
|
|
543
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
544
|
+
return 0
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function count(items) {
|
|
548
|
+
const result = {}
|
|
549
|
+
for (const item of items) result[item] = (result[item] ?? 0) + 1
|
|
550
|
+
return result
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function readFindings(dir) {
|
|
554
|
+
const file = findingsPath(dir)
|
|
555
|
+
if (!existsSync(file)) return []
|
|
556
|
+
const raw = await readFile(file, 'utf8')
|
|
557
|
+
const rows = []
|
|
558
|
+
for (const [index, line] of raw.split('\n').entries()) {
|
|
559
|
+
const text = line.trim()
|
|
560
|
+
if (text.length === 0) continue
|
|
561
|
+
try {
|
|
562
|
+
rows.push(JSON.parse(text))
|
|
563
|
+
} catch (error) {
|
|
564
|
+
throw new Error(`${file}:${index + 1} is not valid JSON: ${error.message}`)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return rows
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function readJsonArg(values) {
|
|
571
|
+
if (values.stdin) return await readStdin()
|
|
572
|
+
const arg = values.json
|
|
573
|
+
if (arg === undefined) throw new Error('finding needs --json <json> | --json @file | --stdin')
|
|
574
|
+
if (arg.startsWith('@')) return await readFile(resolve(arg.slice(1)), 'utf8')
|
|
575
|
+
return arg
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function readStdin() {
|
|
579
|
+
const chunks = []
|
|
580
|
+
for await (const chunk of process.stdin) chunks.push(chunk)
|
|
581
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function cmdFinding(positionals, values) {
|
|
585
|
+
const dir = resolve(positionals[0] ?? '')
|
|
586
|
+
const session = await loadSession(dir)
|
|
587
|
+
const raw = await readJsonArg(values)
|
|
588
|
+
let parsed
|
|
589
|
+
try {
|
|
590
|
+
parsed = JSON.parse(raw)
|
|
591
|
+
} catch (error) {
|
|
592
|
+
throw new Error(`--json is not valid JSON: ${error.message}`)
|
|
593
|
+
}
|
|
594
|
+
const items = Array.isArray(parsed) ? parsed : [parsed]
|
|
595
|
+
const existing = await readFindings(dir)
|
|
596
|
+
|
|
597
|
+
const written = []
|
|
598
|
+
for (const item of items) {
|
|
599
|
+
const severity = String(item.severity ?? 'U').toUpperCase()
|
|
600
|
+
const kind = String(item.kind ?? item.status ?? 'defect').toLowerCase()
|
|
601
|
+
const level = item.level === undefined ? session.levels[0] : String(item.level).toUpperCase()
|
|
602
|
+
if (!SEVERITIES.includes(severity)) {
|
|
603
|
+
throw new Error(`severity must be one of ${SEVERITIES.join(', ')}`)
|
|
604
|
+
}
|
|
605
|
+
if (!KINDS.includes(kind)) throw new Error(`kind must be one of ${KINDS.join(', ')}`)
|
|
606
|
+
if (!LEVELS.includes(level)) throw new Error(`level must be one of ${LEVELS.join(', ')}`)
|
|
607
|
+
if (!item.title_zh && !item.title_en) {
|
|
608
|
+
throw new Error('a finding needs at least title_zh or title_en')
|
|
609
|
+
}
|
|
610
|
+
const id = item.id ?? `F-${String(existing.length + written.length + 1).padStart(3, '0')}`
|
|
611
|
+
const record = {
|
|
612
|
+
id,
|
|
613
|
+
at: item.at ?? nowIso(),
|
|
614
|
+
level,
|
|
615
|
+
platform: item.platform ?? session.platform,
|
|
616
|
+
element: item.element ?? '',
|
|
617
|
+
persona: item.persona ?? '',
|
|
618
|
+
content: item.content ?? '',
|
|
619
|
+
criteria: item.criteria ?? '',
|
|
620
|
+
scenario: item.scenario ?? '',
|
|
621
|
+
task_outcome: item.task_outcome ?? '',
|
|
622
|
+
heuristic: item.heuristic ?? '',
|
|
623
|
+
tour: item.tour ?? '',
|
|
624
|
+
repro_rate: item.repro_rate ?? '',
|
|
625
|
+
minimized: item.minimized ?? '',
|
|
626
|
+
title_zh: item.title_zh ?? item.title_en ?? '',
|
|
627
|
+
title_en: item.title_en ?? item.title_zh ?? '',
|
|
628
|
+
steps_zh: item.steps_zh ?? '',
|
|
629
|
+
steps_en: item.steps_en ?? '',
|
|
630
|
+
expected_zh: item.expected_zh ?? '',
|
|
631
|
+
expected_en: item.expected_en ?? '',
|
|
632
|
+
actual_zh: item.actual_zh ?? '',
|
|
633
|
+
actual_en: item.actual_en ?? '',
|
|
634
|
+
modes: item.modes ?? '',
|
|
635
|
+
severity,
|
|
636
|
+
kind,
|
|
637
|
+
evidence: Array.isArray(item.evidence) ? item.evidence : item.evidence ? [item.evidence] : [],
|
|
638
|
+
impact: item.impact ?? '',
|
|
639
|
+
suggestion: item.suggestion ?? '',
|
|
640
|
+
}
|
|
641
|
+
written.push(record)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
await appendFile(findingsPath(dir), written.map((record) => JSON.stringify(record)).join('\n') + '\n', 'utf8')
|
|
645
|
+
session.counts = { findings: existing.length + written.length }
|
|
646
|
+
await saveSession(dir, session)
|
|
647
|
+
|
|
648
|
+
for (const record of written) {
|
|
649
|
+
process.stdout.write(
|
|
650
|
+
`已记录 / recorded ${record.id} [${record.severity}/${record.kind}] L=${record.level} ` +
|
|
651
|
+
`${record.title_zh || record.title_en}\n`,
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
process.stdout.write(
|
|
655
|
+
`合计 / total: ${existing.length + written.length} — 用 report.mjs build 生成报告。\n`,
|
|
656
|
+
)
|
|
657
|
+
return 0
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function main() {
|
|
661
|
+
const { positionals, values } = parseArgs({
|
|
662
|
+
args: process.argv.slice(2),
|
|
663
|
+
allowPositionals: true,
|
|
664
|
+
options: {
|
|
665
|
+
platform: { type: 'string' },
|
|
666
|
+
app: { type: 'string' },
|
|
667
|
+
levels: { type: 'string' },
|
|
668
|
+
tester: { type: 'string' },
|
|
669
|
+
force: { type: 'boolean', default: false },
|
|
670
|
+
mouse: { type: 'string' },
|
|
671
|
+
keyboard: { type: 'string' },
|
|
672
|
+
'screen-recording': { type: 'string' },
|
|
673
|
+
screenshot: { type: 'string' },
|
|
674
|
+
microphone: { type: 'string' },
|
|
675
|
+
'os-permission': { type: 'string' },
|
|
676
|
+
cursor: { type: 'string' },
|
|
677
|
+
compliance: { type: 'string' },
|
|
678
|
+
'data-boundary': { type: 'string' },
|
|
679
|
+
json: { type: 'string' },
|
|
680
|
+
stdin: { type: 'boolean', default: false },
|
|
681
|
+
},
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
const command = positionals.shift() ?? 'help'
|
|
685
|
+
try {
|
|
686
|
+
if (command === 'init') return await cmdInit(positionals, values)
|
|
687
|
+
if (command === 'gate') return await cmdGate(positionals, values)
|
|
688
|
+
if (command === 'status') return await cmdStatus(positionals, values)
|
|
689
|
+
if (command === 'finding') return await cmdFinding(positionals, values)
|
|
690
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
691
|
+
process.stdout.write(usage())
|
|
692
|
+
return 0
|
|
693
|
+
}
|
|
694
|
+
process.stderr.write(`unknown command: ${command}\n\n${usage()}`)
|
|
695
|
+
return 2
|
|
696
|
+
} catch (error) {
|
|
697
|
+
process.stderr.write(`error: ${error.message}\n`)
|
|
698
|
+
return 2
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Only run when executed as a CLI; importing this module (tests) must not touch stdout.
|
|
703
|
+
const invokedDirectly =
|
|
704
|
+
process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
705
|
+
if (invokedDirectly) {
|
|
706
|
+
process.exitCode = await main()
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export { cmdFinding, cmdGate, cmdInit, cmdStatus, readFindings, stamp }
|