diff --git a/docs/webui.md b/docs/webui.md index 9aec03591..b1ed0420d 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -1204,12 +1204,15 @@ one, Worktree, reads the engine since PB-3. | Preferences | General (通用) | implemented | | Preferences | Voice | implemented, placeholder controls — the microphone dropdown is disabled with a single 「本地版不适用」 option, and both dictation rows show 未设置 (no device enumeration, no dictation input in a browser) | | Preferences | Shortcuts | implemented — 10 desktop rows, each stating what the browser can do with it: 3 rebindable and live, 1 live on macOS only, 6 blocked with the specific reason (see **Shortcuts — what the browser can intercept**) | -| Preferences | Personalization | implemented — 自定义指令 and 关于你 persist to `localStorage`; both memory switches render off and disabled with the not-applicable marker, and 管理 opens the 记忆摘要 dialog in its permanent empty state | +| Preferences | Personalization | implemented, and honest about what it is for — 自定义指令 and 关于你 persist to `localStorage` and each field states 「已保存于本浏览器,不会注入引擎会话」, because the engine has no channel that reads them (SB-8 / D-2; the storage is kept, the injection claim is not); both memory switches render off and disabled with the not-applicable marker, and 管理 opens the 记忆摘要 dialog in its permanent empty state | | Management | Usage & models | implemented; since SB-1 the two engine sources are real (Token Plan / MiniMax API switch the engine's credential, the 「使用中」 badge reads the engine back, and the MiniMax API key can be saved and probed) — the third pill, Custom models, stays a VIEW onto the provider catalogue | | Management | Connection | implemented | | Management | Account | implemented as a read — the section reads `GET /api/account` on mount and renders the account name, the current plan name, the quota overview (plan-quota state plus the 5-hour and weekly remaining figures) and the account status; sign-out stays disabled (no engine method acts on it) | | Coding | Code review | implemented — 自定义审查准则 persists to `localStorage`; 审查方式 is a disabled single-option dropdown showing 子会话 | | Coding | Worktree | implemented as a CLEANUP page since PB-3 — see **Worktree — what the page can and cannot do** | + +| Coding | Code review | implemented — 自定义审查准则 persists to `localStorage` and carries the same 「不会注入引擎会话」 note as the two Personalization texts (SB-8 / D-2); 审查方式 is a disabled single-option dropdown showing 子会话 | +| Coding | Worktree | **not implemented** — the tab is a one-line panel reading 「本地版暂不支持工作树管理」 | | Archived | Archived tasks | the tab renders its empty state 「暂无已归档任务」; the list and its actions need an archived-session contract that does not exist | **Worktree — what the page can and cannot do.** The Worktree tab is a @@ -1326,7 +1329,7 @@ between adjacent rows: | Session management | enabled | one switch, persisted; gates the composer's context-window readout (see below) | | Agent control | disabled furniture | the 「自动打开浏览器面板」 switch renders off and disabled (no capability behind it) | | Preference settings | enabled | follow-up behaviour (disabled / queue / send now); since SB-4 the composer reads it, and a send into a running turn reaches the engine's queue or steers the running turn. Watermark and data opt-in render disabled | -| About | mixed | upload logs and check-for-update are disabled buttons; the local URL and LAN URL are live read-only rows from `/api/settings` | +| About | mixed | **export logs** is a live download of this server's own diagnostic trail (`GET /api/logs/export`, see below); check-for-update is still a disabled button — self-hosted update is `git pull`, and the desktop updater's semantics do not apply; the local URL and LAN URL are live read-only rows from `/api/settings` | | dataDir footer | not implemented | the reference prints the app data directory at the bottom of the General page; `/api/settings` has no such field and the server routes are read-only this round, so no value exists to print | Appearance and language behave as before: immediate effect on click; the @@ -1864,6 +1867,96 @@ scope, and until it lands a queued follow-up is invisible until the running turn ends. A steered message reports admission, not whether the running agent read the text before its next step. +### Export logs: a download, because there is nowhere to upload to (SB-8 / D-3) + +**What the user sees.** The About section's first row is 导出日志 / **Export +logs**, and its button works: the browser saves one text file named +`mcode-webui-logs-.txt`. The row's description says what is in +it — this server's error log and recent activity — and adds that nothing is +uploaded anywhere. + +**Why the rename.** The row used to be a permanently disabled button +labelled 上传日志 / **Upload logs**. There is no upload service in this +edition: no telemetry sink, no ticket intake, nothing that leaves the +machine. A label that names a destination the product does not have is a +promise, and a disabled control cannot keep it — the tooltip only +contradicted the title. The alternative considered and rejected was +keeping 上传日志 and pointing it at the download, on the argument that the +desktop reference uses the word; a reference's word does not make its +destination real, and the button would then say "upload" while writing to +the user's disk. + +**What the file contains.** Two sources, both read from the module that +writes them, so a relocated data directory cannot make the export silently +empty: + +| Section | Source | Bounded by | +| --- | --- | --- | +| Server error log | `WEBUI_DATA_DIR/.server.err` (`config.js#installGlobalErrorHandlers`) | 2000 lines / 2 MiB | +| Event log | `events.path()` (honours `MCODE_WEBUI_EVENTS_PATH`) | 2000 lines / 2 MiB | + +The tail, not the head: the failure being investigated happened most +recently, and the event log is tens of megabytes on a long-lived install. +Every bound is printed in the file itself (`[truncated: showing the last N +of M lines]`), so a reader can tell a bounded file from a complete one +without trusting the tool that produced it. + +**What the file deliberately does not contain.** `sessions.json` +(conversation transcripts), `settings.json` (provider credentials) and +`uploads/`. A diagnostics file users attach to a bug report must not be +the one file on the machine carrying their API keys and their +conversations. The source list is a closed two for that reason, and a test +asserts the markers never appear in a bundle built next to decoy files. + +**Why there is no failure status.** The endpoint answers `200` in every +case, including a log file that does not exist. The client is a browser +anchor with a `download` attribute, so a `404` would be saved into the +user's downloads folder as `mcode-webui-logs-.txt` containing a +JSON error body — a file that looks like logs and is not. Absence is +therefore reported where the reader is: inside the body, per section. + +**What it costs.** One read of two local files per click, synchronously on +the server's event loop. Both are capped, so the worst case is a few MiB +of already-warm page cache. + +**Known debt.** The bundle is plain text with no redaction: an engine error +line can quote a prompt fragment. Redaction is not attempted because there +is no reliable rule for what is secret in an arbitrary log line, and a +partial redaction would be worse than none. Until the engine's own logging +grows a redaction hook, the operator is the one deciding what to share +from the downloaded file. + +### The three stored texts are storage, not instructions (SB-8 / D-2) + +**What the user sees.** 自定义指令, 关于你 and 自定义审查准则 each keep +their textarea and their 保存 action, and each now prints one line under +the field: **「已保存于本浏览器,不会注入引擎会话。」** / "Saved in this +browser only — it is not injected into engine sessions." + +**Why.** The texts have always persisted — that part is real and stays +real. What is not real is any claim that they reach the engine. A grep of +the runtime source for `setConfigOption` — the option the plan had assumed +would carry them — found nothing: the method does not exist anywhere in +`local-runtime-v2`, so there is no config write channel to hang them on, +and no session-creation parameter that takes them either. With no consumer, +a saved 「自定义指令」 read as an instruction the agent follows. The field +was making a capability claim its backend had already disproved. + +**The decision tree this took.** The three options were: extend the engine +contract, drop the fields, or keep the storage and stop claiming the +effect. Extending the contract is engine work with no local caller to size +it against; dropping the fields removes a place users keep text they own +and can read back at any time. What remains is honest and cheap: the +storage is a user's own local text, the field says plainly that it is not +an instruction, and the moment the engine grows a channel, deleting one +sentence and one `note` prop is the whole change. + +**What this does not do.** It does not make the texts work, and it does not +hide that they do not work — that is what the note is for. Nothing reads +the three `localStorage` keys: a future change that wires one of them must +remove the note in the same commit, or the field will be describing an +effect it no longer lacks. + ## Main-surface elements: user menu / project context menu / home capsules (ticket 55c) The user asked for every desktop main-surface screenshot to be copied @@ -3079,6 +3172,7 @@ marker), not by tool name. | `PUT` | `/api/model-source/api-key` | `routes/model-source.js#handlePutModelSourceApiKey` | `{apiKey, saveAndUse?}`; an absent/empty/whitespace `apiKey` is the KEEP sentinel → `200 {changed:false}` with no engine write; `400 {code:"BAD_FIELD_TYPE"\|"INVALID_API_KEY"}`; `500 {code:"engine_error"}` never carries the thrown message | | `POST` | `/api/model-source/test` | `routes/model-source.js#handleTestModelSource` | `{modelId?}`; always 200 for a COMPLETED probe (`{ok, success, providerId:"minimax_api", tested:"stored_key", status}`) including `success:false`; non-200 only when the probe is refused (`503`/`501`, or the engine's `400 NO_API_KEY`) | | `POST` | `/api/follow-up` | `routes/follow-up.js#handleFollowUp` | `{behavior:"queue"\|"steer", content, attachments?, requestId?}` — the engine session id comes from the server's own conversation state, never the body; `400 {code:"invalid_follow_up_behavior"\|"follow_up_empty"\|"no_active_conversation"\|"BAD_FIELD_TYPE"}`; `409 {code:"no_active_turn"\|"turn_not_owned"}` when this process does not own the running turn, and nothing is queued; `501` when the host lacks the method, `503` when no runtime is booted; 200 `{ok, behavior, itemId, position, status}` (queue) or `{ok, behavior, turnId, mode}` (steer) — the engine's own answer | +| `GET` | `/api/logs/export` | `routes/logs.js#handleExportLogs` | always `200 text/plain` with `Content-Disposition: attachment; filename="mcode-webui-logs-.txt"` — the crash trail (`WEBUI_DATA_DIR/.server.err`) plus the last 2000 lines / 2 MiB of the event log, each section stating its own truncation or absence. No status code for a missing or unreadable log file: that is reported inside the body, because the client is a browser anchor and a 4xx would be saved as a `.txt` file containing JSON | | `POST` | `/api/debug/inject` | `routes/debug.js#handleDebugInject` | `DEBUG_INJECT=1` gate | | `GET` | `/api/debug/state` | `routes/debug.js#handleDebugState` | same gate | | `POST` | `/api/protocol/set-mode` | `routes/protocol.js#handleSetMode` | mid-session mode change | diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index b10a72549..7f46bce51 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -1002,12 +1002,15 @@ slice 22 增强: | 偏好 | 通用 | 已实装 | | 偏好 | 语音 | 已实装,控制件为诚实占位——麦克风下拉禁用且只有「本地版不适用」一个选项,两条听写快捷键显示「未设置」(浏览器里既没有设备枚举也没有听写输入) | | 偏好 | 快捷键 | 已实装——10 行逐行说明浏览器到底能做什么:3 行可改键且真实生效,1 行仅 macOS 生效,6 行不可用并写明原因(见**快捷键:浏览器能截获什么**) | -| 偏好 | 个性化 | 已实装——「自定义指令」与「关于你」真存 `localStorage`;两个记忆开关关闭且禁用、行内标注「本地版不适用」,「管理」按钮打开「记忆摘要」弹窗且恒为空态 | +| 偏好 | 个性化 | 已实装,且对自己的作用范围诚实——「自定义指令」与「关于你」存 `localStorage`,每个输入框下都写明「已保存于本浏览器,不会注入引擎会话」:引擎侧没有读取它们的通道(SB-8 / D-2;存储保留,生效的说法不保留);两个记忆开关关闭且禁用、行内标注「本地版不适用」,「管理」按钮打开「记忆摘要」弹窗且恒为空态 | | 管理 | 用量与模型 | 已实装;自 SB-1 起两个引擎来源是真切换(Token Plan / MiniMax API 真切引擎凭据,「使用中」徽标回读引擎真值,MiniMax API Key 可保存可检测)——第三个胶囊「自定义模型」仍是**视图**,落点是供应商目录 | | 管理 | 连接 | 已实装 | | 管理 | 账户 | 以读取实现——该分区挂载时读一次 `GET /api/account`,渲染账户名、当前套餐名、配额概况(套餐配额状态 + 5 小时/周窗口剩余读数)与账户状态;「退出登录」维持禁用(引擎没有可调用的登录登出方法) | | 编码 | 代码审查 | 已实装——「自定义审查准则」真存 `localStorage`;「审查方式」是禁用单选下拉,显示「子会话」 | | 编码 | 工作树 | 自 PB-3 起以**清理页**形态实装——见**工作树:这一页能做什么、不能做什么** | + +| 编码 | 代码审查 | 已实装——「自定义审查准则」存 `localStorage`,并与「个性化」两处文本带同一条「不会注入引擎会话」说明(SB-8 / D-2);「审查方式」是禁用单选下拉,显示「子会话」 | +| 编码 | 工作树 | **未实装**——页签是一行文案「本地版暂不支持工作树管理」 | | 归档 | 已归档任务 | 页签渲染空态「暂无已归档任务」;列表与其操作需要目前不存在的归档会话契约 | **工作树:这一页能做什么、不能做什么。** 工作树页签是一个**清理**页,不是 @@ -1099,7 +1102,7 @@ slice 22 增强: | 会话管理 | 可用 | 一个开关,读写本地存储,控制输入区上下文用量计量的显示(见下) | | Agent 控制权限 | 禁用摆设 | 「自动打开浏览器面板」开关渲染为关闭且禁用(无对应能力) | | 偏好设置 | 可用 | 「跟进消息行为」三段(关闭 / 排队 / 立即发送),读写本地存储;自 SB-4 起编写器真的读它——回合运行中发送的消息会进引擎队列或转向当前回合。水印与数据授权两行渲染为禁用 | -| 关于 | 混合 | 上传日志与检查更新是禁用按钮;本机地址与局域网地址是从 `/api/settings` 取值的真实只读行 | +| 关于 | 混合 | **导出日志**是真动作:下载本服务端自己的诊断日志(`GET /api/logs/export`,见下);检查更新仍是禁用按钮——自托管的更新方式是 `git pull`,桌面更新器的语义在此不成立;本机地址与局域网地址是从 `/api/settings` 取值的真实只读行 | | 页底数据目录(dataDir) | 未实现 | 桌面版在通用页底部显示应用数据目录;`/api/settings` 契约没有该字段且本轮服务端只读,无法取到真值,如实留空不做 | 应用分区里外观与语言的生效方式不变:点击立即生效;外观写入本地存储(`webui:ui:v1` 信封),刷新后保持;跟随系统时操作系统明暗切换页面实时跟随,无需刷新。 @@ -1312,6 +1315,73 @@ Token Plan 视图是桌面的五区块(页签 + 四卡): 原来悬停会弹出一个配额浮层;现在改为点击后直接跳到设置页的「用量与模型」节,浮层组件与其文案键已移除。配额数据不再有两处入口。 +### 导出日志:因为根本没有可上传的去处(SB-8 / D-3) + +**用户看到什么。** 关于区第一行是「导出日志」,按钮可用:浏览器存下一个 +`mcode-webui-logs-<时间戳>.txt` 文本文件。行内说明写清里面是什么——本服务端 +的错误日志与近期活动——并补一句不会上传到任何地方。 + +**为什么改名。** 这一行原来是一个恒禁用的「上传日志」按钮。本地版没有上传服务: +没有遥测接收端、没有工单入口,没有任何东西会离开本机。写着一个产品并不拥有的 +目的地就是许诺,而禁用控件并不能兑现它——那行提示语只是在跟标题打架。考虑过 +并否掉的方案是:保留「上传日志」四个字、让它指向下载,理由是桌面参照就是这么 +写的。参照的措辞不会让它的目的地变成真的;那样按钮会一边写着「上传」,一边往 +用户磁盘上写。 + +**文件里有什么。** 两个来源,都从**写它们的那个模块**读,所以数据目录被挪走时 +导出不会悄无声息地变成空文件: + +| 分段 | 来源 | 上限 | +| --- | --- | --- | +| 崩溃日志 | `WEBUI_DATA_DIR/.server.err`(`config.js#installGlobalErrorHandlers`) | 2000 行 / 2 MiB | +| 事件日志 | `events.path()`(认 `MCODE_WEBUI_EVENTS_PATH`) | 2000 行 / 2 MiB | + +取尾部而非头部:要查的故障最近才发生,而长期运行的实例里事件日志动辄几十兆。 +每个上限都印在文件里(`[truncated: showing the last N of M lines]`),这样读的人 +不必相信生成它的工具,也分得清这份文件是被截过的还是完整的。 + +**文件刻意不含什么。** `sessions.json`(对话记录)、`settings.json`(供应商 +凭据)与 `uploads/`。用户会拿去附在问题单上的诊断文件,不能恰好是这台机器上 +唯一装着 API Key 和对话的那个文件。来源清单因此是封闭的两项,且有测试在相邻 +放置诱饵文件的情况下断言这些标记不会出现在打包结果里。 + +**为什么没有失败状态码。** 端点在任何情况下都答 `200`,包括日志文件根本不存在 +的时候。前端是带 `download` 属性的浏览器 anchor,`404` 会被原样存进用户的 +下载目录,文件名还是 `mcode-webui-logs-<时间戳>.txt`、内容却是 JSON 错误体—— +一个看着像日志、实际不是日志的文件。所以「没有」这件事写在读者所在的地方:写进 +响应体,逐分段说明。 + +**代价。** 每次点击同步读两个本地文件,跑在服务端事件循环上。两者都有上限,最坏 +情况是几 MiB 本就热的页缓存。 + +**已知技术债。** 打包结果是纯文本、不做脱敏:引擎的错误行可能引用提示词片段。 +不尝试脱敏,是因为对任意一行日志没有可靠的「哪里是秘密」规则,而半吊子脱敏比 +不脱敏更糟。在引擎自身的日志长出脱敏钩子之前,由用户自己决定下载到的文件里 +哪些内容可以分享出去。 + +### 三段文本是存储,不是指令(SB-8 / D-2) + +**用户看到什么。** 「自定义指令」「关于你」「自定义审查准则」三处的文本框与 +「保存」动作都保留,输入框下各多一行:**「已保存于本浏览器,不会注入引擎会话。」** + +**为什么。** 这三段文本一直存得住——这部分是真的,也继续保留为真。不真的是 +「它们会进引擎」这个说法。对运行时源码 grep `setConfigOption`(计划里原本假设 +用它承载这三段文本)一无所获:该方法在 `local-runtime-v2` 全源都不存在,因此没有 +可挂载的 config 写通道,会话创建参数里也没有对应入参。没有任何消费方时,一条存下 +的「自定义指令」读起来就像一条 Agent 会遵守的指令——输入框在做它后端已经证伪的 +能力宣称。 + +**这条决策树走到了哪。** 三个选项:扩展引擎契约、删掉这些输入框、或保留存储但 +停止宣称生效。扩展契约是没有本地调用方可估量的引擎工作;删掉输入框则是拿掉一个 +用户放自己文字、且随时能读回来的地方。剩下的方案既诚实又便宜:存储是用户自己的 +本地文本,输入框直说它不是指令;等引擎长出通道时,删掉一句话与一个 `note` 属性 +就是全部改动。 + +**这批没做什么。** 它没有让这三段文本生效,也没有掩盖它们不生效——那行说明就是 +为此存在的。三个 `localStorage` 键仍然没有任何读取方:将来若把其中之一接上, +必须在同一个提交里删掉那行说明,否则输入框会开始描述一个它已经不再欠缺的、 +并不存在的能力。 + ## 主界面三元素:用户菜单 / 项目右键菜单 / 主页快捷胶囊(工单 55c) 用户要求把桌面版主界面截图全部照抄。本工单覆盖其中三个元素,对齐原则沿用 53 的 A1 拍板:**有本地数据源的真做,没有的渲染桌面同款形态 + 「本地版不适用」诚实占位,不造假数据。** @@ -2315,6 +2385,7 @@ createdAtMs, updatedAtMs}`)下发,按 `toolCallId` 幂等、上限 32 条、 | `PUT` | `/api/model-source/api-key` | `routes/model-source.js#handlePutModelSourceApiKey` | `{apiKey, saveAndUse?}`;`apiKey` 缺失/空/仅空白即**保留**哨兵 → `200 {changed:false}` 且不写引擎;`400 {code:"BAD_FIELD_TYPE"\|"INVALID_API_KEY"}`;`500 {code:"engine_error"}` 绝不携带抛出的异常消息 | | `POST` | `/api/model-source/test` | `routes/model-source.js#handleTestModelSource` | `{modelId?}`;**跑完**的检测恒 200(`{ok, success, providerId:"minimax_api", tested:"stored_key", status}`),含 `success:false`;非 200 只出现在拒绝去试时(`503`/`501`,或引擎的 `400 NO_API_KEY`) | | `POST` | `/api/follow-up` | `routes/follow-up.js#handleFollowUp` | `{behavior:"queue"\|"steer", content, attachments?, requestId?}`——引擎会话 id 取自服务端自己的会话状态,**不从请求体取**;`400 {code:"invalid_follow_up_behavior"\|"follow_up_empty"\|"no_active_conversation"\|"BAD_FIELD_TYPE"}`;本进程不持有正在跑的回合时 `409 {code:"no_active_turn"\|"turn_not_owned"}`,且**不排任何队**;宿主缺该方法 `501`、没有运行时 `503`;200 `{ok, behavior, itemId, position, status}`(排队)或 `{ok, behavior, turnId, mode}`(转向)——都是引擎自己的回答 | +| `GET` | `/api/logs/export` | `routes/logs.js#handleExportLogs` | 恒为 `200 text/plain`,带 `Content-Disposition: attachment; filename="mcode-webui-logs-.txt"`——崩溃日志(`WEBUI_DATA_DIR/.server.err`)加事件日志最后 2000 行 / 2 MiB,每段自带截断或缺失说明。日志文件缺失或读不了**不产生状态码**:这些情况写进响应体,因为前端是浏览器 anchor,4xx 会被当成 `.txt` 存成一个装着 JSON 的文件 | | `POST` | `/api/debug/inject` | `routes/debug.js#handleDebugInject` | `DEBUG_INJECT=1` 守门 | | `GET` | `/api/debug/state` | `routes/debug.js#handleDebugState` | 同上 | | `POST` | `/api/protocol/set-mode` | `routes/protocol.js#handleSetMode` | 会话中途切换 mode | diff --git a/packages/local-runtime/src/files/git-process.ts b/packages/local-runtime/src/files/git-process.ts index c388ea575..6e2b63f66 100644 --- a/packages/local-runtime/src/files/git-process.ts +++ b/packages/local-runtime/src/files/git-process.ts @@ -12,6 +12,12 @@ export interface GitRunResult { code: number; stdout: string; stderr: string; + /** + * Set only when `git` never ran — a missing binary or an unusable working + * directory. Absent means Git produced a verdict, so `code` is a real exit + * status that callers may branch on instead of parsing a translated message. + */ + spawnError?: string; } export async function git(args: string[], workspace: string): Promise { @@ -29,6 +35,7 @@ export async function git(args: string[], workspace: string): Promise { + const probe = await git(['rev-parse', '--git-dir'], workspace); + if (probe.spawnError !== undefined) return 'workspace_unavailable'; + return probe.code === 0 && probe.stdout.trim() !== '' + ? 'workspace_unavailable' + : 'not_git_repository'; +} + /** null means confirmed missing; undefined means metadata could not be read. */ async function worktreeLastModifiedMs(worktreePath: string): Promise { try { diff --git a/packages/local-runtime/test/unit/worktree-discovery-code.test.ts b/packages/local-runtime/test/unit/worktree-discovery-code.test.ts new file mode 100644 index 000000000..3d365611b --- /dev/null +++ b/packages/local-runtime/test/unit/worktree-discovery-code.test.ts @@ -0,0 +1,124 @@ +// Discovery-code classification for `listWorkspaceGitWorktrees`, driven by a +// stubbed `git` so the assertions hold on hosts whose Git prints English only. +// +// Why the stub instead of a real repository: the regression this file pins is +// that the classification read Git's human-readable diagnostic. That text is +// translated by Git, so a real-subprocess test only reproduces it on a host +// carrying the matching catalogue. Here the stub returns exactly what a +// localized Git returns, and the code under test must classify it from the +// exit status alone. + +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GitRunResult } from '../../src/files/git-process.js'; + +const gitStub = vi.fn<(args: string[], workspace: string) => Promise>(); + +vi.mock('../../src/files/git-process.js', () => ({ + git: (args: string[], workspace: string) => gitStub(args, workspace), +})); + +const { listWorkspaceGitWorktrees } = await import('../../src/files/worktrees.js'); + +const NOT_A_REPO_ZH = 'fatal: 不是 git 仓库(或者任何父目录):.git'; +const NOT_A_REPO_EN = 'fatal: not a git repository (or any of the parent directories): .git'; +const NO_WORK_TREE_ZH = 'fatal: 该操作必须在一个工作区中运行'; + +let workspace: string; + +beforeEach(async () => { + gitStub.mockReset(); + workspace = await mkdtemp(join(tmpdir(), 'mcode-worktree-code-')); + await mkdir(join(workspace, 'plain-folder'), { recursive: true }); +}); + +afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +/** Answer the two `rev-parse` probes the discovery performs. */ +function stubRevParse(options: { + toplevel: GitRunResult; + gitDir: GitRunResult; +}): void { + gitStub.mockImplementation(async (args) => + args.includes('--git-dir') ? options.gitDir : options.toplevel, + ); +} + +function failed(code: number, stderr: string): GitRunResult { + return { code, stdout: '', stderr }; +} + +describe('listWorkspaceGitWorktrees discovery code', () => { + it.each([ + ['zh_CN', NOT_A_REPO_ZH], + ['en_US', NOT_A_REPO_EN], + ])('classifies a plain folder as not_git_repository under %s diagnostics', async (_tag, stderr) => { + stubRevParse({ + toplevel: failed(128, stderr), + gitDir: failed(128, stderr), + }); + + const result = await listWorkspaceGitWorktrees(join(workspace, 'plain-folder')); + + expect(result.success).toBe(false); + expect(result.code).toBe('not_git_repository'); + expect(result.error).toBe(stderr); + }); + + it('keeps a repository without a work tree out of the not-a-repository bucket', async () => { + // A bare repository answers `--git-dir` successfully while + // `--show-toplevel` fails; that is a repository this listing cannot walk, + // not a folder that was never one. + stubRevParse({ + toplevel: failed(128, NO_WORK_TREE_ZH), + gitDir: { code: 0, stdout: '.\n', stderr: '' }, + }); + + const result = await listWorkspaceGitWorktrees(workspace); + + expect(result.success).toBe(false); + expect(result.code).toBe('workspace_unavailable'); + }); + + it('falls back to the generic bucket when git could not run at all', async () => { + // No binary, unusable cwd: neither probe produced a Git verdict, so the + // discovery must not claim the folder was never a repository. + gitStub.mockImplementation(async () => ({ + code: 1, + stdout: '', + stderr: 'spawn git ENOENT', + spawnError: 'ENOENT', + })); + + const result = await listWorkspaceGitWorktrees(workspace); + + expect(result.success).toBe(false); + expect(result.code).toBe('workspace_unavailable'); + }); + + it('still lists the worktrees of a healthy repository', async () => { + gitStub.mockImplementation(async (args) => { + if (args.includes('--show-toplevel')) return { code: 0, stdout: `${workspace}\n`, stderr: '' }; + if (args.includes('worktree')) { + return { + code: 0, + stdout: `worktree ${workspace}\nHEAD 1111111111111111111111111111111111111111\nbranch refs/heads/main\n\n`, + stderr: '', + }; + } + return failed(1, 'unexpected call'); + }); + + const result = await listWorkspaceGitWorktrees(workspace); + + expect(result.success).toBe(true); + expect(result.worktrees).toHaveLength(1); + expect(result.worktrees[0]).toMatchObject({ branch: 'main', isMain: true }); + }); +}); diff --git a/packages/local-runtime/test/unit/worktree-locale-parity.test.ts b/packages/local-runtime/test/unit/worktree-locale-parity.test.ts new file mode 100644 index 000000000..e5465136d --- /dev/null +++ b/packages/local-runtime/test/unit/worktree-locale-parity.test.ts @@ -0,0 +1,105 @@ +// Locale parity for the worktree discovery codes, against a real `git`. +// +// The discovery used to recognise "not a repository" by matching Git's +// English diagnostic, so on a host whose Git speaks the user's language every +// plain folder degraded to the generic `workspace_unavailable` bucket. These +// cases run the real subprocess under a fixed locale and assert the discovery +// code, never the wording — a Git build without the requested catalogue simply +// stays in English and the expectation still holds. + +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { listWorkspaceGitWorktrees } from '../../src/files/worktrees.js'; + +const execFile = promisify(execFileCallback); + +const LOCALES = ['zh_CN.utf8', 'en_US.UTF-8']; + +let fixture: string; +let plainFolder: string; +let repository: string; + +beforeEach(async () => { + fixture = await mkdtemp(join(tmpdir(), 'mcode-worktree-locale-')); + plainFolder = join(fixture, 'plain-folder'); + repository = join(fixture, 'repository'); + await mkdir(plainFolder, { recursive: true }); + await mkdir(repository, { recursive: true }); + await git(repository, ['-c', 'init.defaultBranch=main', 'init', '-q']); + await git(repository, [ + '-c', + 'user.name=P21', + '-c', + 'user.email=p21@example.test', + 'commit', + '-q', + '--allow-empty', + '-m', + 'initial', + ]); +}); + +afterEach(async () => { + await rm(fixture, { recursive: true, force: true }); +}); + +async function git(cwd: string, args: string[]): Promise { + await execFile('git', args, { cwd, encoding: 'utf-8' }); +} + +/** + * `git` translates its diagnostics from `LC_ALL`/`LANG`/`LC_MESSAGES`; the + * discovery inherits this process's environment, so pinning the variables here + * is what fixes the subprocess locale. Restoration is unconditional: another + * case in this file must not inherit the previous locale. + */ +async function withLocale(locale: string, run: () => Promise): Promise { + const saved = ['LANG', 'LC_ALL', 'LC_MESSAGES'].map((key) => [key, process.env[key]] as const); + process.env.LANG = locale; + process.env.LC_ALL = locale; + delete process.env.LC_MESSAGES; + try { + return await run(); + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +describe('listWorkspaceGitWorktrees locale parity', () => { + it.each(LOCALES)('reports a plain folder as not_git_repository under %s', async (locale) => { + const result = await withLocale(locale, () => listWorkspaceGitWorktrees(plainFolder)); + + expect(result.success).toBe(false); + expect(result.code).toBe('not_git_repository'); + expect(result.error).toBeTruthy(); + }); + + it.each(LOCALES)('lists the worktrees of a repository under %s', async (locale) => { + const result = await withLocale(locale, () => listWorkspaceGitWorktrees(repository)); + + expect(result.success).toBe(true); + expect(result.code).toBeUndefined(); + expect(result.worktrees).toHaveLength(1); + expect(result.worktrees[0]).toMatchObject({ branch: 'main', isMain: true, isActive: true }); + }); + + it('produces the same codes regardless of the locale in force', async () => { + const perLocale = []; + for (const locale of LOCALES) { + perLocale.push( + (await withLocale(locale, () => listWorkspaceGitWorktrees(plainFolder))).code, + (await withLocale(locale, () => listWorkspaceGitWorktrees(repository))).code, + ); + } + expect(perLocale).toEqual(['not_git_repository', undefined, 'not_git_repository', undefined]); + }); +}); diff --git a/packages/webui/server/app.js b/packages/webui/server/app.js index 74d107d29..de7e27b66 100644 --- a/packages/webui/server/app.js +++ b/packages/webui/server/app.js @@ -73,6 +73,8 @@ import * as pluginsRoute from "./routes/plugins.js"; import * as turnDiffRoute from "./routes/turn-diff.js"; import * as engineCapabilitiesRoute from "./routes/engine-capabilities.js"; import * as worktreesRoute from "./routes/worktrees.js"; + +import * as logsRoute from "./routes/logs.js"; import * as authorizeRoute from "./lib/authorize.js"; /** @@ -128,6 +130,13 @@ export const OWNED_ROUTES = new Set([ // Session export (Markdown / JSON). The `:` is Hono's parameter marker; // matches `/api/sessions//export`. "GET /api/sessions/:id/export", + // SB-8 (D-3) — the About section's 「导出日志」 action. A GET that always + // answers 200 `text/plain` with a `Content-Disposition: attachment`: the + // bundle is the server's own crash trail plus the tail of its event log, + // assembled by `lib/log-export.js`. It replaced a permanently disabled + // 「上传日志」 button whose label promised a cloud destination this + // edition has none of. + "GET /api/logs/export", // Chat: fire-and-forget (output pushed via /api/events SSE). "POST /api/send", "POST /api/stop", @@ -567,6 +576,11 @@ export function createHonoApp() { invokeHandler(c, c.get(CAPTURE_KEY), exportRoute.handleExport), ); + // ----- SB-8: log export (About section) ----- + app.get("/api/logs/export", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), logsRoute.handleExportLogs), + ); + // ----- Chat ----- // POST /api/send is fire-and-forget — the response is an ack, the actual // chat output flows back over the /api/events SSE channel. diff --git a/packages/webui/server/lib/config.js b/packages/webui/server/lib/config.js index e401fe3de..41b7fa028 100644 --- a/packages/webui/server/lib/config.js +++ b/packages/webui/server/lib/config.js @@ -324,6 +324,13 @@ export const DEFAULT_WORKSPACE = (() => { // re-export detectTuiCwd for workspace route export { detectTuiCwd }; +// SERVER_ERR_LOG — the file the global error handlers below append to. +// Declared here (rather than spelled `join(WEBUI_DATA_DIR, ".server.err")` +// at each site) so the writer and the reader that exports it — the About +// section's 「导出日志」 route, `lib/log-export.js` — cannot disagree about +// where the trail lives. +export const SERVER_ERR_LOG = join(WEBUI_DATA_DIR, ".server.err"); + // installGlobalErrorHandlers — log + append .server.err so a crashed // server leaves a trail rather than going silent. export function installGlobalErrorHandlers() { @@ -331,7 +338,7 @@ export function installGlobalErrorHandlers() { console.error("[uncaughtException]", err); try { import("node:fs").then(({ appendFileSync }) => { - const logFile = join(WEBUI_DATA_DIR, ".server.err"); + const logFile = SERVER_ERR_LOG; appendFileSync( logFile, `\n[uncaughtException ${new Date().toISOString()}] ${err.stack || err.message}\n`, @@ -343,7 +350,7 @@ export function installGlobalErrorHandlers() { console.error("[unhandledRejection]", reason); try { import("node:fs").then(({ appendFileSync }) => { - const logFile = join(WEBUI_DATA_DIR, ".server.err"); + const logFile = SERVER_ERR_LOG; appendFileSync( logFile, `\n[unhandledRejection ${new Date().toISOString()}] ${reason && reason.stack ? reason.stack : String(reason)}\n`, diff --git a/packages/webui/server/lib/log-export.js b/packages/webui/server/lib/log-export.js new file mode 100644 index 000000000..a3948cc96 --- /dev/null +++ b/packages/webui/server/lib/log-export.js @@ -0,0 +1,178 @@ +// webui/server/lib/log-export.js +// +// The body behind the About section's 「导出日志」 action (SB-8 / D-3). +// +// Why a bundle and not an upload. The disabled placeholder read 「上传日志」, +// which promises a destination this edition does not have: there is no +// telemetry sink, no ticket intake, and nothing leaves the machine. The +// honest action for a self-hosted server is to hand the operator a file. +// So this module assembles the server's own diagnostic trail into ONE text +// file that the browser saves, and the route serves it as an attachment. +// +// The two sources, and why only two: +// +// - `.server.err` — the crash trail `config.js#installGlobalErrorHandlers` +// appends to (uncaught exceptions, unhandled rejections). Small, and the +// first thing anyone debugging a dead server asks for. +// - `events.ndjson` — the append-only event log `lib/events.js` writes +// (session lifecycle, authorization decisions, exports). On a long-lived +// install this is tens of megabytes, so only its tail ships. +// +// What is deliberately NOT here: `sessions.json` (conversation transcripts), +// `settings.json` (provider credentials) and `uploads/`. A diagnostics file +// that a user is likely to attach to a bug report must not be the one file +// on the machine carrying their API keys and their conversations. +// +// Every failure is a VALUE, not an exception. A missing data directory, an +// unreadable file, a file that grew past the byte cap — each becomes a +// section that says so, so the downloaded file is never silently shorter +// than the operator believes. + +import { readFileSync } from "node:fs"; + +import { SERVER_ERR_LOG } from "./config.js"; +import * as events from "./events.js"; +import { fileTimestamp } from "./markdown.js"; + +// Line cap per source. The event log is the only one that realistically +// exceeds it; `.server.err` is a crash trail and stays whole. +export const DEFAULT_MAX_LINES_PER_FILE = 2000; + +// Byte cap per source, applied AFTER the line cap, because a single +// pathological line (a giant tool payload echoed into a log) would +// otherwise slip past a line-based bound. +export const DEFAULT_MAX_BYTES_PER_FILE = 2 * 1024 * 1024; + +/** + * The sources, resolved at call time. + * + * Both paths come from the modules that WRITE them rather than from a + * second hardcoded guess: `SERVER_ERR_LOG` for the crash trail, and + * `events.path()` for the event log (which honours + * `MCODE_WEBUI_EVENTS_PATH`, so a test or an operator with a relocated + * data directory is read from where it actually writes). + * + * `resolve` returning `null` means "this edition does not have that file + * at all" and the section records that instead of reading a guess. + */ +export function resolveLogSources() { + return [ + { id: "server-errors", label: "Server error log (.server.err)", path: SERVER_ERR_LOG }, + { id: "events", label: "Event log (events.ndjson, most recent last)", path: events.path() }, + ]; +} + +/** + * Keep the tail of `text`, bounded by BOTH a line count and a byte count. + * + * The tail (not the head) is what a diagnostic export needs: the failure + * being investigated happened most recently. `truncated` carries the counts + * the header prints, so the reader learns from the file itself that an + * older part was left out — a bounded file that looks complete is the one + * failure mode this whole module exists to avoid. + */ +export function tailText(text, { maxLines, maxBytes } = {}) { + const source = typeof text === "string" ? text : ""; + const lines = source.split("\n"); + // A trailing newline yields a final empty element; it is not a line. + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + const totalLines = lines.length; + const keptLines = lines.slice(Math.max(0, totalLines - maxLines)); + let body = keptLines.join("\n"); + const linesTruncated = totalLines > keptLines.length; + const bytesTruncated = body.length > maxBytes; + if (bytesTruncated) body = body.slice(body.length - maxBytes); + return { + body, + totalLines, + keptLines: keptLines.length, + linesTruncated, + bytesTruncated, + truncated: linesTruncated || bytesTruncated, + }; +} + +/** `mcode-webui-logs-.txt` — the attachment filename. */ +export function logBundleFilename(now = Date.now()) { + return `mcode-webui-logs-${fileTimestamp(now)}.txt`; +} + +/** + * Assemble the downloadable bundle. + * + * Returns the body text plus a per-source report, so a test (and the + * route's own audit line) can assert on what was actually included + * instead of on prose. + */ +export function buildLogBundle({ + sources = resolveLogSources(), + maxLinesPerFile = DEFAULT_MAX_LINES_PER_FILE, + maxBytesPerFile = DEFAULT_MAX_BYTES_PER_FILE, + now = Date.now(), +} = {}) { + const stamp = new Date(Number(now) || Date.now()).toISOString(); + const header = [ + "mcode-webui diagnostic log bundle", + `generated: ${stamp}`, + `sources per file: last ${maxLinesPerFile} lines / last ${maxBytesPerFile} bytes`, + "note: this bundle is produced by the server you are running; nothing was uploaded anywhere.", + ]; + const sections = []; + const report = []; + + for (const source of sources) { + const title = `===== ${source.label} =====`; + if (!source.path) { + const text = "(not available on this server)"; + sections.push(`${title}\n${text}\n`); + report.push({ id: source.id, path: source.path ?? null, state: "unavailable" }); + continue; + } + let raw; + try { + raw = readFileSync(source.path, "utf8"); + } catch (e) { + // ENOENT is not a failure to report as one: a server that has never + // crashed has no `.server.err`, and a fresh install has no event log. + // That is the bundle's NORMAL state, and the file says so in the same + // words the other sections use for an honest empty. + const missing = e.code === "ENOENT"; + const text = missing + ? "(not written yet — this server has logged nothing to this file)" + : `(unreadable: ${e.code || e.message})`; + sections.push(`${title}\n${text}\n`); + report.push({ + id: source.id, + path: source.path, + state: missing ? "absent" : "unreadable", + ...(missing ? {} : { reason: e.code || e.message }), + }); + continue; + } + const tail = tailText(raw, { maxLines: maxLinesPerFile, maxBytes: maxBytesPerFile }); + if (tail.totalLines === 0) { + sections.push(`${title}\n(empty — the file exists but holds no lines yet)\n`); + report.push({ id: source.id, path: source.path, state: "empty" }); + continue; + } + const notes = []; + if (tail.linesTruncated) { + notes.push(`[truncated: showing the last ${tail.keptLines} of ${tail.totalLines} lines]`); + } + if (tail.bytesTruncated) { + notes.push(`[truncated: also cut to the last ${maxBytesPerFile} bytes]`); + } + sections.push(`${title}\n${notes.length ? `${notes.join(" ")}\n` : ""}${tail.body}\n`); + report.push({ + id: source.id, + path: source.path, + state: "read", + totalLines: tail.totalLines, + keptLines: tail.keptLines, + truncated: tail.truncated, + }); + } + + const text = `${header.join("\n")}\n\n${sections.join("\n")}`; + return { text, sources: report, generatedAt: stamp }; +} diff --git a/packages/webui/server/routes/logs.js b/packages/webui/server/routes/logs.js new file mode 100644 index 000000000..9a1e28dde --- /dev/null +++ b/packages/webui/server/routes/logs.js @@ -0,0 +1,36 @@ +// webui/server/routes/logs.js +// GET /api/logs/export — the About section's 「导出日志」 action (SB-8 / D-3). +// +// This endpoint REPLACED a permanently disabled 「上传日志」 button. The old +// label promised a destination this edition does not have (no telemetry +// sink, no ticket intake, nothing leaves the machine), so the honest action +// is a download of the server's own diagnostic trail. The bundle is +// assembled by `lib/log-export.js`; this file owns the wire. +// +// Contract: +// - 200 `text/plain; charset=utf-8` with `Content-Disposition: +// attachment` — the browser saves the bytes under a timestamped +// filename. There is no `?download=` flag and no JSON variant: one +// shape, one meaning. +// - ALWAYS 200, including when a log file is missing or unreadable. +// Those cases are reported INSIDE the body (lib/log-export.js records +// them per section), because the UI hands the user a plain anchor and +// a 4xx would silently land a JSON error document in their downloads +// folder under a `.txt` name — a file that looks like logs and is not. +// A status code is the wrong channel when the deliverable is a file. +// +// Gate posture: this is a GET, so the shared chain (router gates 1-5) puts +// it behind the same token / LAN / rate-limit rules as every other read — +// the log trail is not a lower-sensitivity surface than the session list. + +import { buildLogBundle, logBundleFilename } from "../lib/log-export.js"; + +export function handleExportLogs(_req, res, _ctx) { + const { text } = buildLogBundle(); + res.writeHead(200, { + "Content-Type": "text/plain; charset=utf-8", + "Content-Disposition": `attachment; filename="${logBundleFilename()}"`, + "Content-Length": Buffer.byteLength(text, "utf8"), + }); + return res.end(text); +} diff --git a/packages/webui/test/routes/logs-export.test.js b/packages/webui/test/routes/logs-export.test.js new file mode 100644 index 000000000..29f9fad3e --- /dev/null +++ b/packages/webui/test/routes/logs-export.test.js @@ -0,0 +1,237 @@ +// webui/test/routes/logs-export.test.js +// +// SB-8 (D-3) — `GET /api/logs/export`, the About section's 「导出日志」. +// +// The decision this suite defends: the action is a LOCAL FILE HAND-OVER, +// not an upload. The row it replaced was a permanently disabled button +// labelled 「上传日志」, which promised a cloud destination this +// self-hosted edition does not have. Four invariants make the replacement +// honest, and each has a test below that fails when it is removed: +// +// 1. The endpoint ALWAYS answers 200 `text/plain` with a +// `Content-Disposition: attachment` — including when a log file is +// missing or unreadable. A 4xx here would land a JSON error document +// in the user's downloads folder under a `.txt` name, which is a file +// that looks like logs and is not. +// 2. A bounded file says it was bounded. `events.ndjson` is tens of +// megabytes on a long-lived install, so only its tail ships — and the +// truncation note carries the counts, so the reader learns from the +// file itself that an older part was left out. +// 3. The bundle carries the two diagnostic sources and nothing else. The +// files this server writes that hold CONVERSATIONS or CREDENTIALS +// (`sessions.json`, `settings.json`, `uploads/`) must never appear: +// a file users attach to a bug report cannot be the one file on the +// machine with their API keys in it. +// 4. The default sources are the ones the WRITERS use — `SERVER_ERR_LOG` +// for the crash trail, `events.path()` for the event log. A second +// hardcoded guess about where logs live is the failure mode that +// makes an export silently empty. +// +// `WEBUI_DATA_DIR` is read at import time by `server/lib/config.js`, so the +// per-test overrides below are installed BEFORE the dynamic imports — the +// same rule the spawn-isolation lint states for the four MCODE_WEBUI_* +// variables. Without them this suite would read the developer's own +// ~/.mcode-webui (which is how the first run of it "passed" while asserting +// against a stranger's EADDRINUSE log). The temp directory comes from +// `test/helpers/tmp.js` (mkTmpDir), never a bare mkdtemp, so the suite's +// exit/signal handlers clean it up. + +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { existsSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { mkTmpDir } from "../helpers/tmp.js"; + +const dataDir = mkTmpDir("logs-export-"); +const eventsPath = join(dataDir, "events.ndjson"); +// The four the isolation lint names, plus DATA_DIR (the bundle's own +// default source list is derived from it). +process.env.MCODE_WEBUI_DATA_DIR = dataDir; +process.env.MCODE_WEBUI_EVENTS_PATH = eventsPath; +process.env.MCODE_WEBUI_SETTINGS_PATH = join(dataDir, "settings.json"); +process.env.MCODE_WEBUI_SESSIONS_DB = join(dataDir, "sessions.json"); +process.env.MCODE_WEBUI_UPLOAD_DIR = join(dataDir, "uploads"); + +const absPath = (rel) => + pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href; + +const { tailText, logBundleFilename, buildLogBundle, resolveLogSources } = + await import(absPath("lib/log-export.js")); +const { handleExportLogs } = await import(absPath("routes/logs.js")); + +// A response writer with the three methods the route uses. The `done` +// promise lets a caller await the end of the response without a socket. +function fakeRes() { + let resolveDone; + const done = new Promise((r) => (resolveDone = r)); + return { + status: 0, + headers: {}, + body: "", + done, + writeHead(status, headers) { + this.status = status; + this.headers = headers || {}; + }, + end(chunk) { + if (chunk !== undefined) this.body += chunk; + resolveDone(); + }, + }; +} + +// Each test starts from an empty data directory, so "this file does not +// exist" is a state a test can actually create. +beforeEach(() => { + for (const name of [".server.err", "events.ndjson", "settings.json", "sessions.json", "usage-history.ndjson"]) { + rmSync(join(dataDir, name), { force: true }); + } +}); + +function writeLines(path, count, prefix = "line") { + const body = Array.from({ length: count }, (_, i) => `${prefix}-${i}`).join("\n") + "\n"; + writeFileSync(path, body, "utf8"); + return body; +} + +describe("tailText — the tail is bounded by lines AND by bytes", () => { + test("keeps the LAST lines, not the first", () => { + const text = "a\nb\nc\nd\ne\n"; + const tail = tailText(text, { maxLines: 2, maxBytes: 1024 }); + assert.equal(tail.body, "d\ne"); + assert.equal(tail.totalLines, 5); + assert.equal(tail.keptLines, 2); + assert.equal(tail.linesTruncated, true); + assert.equal(tail.truncated, true); + }); + + test("the trailing newline is not counted as a line", () => { + // Without this, a one-line file with a final newline reports two lines + // and every "showing N of M" note is off by one. + const tail = tailText("only\n", { maxLines: 10, maxBytes: 1024 }); + assert.equal(tail.totalLines, 1); + assert.equal(tail.keptLines, 1); + assert.equal(tail.truncated, false); + }); + + test("a byte cap also bites, and is reported separately from the line cap", () => { + const tail = tailText("x".repeat(50) + "\n" + "y".repeat(50), { maxLines: 100, maxBytes: 10 }); + assert.equal(tail.bytesTruncated, true); + assert.equal(tail.linesTruncated, false); + assert.equal(tail.truncated, true); + assert.equal(tail.body, "y".repeat(10)); + }); + + test("a file under both caps is not marked truncated", () => { + const tail = tailText("one\ntwo\n", { maxLines: 10, maxBytes: 1024 }); + assert.equal(tail.truncated, false); + }); +}); + +describe("logBundleFilename", () => { + test("is a Windows-safe .txt name stamped with the export time", () => { + // The assertion is on the SHAPE, because the clock zone is the host's, + // not this suite's. + const name = logBundleFilename(Date.UTC(2026, 9, 4, 0, 46, 0)); + assert.match(name, /^mcode-webui-logs-\d{8}T\d{6}\.txt$/); + assert.ok(!name.includes(":"), "no colons — Windows-safe"); + }); +}); + +describe("buildLogBundle — what ships and what must never ship", () => { + test("includes both diagnostic sources when both exist", () => { + writeFileSync(join(dataDir, ".server.err"), "[uncaughtException] boom\n", "utf8"); + writeLines(eventsPath, 3, "event"); + const { text, sources } = buildLogBundle({ now: 0 }); + assert.match(text, /===== Server error log \(\.server\.err\) =====/); + assert.match(text, /===== Event log \(events\.ndjson, most recent last\) =====/); + assert.match(text, /\[uncaughtException\] boom/); + assert.match(text, /event-2/); + assert.deepEqual(sources.map((s) => s.state), ["read", "read"]); + }); + + test("says so, per section, when a file was never written", () => { + const { text, sources } = buildLogBundle({ now: 0 }); + assert.match(text, /not written yet/); + assert.deepEqual(sources.map((s) => s.state), ["absent", "absent"]); + // Absent is a state, not a crash: the bundle is still produced. + assert.ok(text.length > 0); + }); + + test("an empty file reads as empty, not as absent", () => { + writeFileSync(join(dataDir, ".server.err"), "", "utf8"); + const { text, sources } = buildLogBundle({ now: 0 }); + assert.match(text, /empty — the file exists but holds no lines yet/); + assert.equal(sources[0].state, "empty"); + }); + + test("truncation is stated in the file, with the counts", () => { + writeLines(eventsPath, 50, "e"); + const { text } = buildLogBundle({ maxLinesPerFile: 5, now: 0 }); + assert.match(text, /\[truncated: showing the last 5 of 50 lines\]/); + assert.ok(!/e-0\b/.test(text), "the oldest lines are the ones dropped"); + assert.match(text, /e-49/); + }); + + test("never carries conversations or credentials out of the data directory", () => { + // The reason the source list is a closed two. A future change that adds + // "settings.json because it is in the same directory" fails here. + writeFileSync(join(dataDir, ".server.err"), "[uncaughtException] boom\n", "utf8"); + writeLines(eventsPath, 2, "e"); + writeFileSync(join(dataDir, "settings.json"), '{"apiKey":"SECRET-KEY-MARKER"}', "utf8"); + writeFileSync(join(dataDir, "sessions.json"), '[{"chat":["PRIVATE-CONVERSATION-MARKER"]}]', "utf8"); + writeFileSync(join(dataDir, "usage-history.ndjson"), "USAGE-MARKER\n", "utf8"); + + const { text } = buildLogBundle({ now: 0 }); + for (const marker of ["SECRET-KEY-MARKER", "PRIVATE-CONVERSATION-MARKER", "USAGE-MARKER"]) { + assert.ok(!text.includes(marker), `bundle leaked ${marker}`); + } + }); + + test("the header states the cap and denies any upload", () => { + const { text } = buildLogBundle({ now: 0 }); + assert.match(text, /last 2000 lines/); + assert.match(text, /nothing was uploaded anywhere/); + }); +}); + +describe("resolveLogSources — the default paths come from the writers", () => { + test("the crash trail is the config constant and the event log follows its env", () => { + const sources = resolveLogSources(); + assert.equal(sources[0].path, join(dataDir, ".server.err")); + assert.equal(sources[1].path, eventsPath); + assert.ok(!existsSync(sources[0].path), "the fixture starts empty"); + }); +}); + +describe("handleExportLogs — the wire", () => { + test("answers 200 text/plain as an attachment, always", async () => { + writeFileSync(join(dataDir, ".server.err"), "[uncaughtException] boom\n", "utf8"); + const res = fakeRes(); + handleExportLogs({ url: "/api/logs/export" }, res, { cid: "c", cs: {}, pathname: "/api/logs/export" }); + await res.done; + + assert.equal(res.status, 200); + assert.equal(res.headers["Content-Type"], "text/plain; charset=utf-8"); + assert.match(res.headers["Content-Disposition"], /^attachment; filename="mcode-webui-logs-\d{8}T\d{6}\.txt"$/); + assert.equal(res.headers["Content-Length"], Buffer.byteLength(res.body, "utf8")); + assert.match(res.body, /mcode-webui diagnostic log bundle/); + assert.match(res.body, /\[uncaughtException\] boom/); + }); + + test("a missing log file is still 200, and says so in the body", async () => { + // The failure this guards: a 404 here would be saved by the browser as + // `mcode-webui-logs-.txt` containing a JSON error body. + const res = fakeRes(); + handleExportLogs({ url: "/api/logs/export" }, res, { cid: "c", cs: {}, pathname: "/api/logs/export" }); + await res.done; + + assert.equal(res.status, 200); + assert.match(res.headers["Content-Disposition"], /^attachment;/); + assert.match(res.body, /not written yet/); + assert.ok(!res.body.includes("ENOENT"), "an absent file is not an error to the reader"); + }); +}); diff --git a/packages/webui/test/server/app-hono.test.js b/packages/webui/test/server/app-hono.test.js index 8d225182e..fdf7c8353 100644 --- a/packages/webui/test/server/app-hono.test.js +++ b/packages/webui/test/server/app-hono.test.js @@ -78,6 +78,8 @@ describe("app.js — migration ledger", () => { "GET /api/acp-sessions", "GET /api/acp-session-title", "GET /api/sessions/:id/export", + // SB-8 (D-3): the About section's log download. + "GET /api/logs/export", "POST /api/send", "POST /api/stop", "POST /api/cmd", diff --git a/packages/webui/webapp/components/settings-extra-pages.tsx b/packages/webui/webapp/components/settings-extra-pages.tsx index a1aba8bab..9227e4a79 100644 --- a/packages/webui/webapp/components/settings-extra-pages.tsx +++ b/packages/webui/webapp/components/settings-extra-pages.tsx @@ -29,16 +29,21 @@ // dropdown shows the standing 「本地版不适用」 placeholder as its only // option; the two dictation rows show 「未设置」 like the desktop's // unset state. -// - Personalization: 自定义指令 / 关于你 are REAL — both persist to -// `localStorage` and survive refresh. The memory card has no local -// memory system behind it: both switches render off and greyed (the -// desktop's 记忆 row shows a live blue ON — a capability claim this -// client cannot make), and the 管理 button opens the desktop's -// 记忆摘要 dialog in its permanent empty state. +// - Personalization: 自定义指令 / 关于你 persist to `localStorage` and +// survive refresh. They are NOT read by anything: SB-8 (D-2) closed +// with a grep that found no engine channel able to consume them +// (`setConfigOption` does not exist in the runtime source at all), so +// each field carries the standing note 「已保存于本浏览器,不会注入引擎会话」 +// rather than letting a saved instruction read as one that takes +// effect. The memory card has no local memory system behind it: both +// switches render off and greyed (the desktop's 记忆 row shows a live +// blue ON — a capability claim this client cannot make), and the 管理 +// button opens the desktop's 记忆摘要 dialog in its permanent empty +// state. // - Code review: 审查方式 renders 子会话 (the one locally meaningful // value — the engine runs reviews in a sub-session) as a disabled -// dropdown; 自定义审查准则 is REAL and persists like the two -// Personalization texts. +// dropdown; 自定义审查准则 persists like the two Personalization +// texts, and carries the same non-injection note for the same reason. // // This module imports React explicitly: the render test loads it under // the tsx loader with `jsx: "preserve"`, which falls back to the classic @@ -217,6 +222,7 @@ function PersistedTextBlock({ testId, read, commit, + note: noteText, t, }: { title: string; @@ -226,6 +232,8 @@ function PersistedTextBlock({ testId: string; read: () => string; commit: (setState: (value: string) => void, value: string) => void; + /** Optional standing caption under the field (SB-8 / D-2). */ + note?: string; t: (key: MessageKey) => string; }) { const [draft, setDraft] = useState(read); @@ -255,11 +263,25 @@ function PersistedTextBlock({ /> ); + // SB-8 (D-2): the standing honesty line under a stored-but-unread text. + // The engine has no channel that would consume these values, so the field + // says so next to the input rather than letting a saved instruction read + // as an instruction that takes effect. + const note = noteText ? ( +

+ {noteText} +

+ ) : null; + if (layout === "labelAbove") { return (
{title} {textarea} + {note}
{save}
); @@ -276,6 +298,7 @@ function PersistedTextBlock({ {save} {textarea} + {note} ); } @@ -669,6 +692,7 @@ export function PersonalizationSection({ t }: { t: (key: MessageKey) => string } testId="settings-personalization-instructions" read={readCustomInstructions} commit={commitCustomInstructions} + note={t("settings.storedOnly")} t={t} /> string } testId="settings-personalization-about" read={readAboutUser} commit={commitAboutUser} + note={t("settings.storedOnly")} t={t} /> string }) { testId="settings-code-review-guidelines" read={readCodeReviewGuidelines} commit={commitCodeReviewGuidelines} + note={t("settings.storedOnly")} t={t} /> diff --git a/packages/webui/webapp/components/settings-modal-port.tsx b/packages/webui/webapp/components/settings-modal-port.tsx index c0fc8a7b3..185ede784 100644 --- a/packages/webui/webapp/components/settings-modal-port.tsx +++ b/packages/webui/webapp/components/settings-modal-port.tsx @@ -290,6 +290,40 @@ function SettingsButton({ ); } +/** + * A settings-row action that is really a navigation to a file, not a + * callback: the same button chrome as `SettingsButton`, rendered as an + * anchor so the browser owns the download (SB-8 / D-3). + * + * The `download` attribute is set without a value, which tells the browser + * to save the response under the server's `Content-Disposition` filename + * rather than navigate to it — the response is `text/plain`, so without + * it the click would replace the app with a wall of log lines. + */ +function SettingsLink({ + children, + href, + title, + testId, +}: { + readonly children: ReactNode; + readonly href: string; + readonly title?: string; + readonly testId?: string; +}): ReactElement { + return ( + + {children} + + ); +} + function SettingRow({ title, description, @@ -697,10 +731,24 @@ function GenericPage({ - - - {t("settings.about.uploadAction")} - + {/* SB-8 (D-3): 「上传日志」 → 「导出日志」, and the button is live. + * The old row was a permanently disabled button whose label + * promised a cloud destination this self-hosted edition does not + * have. The action is now what it can honestly be: an anchor at + * `GET /api/logs/export`, which answers 200 `text/plain` with a + * `Content-Disposition: attachment` and lets the browser save the + * server's own diagnostic trail. An anchor (not a fetch + blob) + * because the endpoint has no failure status to branch on — a + * missing or unreadable log file is reported inside the downloaded + * body, so there is nothing for the page to catch. */} + + + {t("settings.about.exportAction")} + diff --git a/packages/webui/webapp/lib/api.ts b/packages/webui/webapp/lib/api.ts index 5b1dd6224..7e55bdc11 100644 --- a/packages/webui/webapp/lib/api.ts +++ b/packages/webui/webapp/lib/api.ts @@ -1753,6 +1753,20 @@ export function sessionExportUrl(id: string, format: "md" | "json" = "md"): stri ); } +/** + * The About section's 「导出日志」 target (SB-8 / D-3). + * + * A plain URL, not a fetch: the endpoint answers 200 `text/plain` with + * `Content-Disposition: attachment` in every case — a missing or unreadable + * log file is reported inside the downloaded body, never as a status code + * (see `server/routes/logs.js`). A fetch would add a failure path the + * server deliberately does not have, and the browser's own download flow is + * the one that already works for every other export in this client. + */ +export function logsExportUrl(): string { + return withClientQuery("/api/logs/export"); +} + // --- git panel (slice 03) ------------------------------------------------- /** diff --git a/packages/webui/webapp/lib/i18n.ts b/packages/webui/webapp/lib/i18n.ts index efa7245c7..ee79195d3 100644 --- a/packages/webui/webapp/lib/i18n.ts +++ b/packages/webui/webapp/lib/i18n.ts @@ -1206,6 +1206,16 @@ const en = { "settings.codeReview.guidelines": "Custom review guidelines", "settings.codeReview.guidelinesPlaceholder": "Enter code-review rules to apply on every review", + // SB-8 (D-2): the honesty note under the three long-text blocks + // (自定义指令 / 关于你 / 审查准则). The engine has no channel that would + // read them — `setConfigOption` does not exist anywhere in the runtime + // source, so there is nowhere to inject them. The texts stay editable + // (they persist in this browser and remain readable to the user), but + // the field now says plainly that saving one does not change what the + // engine sees. Without this line a saved instruction reads as an + // instruction, which is the exact lie D-2 closed. + "settings.storedOnly": + "Saved in this browser only — it is not injected into engine sessions.", /* Ticket 59 D3-4: the settings-modal port's hardcoded Chinese moved into the dictionary (en side). */ "settings.nav.aria": "Settings sections", @@ -1303,10 +1313,14 @@ const en = { "settings.preference.dataOptInHint": "Allow your conversations to improve MiniMax Code; your data stays private and secure", "settings.about.section": "About", - "settings.about.uploadLogs": "Upload logs", - "settings.about.uploadLogsHint": "Upload app logs to help with troubleshooting", - "settings.about.uploadUnavailable": "The local edition cannot upload logs", - "settings.about.uploadAction": "Upload", + // SB-8 (D-3): 「上传日志」 → 「导出日志」. The old row promised a + // destination this edition has none of; the action is now a real + // download of the server's own diagnostic trail (`GET /api/logs/export`), + // and the hint says what the file actually contains. + "settings.about.exportLogs": "Export logs", + "settings.about.exportLogsHint": + "Download this server's error log and recent activity as one text file; nothing is uploaded anywhere", + "settings.about.exportAction": "Export", "settings.about.version": "App version", "settings.about.updateUnavailable": "The local edition cannot check for updates", "settings.about.checkUpdate": "Check for updates", @@ -2351,6 +2365,11 @@ const zh: Record = { "settings.codeReview.methodSubsession": "子会话", "settings.codeReview.guidelines": "自定义审查准则", "settings.codeReview.guidelinesPlaceholder": "输入需要长期应用的代码审查规则", + // SB-8(D-2):三处长文本(自定义指令 / 关于你 / 审查准则)输入框下的诚实 + // 说明。引擎没有读取它们的通道——`setConfigOption` 在 local-runtime-v2 全源 + // 不存在——所以无处注入。文本仍可编辑(存于本浏览器,用户自己随时能看), + // 但输入框直说「不会注入」,不再让已保存的指令读起来像一条生效的指令。 + "settings.storedOnly": "已保存于本浏览器,不会注入引擎会话。", /* 工单 59 D3-4:设置壳的硬编码中文收进字典(zh 侧原文照搬)。 */ "settings.nav.aria": "设置分类", "settings.account.signOutUnavailable": "本地版未接入账户服务", @@ -2435,10 +2454,12 @@ const zh: Record = { "settings.preference.dataOptIn": "数据用于优化体验", "settings.preference.dataOptInHint": "允许我们将你的对话内容用于优化 MiniMax Code 的使用体验。我们保障你的数据隐私安全。", "settings.about.section": "关于", - "settings.about.uploadLogs": "上传日志", - "settings.about.uploadLogsHint": "上传应用日志以协助排查问题", - "settings.about.uploadUnavailable": "本地版未接入日志上传", - "settings.about.uploadAction": "上传", + // SB-8(D-3):「上传日志」→「导出日志」。旧文案承诺了本地版根本没有的 + // 目的地;现在的动作是下载本服务端自己的诊断日志(`GET /api/logs/export`), + // 说明文案写清文件里到底是什么。 + "settings.about.exportLogs": "导出日志", + "settings.about.exportLogsHint": "把本服务端错误日志与近期活动记录导出为一个文本文件;不会上传到任何地方", + "settings.about.exportAction": "导出", "settings.about.version": "应用版本", "settings.about.updateUnavailable": "本地版未接入更新检查", "settings.about.checkUpdate": "检查更新", diff --git a/packages/webui/webapp/test/i18n-settings-parity.test.ts b/packages/webui/webapp/test/i18n-settings-parity.test.ts index cc2d88f6e..b9c68d6a1 100644 --- a/packages/webui/webapp/test/i18n-settings-parity.test.ts +++ b/packages/webui/webapp/test/i18n-settings-parity.test.ts @@ -140,6 +140,13 @@ const NEW_KEYS = [ "settings.codeReview.methodSubsession", "settings.codeReview.guidelines", "settings.codeReview.guidelinesPlaceholder", + // SB-8 (D-2): the non-injection note under the three stored long-text + // blocks, and (D-3) the About section's log-export row — the keys that + // replaced the 「上传日志」 vocabulary. + "settings.storedOnly", + "settings.about.exportLogs", + "settings.about.exportLogsHint", + "settings.about.exportAction", // SB-1 — the 「用量与模型」 model-source row: the in-use badge, the // key-status badges, the three busy labels, and the two refusals the // engine can answer (no key stored yet / save before probing). The old @@ -177,6 +184,17 @@ const RETIRED_USAGE_KEYS = [ "usage.reset", ] as const; +// SB-8 (D-3): the 「上传日志」 vocabulary is retired in BOTH dictionaries. +// The row it named is now 「导出日志」 and is a real download, so leaving +// the old strings behind would be a ghost label for a capability this +// edition has none of — a cloud destination to upload to. +const RETIRED_UPLOAD_LOG_KEYS = [ + "settings.about.uploadLogs", + "settings.about.uploadLogsHint", + "settings.about.uploadUnavailable", + "settings.about.uploadAction", +] as const; + const RETIRED_POPOVER_KEYS = [ "usagePopover.title", "usagePopover.fiveHour", @@ -209,6 +227,44 @@ describe("i18n settings parity (ticket 37)", () => { } }); + test("SB-8 (D-3): the 「上传日志」 vocabulary is gone from both dictionaries", () => { + for (const key of RETIRED_UPLOAD_LOG_KEYS) { + assert.equal( + translate("en", key as unknown as MessageKey), + undefined, + `${key} must be removed from the en dictionary`, + ); + assert.equal( + translate("zh", key as unknown as MessageKey), + undefined, + `${key} must be removed from the zh dictionary`, + ); + } + }); + + test("SB-8 (D-3): the About row says the logs are downloaded, not uploaded", () => { + assert.equal(translate("zh", "settings.about.exportLogs" as MessageKey), "导出日志"); + assert.equal(translate("en", "settings.about.exportLogs" as MessageKey), "Export logs"); + for (const locale of ["en", "zh"] as const) { + const hint = translate(locale, "settings.about.exportLogsHint" as MessageKey); + assert.ok( + /nothing is uploaded|不会上传/.test(hint), + `${locale} export hint must deny the upload it replaced: ${hint}`, + ); + } + }); + + test("SB-8 (D-2): the stored-text note tells the user it is not injected", () => { + assert.equal( + translate("zh", "settings.storedOnly" as MessageKey), + "已保存于本浏览器,不会注入引擎会话。", + ); + assert.equal( + translate("en", "settings.storedOnly" as MessageKey), + "Saved in this browser only — it is not injected into engine sessions.", + ); + }); + test("SB-1: the in-use badge reads the reference's 「使用中」 in Chinese", () => { assert.equal(translate("zh", "usageModels.source.inUse" as MessageKey), "使用中"); assert.equal(translate("en", "usageModels.source.inUse" as MessageKey), "In use"); diff --git a/packages/webui/webapp/test/settings-extra-pages.test.ts b/packages/webui/webapp/test/settings-extra-pages.test.ts index c82570808..51b29b09f 100644 --- a/packages/webui/webapp/test/settings-extra-pages.test.ts +++ b/packages/webui/webapp/test/settings-extra-pages.test.ts @@ -459,6 +459,66 @@ describe("CodeReviewSection: disabled method dropdown, real guideline persistenc }); }); +// --------------------------------------------------------------------------- +// SB-8 (D-2) — the three stored long-text blocks say they are not injected. +// +// The texts persist and stay editable; that part was never the problem. The +// problem was that nothing reads them: a grep over the engine source found +// no `setConfigOption` (or any other channel) able to carry a custom +// instruction, a user profile, or a review guideline into a session. A field +// that saves an instruction and prints nothing about its fate reads as an +// instruction that takes effect, which is the exact claim D-2 closed. +// +// These are RENDER assertions on all three surfaces, because the note is +// prose a user reads: a key that exists in the dictionary but is not mounted +// under the field leaves the lie standing. +// --------------------------------------------------------------------------- +describe("SB-8 (D-2): every stored text block declares that it is not injected", () => { + beforeEach(() => { + storage.clear(); + }); + + const STORED_ONLY = "已保存于本浏览器,不会注入引擎会话。"; + const SURFACES = [ + { name: "自定义指令", markup: () => render(createElement(PersonalizationSection, { t: tZh })), testId: "settings-personalization-instructions-note" }, + { name: "关于你", markup: () => render(createElement(PersonalizationSection, { t: tZh })), testId: "settings-personalization-about-note" }, + { name: "自定义审查准则", markup: () => render(createElement(CodeReviewSection, { t: tZh })), testId: "settings-code-review-guidelines-note" }, + ]; + + for (const surface of SURFACES) { + test(`${surface.name} renders the note under its field`, () => { + const markup = surface.markup(); + const note = controlMarkup(markup, surface.testId); + assert.ok(markup.includes(STORED_ONLY), `${surface.name} must print the honest note`); + // The note sits BELOW the textarea, not above it: it describes what + // saving the field does, so it reads after the field it is about. + assert.ok( + markup.indexOf(surface.testId) > markup.indexOf(`${surface.testId.replace("-note", "")}-textarea`), + `${surface.name}: the note must follow the textarea`, + ); + assert.ok(note.includes("text-text_default_tertiary"), "rendered as a caption, not as body text"); + }); + } + + test("a SAVED value still renders, and still carries the note", () => { + // Storing stays supported — the value is the user's own text and stays + // readable. D-2 changed the promise printed under it, nothing else. + storage.set(CUSTOM_INSTRUCTIONS_KEY, "永远不要吞异常"); + const markup = render(createElement(PersonalizationSection, { t: tZh })); + assert.ok(markup.includes("永远不要吞异常"), "the text still round-trips through storage"); + assert.ok(markup.includes(STORED_ONLY)); + }); + + test("neither locale claims the text reaches the engine", () => { + // The pre-D-2 surfaces carried no such claim in prose, but the + // dictionary is where a future edit would add one back. + for (const locale of ["en", "zh"] as const) { + const note = translate(locale, "settings.storedOnly" as MessageKey); + assert.match(note, /not injected|不会注入/, `${locale} note must deny injection: ${note}`); + } + }); +}); + describe("the three long-text keys (settings-local, ticket 55a)", () => { beforeEach(() => { storage.clear(); diff --git a/packages/webui/webapp/test/settings-log-export.test.ts b/packages/webui/webapp/test/settings-log-export.test.ts new file mode 100644 index 000000000..f40f2484f --- /dev/null +++ b/packages/webui/webapp/test/settings-log-export.test.ts @@ -0,0 +1,112 @@ +// webapp/test/settings-log-export.test.ts +// +// SB-8 (D-3) — the About section's 「导出日志」 replaced a disabled +// 「上传日志」 button. +// +// Why a static tripwire and not a render test: the About row lives inside +// `settings-modal-port.tsx`, whose module graph reaches the store and the +// api client, so the webapp suite (server-render only, no DOM harness — see +// settings-parity-nav.test.ts's header for the standing rule) cannot mount +// it. The URL helper is import-clean and IS driven directly below; the row's +// wiring is pinned as source, the same split `settings-account-readout` +// established for the account section. +// +// The defect each guard exists for: +// +// - The button must not be disabled. The whole point of D-3 is that the +// action became real; a `disabled` left on it re-creates the placeholder +// under a truthful label, which is worse than the old one because the +// label now promises a working download. +// - It must be an ANCHOR at the export endpoint with a `download` +// attribute, not a button whose onClick a reader assumes fetches. The +// endpoint answers `text/plain`; without `download` the click navigates +// the app away to a wall of log lines. +// - The 「上传日志」 vocabulary must be gone from the component AND the +// dictionary in both locales (i18n-settings-parity.test.ts pins the +// dictionary half). + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { resolve, dirname } from "node:path"; + +import { logsExportUrl } from "../lib/api"; +import { translate, type MessageKey } from "../lib/i18n"; + +const here = dirname(fileURLToPath(import.meta.url)); +const portSource = readFileSync(resolve(here, "../components/settings-modal-port.tsx"), "utf8"); + +/** The About row's SettingRow, from its opening tag to its closing tag. */ +function aboutRowBlock(): string { + const at = portSource.indexOf('testId="export-logs-row"'); + assert.ok(at > 0, "the export-logs row must be declared"); + return portSource.slice(portSource.lastIndexOf("", at)); +} + +describe("logsExportUrl points at the endpoint that exists", () => { + test("is the export path, carrying the client query when one exists", () => { + const url = logsExportUrl(); + assert.match(url, /^\/api\/logs\/export(\?.*)?$/); + // `withClientQuery` is what carries `?cid=` (and the token when set) — + // the same client identity every other call in api.ts sends. + assert.ok(!url.includes("?format="), "the endpoint has no format parameter to fill in"); + }); +}); + +describe("the About row (settings-modal-port.tsx)", () => { + test("the export action is a live anchor, not a disabled button", () => { + // The anchor itself is declared once, in the `SettingsLink` helper; the + // About row supplies its href. Both halves are pinned, because a + // `download` attribute lost in the helper and a `disabled` prop added + // at the call site are the two ways this action dies silently. + const helper = portSource.slice( + portSource.indexOf("function SettingsLink("), + portSource.indexOf("function SettingRow("), + ); + assert.match(helper, /"), + ); + assert.ok(call.includes('testId="export-logs-action"'), "the About row is the caller"); + assert.ok(call.includes("api.logsExportUrl()"), "the href comes from the shared helper"); + assert.ok(!/disabled/.test(call), "D-3's whole point is that this action is live"); + }); + + test("the row is titled as an export and says nothing leaves the machine", () => { + const row = aboutRowBlock(); + assert.ok(row.includes('t("settings.about.exportLogs")'), "row title key"); + assert.ok(row.includes('t("settings.about.exportLogsHint")'), "row hint key"); + const hint = translate("zh", "settings.about.exportLogsHint" as MessageKey); + assert.match(hint, /不会上传/, "the zh hint must deny the upload it replaced"); + assert.match(translate("en", "settings.about.exportLogsHint" as MessageKey), /nothing is uploaded/); + }); + + test("no 「上传日志」 vocabulary survives in the component", () => { + for (const key of ["settings.about.uploadLogs", "settings.about.uploadLogsHint", "settings.about.uploadUnavailable", "settings.about.uploadAction"]) { + assert.ok(!portSource.includes(key), `${key} must not be referenced any more`); + } + // The prose check reads the component with its comments stripped: the + // SB-8 comment above the row quotes the old label on purpose, and a + // comment cannot reach the user. + const code = portSource + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^[ \t]*\/\/.*$/gm, ""); + assert.ok(!/Upload logs|上传日志/.test(code), "no live markup may still name the upload action"); + }); + + test("the check-for-update row is untouched — it is still an honest placeholder", () => { + // D-3 renamed ONE row. 检查更新 has no honest local implementation + // (self-hosted update = git pull), so it keeps its disabled form; a + // "while we were here" de-disabling would be the same lie D-3 removed. + const at = portSource.indexOf('t("settings.about.checkUpdate")'); + assert.ok(at > 0, "the update row still exists"); + const window = portSource.slice(Math.max(0, at - 400), at); + assert.match(window, /SettingsButton[^]*disabled|disabled[^]*SettingsButton/, "the update button stays disabled"); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index a59d471cc..ae7c7277d 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -2730,6 +2730,8 @@ "packages/local-runtime/test/unit/thread-goal/host-integration-testkit.ts", "packages/local-runtime/test/unit/thread-goal/host-integration-verifier-charge-rearm.test.ts", "packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts", + "packages/local-runtime/test/unit/worktree-discovery-code.test.ts", + "packages/local-runtime/test/unit/worktree-locale-parity.test.ts", "packages/mcode-tools-host/README.md", "packages/mcode-tools-host/package.json", "packages/mcode-tools-host/src/contracts.ts", @@ -3511,6 +3513,7 @@ "packages/webui/server/lib/interaction/user-questions.js", "packages/webui/server/lib/lan.js", "packages/webui/server/lib/layout.js", + "packages/webui/server/lib/log-export.js", "packages/webui/server/lib/markdown.js", "packages/webui/server/lib/mavis-usage.js", "packages/webui/server/lib/mcode-acp.js", @@ -3551,6 +3554,7 @@ "packages/webui/server/routes/fs.js", "packages/webui/server/routes/git.js", "packages/webui/server/routes/health.js", + "packages/webui/server/routes/logs.js", "packages/webui/server/routes/model-source.js", "packages/webui/server/routes/model.js", "packages/webui/server/routes/plugins.js", @@ -3718,6 +3722,7 @@ "packages/webui/test/routes/git.test.js", "packages/webui/test/routes/handleStop-dispatch.test.js", "packages/webui/test/routes/health.check.mjs", + "packages/webui/test/routes/logs-export.test.js", "packages/webui/test/routes/model-source.test.js", "packages/webui/test/routes/model.check.mjs", "packages/webui/test/routes/plugins.test.js", @@ -4030,6 +4035,7 @@ "packages/webui/webapp/test/settings-account-readout.test.ts", "packages/webui/webapp/test/settings-extra-pages.test.ts", "packages/webui/webapp/test/settings-general-sections.test.ts", + "packages/webui/webapp/test/settings-log-export.test.ts", "packages/webui/webapp/test/settings-parity-nav.test.ts", "packages/webui/webapp/test/settings-worktree-section.test.ts", "packages/webui/webapp/test/shell-elements-parity.test.ts", diff --git a/scripts/test-tmp-leak.check.mjs b/scripts/test-tmp-leak.check.mjs index bb8b3a1d9..ab83c5fb6 100644 --- a/scripts/test-tmp-leak.check.mjs +++ b/scripts/test-tmp-leak.check.mjs @@ -215,6 +215,7 @@ const KNOWN_PREFIXES = [ "git-panel-badge-unborn-", "git-panel-plain-", "git-panel-repo-", + "logs-export-", "mcode-d01-empty-", "mcode-d01-home-", "mcode-d01-isolate-", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 6ef1ba3a6..c33ddfe9a 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -110,6 +110,8 @@ "packages/local-runtime/test/unit/thread-goal/host-integration-settlement.test.ts", "packages/local-runtime/test/unit/thread-goal/host-integration-verifier-charge-rearm.test.ts", "packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts", + "packages/local-runtime/test/unit/worktree-discovery-code.test.ts", + "packages/local-runtime/test/unit/worktree-locale-parity.test.ts", "packages/mcode-tools-host/test/unit/lease-broker.test.ts", "packages/mcode-tools-host/test/unit/resource-manifest.test.ts", "packages/mcode-tools-host/test/unit/resource.test.ts",