From 8b7e1f5035f1db83f1b5ad96da837cfd9cb10831 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:06:37 +0800 Subject: [PATCH] fix(client): name an unread error log instead of claiming a clean one (#2833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2833 Docs-not-needed: the Feedback page's empty state is pinned by its new test and the changelog fragment; docs/subsystems/core-client.md documents the list's split and Clear, not this sentence. Risk tier: T1 Owner: Dev-1 Reviewer: Reviewer Written-by: Dev-1 Bot: Dev-1 Base: 1e27f10ed89d364e085db24b8d3e21b0af0e1792 Claim-Id: 1fd7cb86bd1148d7be89842b4e92cb2d ## What changed `core/src/client/feedback.js` (`FeedbackPage()`) drew `No errors recorded.` whenever the list it built was empty, and never read `report.unread` — the field the host has sent since #2604 to say it could not read the diagnostics log at all. A host whose log is unreadable (for example `EACCES` after a permission change) was therefore drawn as a host with a clean log, exactly when the truth was that the errors were not shown. The empty state now reads the field: when it is a string, the row names the reason and says the errors the log holds are not shown; when it is absent, the old sentence stays. Rows the report does carry are untouched, so the notice never replaces them. The new `{reason}` key also has its zh entry. ## What ran - Red first at the base `1e27f10e`, twice: 3 tests / 2 pass / 1 fail, identical apart from temp paths and durations (`the empty state claims a clean log: ...`). - After the fix: 3/3. Neighbourhood (11 files: diagnostics x3, feedback x4, i18n, error-toast-code, shell-level, state-link-hardlink): 97/97. - `build-client` and `build-client --check`: exit 0; `core/client.js` rebuilt. - `generate`: rewrites only `patches/ds-bot/0001-core-fixes.patch` (+14/-4) and `patches/ds-bot/0008-client-bundle.patch` (+8/-3), plus the new `patches/ds-bot/files/test/feedback-unread-log.test.js`; the other eight patches are byte-identical. `check` three ok lines, `canonical` a no-op. --- changelog.d/2833-feedback-unread-log.fixed.md | 1 + core/client.js | 7 +- core/src/client/feedback.js | 11 +- core/src/client/i18n.js | 1 + core/test/feedback-unread-log.test.js | 181 ++++++++++++++++++ patches/ds-bot/0001-core-fixes.patch | 18 +- patches/ds-bot/0008-client-bundle.patch | 11 +- .../files/test/feedback-unread-log.test.js | 181 ++++++++++++++++++ 8 files changed, 402 insertions(+), 9 deletions(-) create mode 100644 changelog.d/2833-feedback-unread-log.fixed.md create mode 100644 core/test/feedback-unread-log.test.js create mode 100644 patches/ds-bot/files/test/feedback-unread-log.test.js diff --git a/changelog.d/2833-feedback-unread-log.fixed.md b/changelog.d/2833-feedback-unread-log.fixed.md new file mode 100644 index 000000000..53c91e093 --- /dev/null +++ b/changelog.d/2833-feedback-unread-log.fixed.md @@ -0,0 +1 @@ +The Feedback page now says when the error log could not be read instead of claiming nothing was recorded: when the host reports the log unreadable, the empty state names the reason (for example `EACCES`) and says the errors it holds are not shown, while any errors the report does carry are drawn unchanged. See [#2833](https://github.com/ThinkFlowLab/FlyBot/issues/2833). diff --git a/core/client.js b/core/client.js index 9d7b9b452..b8a205714 100644 --- a/core/client.js +++ b/core/client.js @@ -1260,6 +1260,7 @@ var ZH = { "Recent errors": "最近的错误", "Recent errors · {count}": "最近的错误 · {count}", "No errors recorded.": "没有错误记录。", + "The error log could not be read ({reason}); the errors it holds are not shown.": "错误日志读不出来({reason});里面的错误没有显示。", "Failed model calls and FlyBot problems show here, newest first.": "模型调用失败和 FlyBot 的问题会显示在这里,最新的在前。", "Model call": "模型调用", "This window": "这个窗口", @@ -19069,6 +19070,7 @@ function FeedbackPage({ roster, actions, run, Section, Row: Row4 }) { const browserErrors = report ? clientErrors().map((entry) => ({ ...entry, source: "browser" })) : []; const entries2 = [...report?.entries ?? [], ...browserErrors].sort((a, b) => new Date(b.time) - new Date(a.time)); const total = (report?.total ?? 0) + browserErrors.length; + const unread = typeof report?.unread === "string" ? report.unread : void 0; const shown = allErrors ? entries2 : entries2.slice(0, 8); return [ (0, import_react40.createElement)( @@ -19107,7 +19109,10 @@ function FeedbackPage({ roster, actions, run, Section, Row: Row4 }) { (0, import_react40.createElement)( Section, { key: "errors", title: report ? t("Recent errors · {count}", { count: total }) : t("Recent errors") }, - shown.length === 0 ? (0, import_react40.createElement)(Row4, { label: t("No errors recorded."), hint: t("Failed model calls and FlyBot problems show here, newest first.") }) : shown.map((entry, index) => (0, import_react40.createElement)( + shown.length === 0 ? (0, import_react40.createElement)(Row4, { + label: unread === void 0 ? t("No errors recorded.") : t("The error log could not be read ({reason}); the errors it holds are not shown.", { reason: unread }), + hint: t("Failed model calls and FlyBot problems show here, newest first.") + }) : shown.map((entry, index) => (0, import_react40.createElement)( "div", { key: `${entry.time}-${index}`, className: "bt-set-row bt-fb-entry", title: entry.text }, (0, import_react40.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }), diff --git a/core/src/client/feedback.js b/core/src/client/feedback.js index c284d958e..25d7a4399 100644 --- a/core/src/client/feedback.js +++ b/core/src/client/feedback.js @@ -106,6 +106,10 @@ export function FeedbackPage({ roster, actions, run, Section, Row }) { const browserErrors = report ? clientErrors().map(entry => ({ ...entry, source: 'browser' })) : [] const entries = [...(report?.entries ?? []), ...browserErrors].sort((a, b) => new Date(b.time) - new Date(a.time)) const total = (report?.total ?? 0) + browserErrors.length + // The host's additive read failure (#2604): `entries` and `total` are 0 when the log could not + // be opened, which is not the same as a log with nothing in it. The reason is the host's own + // short word (EACCES), and it explains the empty list rather than any row in it. + const unread = typeof report?.unread === 'string' ? report.unread : undefined // The list opens with eight errors; anything past them waits behind one control, so a // burst of failures does not push the download and clear buttons off the page. const shown = allErrors ? entries : entries.slice(0, 8) @@ -127,7 +131,12 @@ export function FeedbackPage({ roster, actions, run, Section, Row }) { issueLink ? [' ', h('a', { key: 'link', href: issueLink, target: '_blank', rel: 'noopener noreferrer' }, t('Open the issue'))] : null) : null, h(Section, { key: 'errors', title: report ? t('Recent errors · {count}', { count: total }) : t('Recent errors') }, shown.length === 0 - ? h(Row, { label: t('No errors recorded.'), hint: t('Failed model calls and FlyBot problems show here, newest first.') }) + ? h(Row, { + label: unread === undefined + ? t('No errors recorded.') + : t('The error log could not be read ({reason}); the errors it holds are not shown.', { reason: unread }), + hint: t('Failed model calls and FlyBot problems show here, newest first.'), + }) : shown.map((entry, index) => h('div', { key: `${entry.time}-${index}`, className: 'bt-set-row bt-fb-entry', title: entry.text }, h('span', { className: 'bt-fb-dot', 'data-level': entry.level, 'aria-hidden': true }), h('div', { className: 'bt-set-copy' }, diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 928ec7def..30ad9c28f 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1214,6 +1214,7 @@ const ZH = { 'Recent errors': '最近的错误', 'Recent errors · {count}': '最近的错误 · {count}', 'No errors recorded.': '没有错误记录。', + 'The error log could not be read ({reason}); the errors it holds are not shown.': '错误日志读不出来({reason});里面的错误没有显示。', 'Failed model calls and FlyBot problems show here, newest first.': '模型调用失败和 FlyBot 的问题会显示在这里,最新的在前。', 'Model call': '模型调用', 'This window': '这个窗口', diff --git a/core/test/feedback-unread-log.test.js b/core/test/feedback-unread-log.test.js new file mode 100644 index 000000000..79ea28641 --- /dev/null +++ b/core/test/feedback-unread-log.test.js @@ -0,0 +1,181 @@ +// P-687: the Feedback page drew `No errors recorded.` whenever the host sent no rows, and the +// host's `unread` field (#2604) says when no rows is not the same as "nothing was recorded": the +// error log could not be opened, so the errors it holds are missing from the report. The page +// never read the field, so a refused log (EACCES) read as a clean one. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { FeedbackPage } from './src/client/feedback.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const child of node) all(child, out); return out } + if (node && typeof node === 'object') { + out.push(node) + for (const child of [node.props?.children].flat(Infinity)) all(child, out) + } + return out +} + +function hooks() { + const states = [] + const effectSlots = [] + let cursor = 0 + let dirty = false + let pending = [] + const sameDeps = (a, b) => (a?.length === b?.length && (a ?? []).every((value, index) => value === b?.[index])) + return { + react: { + createElement: h, + Fragment: Symbol('Fragment'), + Component: class {}, + useState: (initial) => { + const slot = cursor++ + if (!(slot in states)) states[slot] = typeof initial === 'function' ? initial() : initial + const set = (value) => { + const next = typeof value === 'function' ? value(states[slot]) : value + if (next !== states[slot]) { states[slot] = next; dirty = true } + } + return [states[slot], set] + }, + useEffect: (fn, deps) => { + const slot = cursor++ + if (!effectSlots[slot] || !sameDeps(effectSlots[slot].deps, deps)) pending.push({ slot, fn, deps }) + }, + useLayoutEffect: (fn, deps) => { const slot = cursor++; if (!effectSlots[slot] || !sameDeps(effectSlots[slot].deps, deps)) pending.push({ slot, fn, deps }) }, + useMemo: (make) => make(), + useCallback: (fn) => fn, + useRef: (value) => ({ current: value }), + }, + begin() { cursor = 0; pending = [] }, + end() { + for (const { slot, fn, deps } of pending) { + effectSlots[slot]?.cleanup?.() + const cleanup = fn() + effectSlots[slot] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined } + } + pending = [] + }, + get dirty() { return dirty }, + clear() { dirty = false }, + } +} + +const Section = ({ title, children }) => h('section', { className: 'bt-set-section' }, h('h3', null, title), h('div', { className: 'bt-set-card' }, children)) +const Row = ({ label, hint, children }) => h('div', { className: 'bt-set-row' }, + h('div', { className: 'bt-set-copy' }, h('span', null, label), hint ? h('span', { className: 'bt-set-hint' }, hint) : null), + children ? h('div', { className: 'bt-set-control' }, children) : null) + +const T0 = Date.UTC(2026, 10, 3, 4, 0, 0) +const errorEntries = count => Array.from({ length: count }, (_, index) => ({ + time: T0 - index * 1000, + level: 'error', + text: `failure number ${index + 1}`, + source: 'host', +})) + +async function mount({ entries = [], total = 0, unread } = {}) { + const runtime = hooks() + const report = { + entries, + total, + ...(unread === undefined ? {} : { unread }), + markdown: '# diagnostics', + issuesUrl: 'https://github.com/ThinkFlowLab/FlyBot/issues/new', + } + const actions = { + diagnostics: () => Promise.resolve(report), + diagnosticsLog: () => Promise.resolve(''), + diagnosticsClear: () => Promise.resolve(), + } + const context = { + module: { exports: {} }, exports: {}, + document: { addEventListener() {}, removeEventListener() {}, activeElement: null, querySelector: () => null }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: { addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) }, + console, setTimeout, clearTimeout, Date, JSON, Promise, Symbol, + __flybot__: { slots: { entries: () => [], subscribe: () => () => {}, version: () => 0 }, call: () => {} }, + require: (name) => { + if (name === 'react') return runtime.react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, + } + context.module.exports = context.exports + runInNewContext(outputFiles[0].text, context) + const { FeedbackPage } = context.module.exports + + const expand = (node) => { + if (Array.isArray(node)) return node.map(expand) + if (!node || typeof node !== 'object') return node + const children = [node.props?.children].flat(Infinity).map(expand) + if (typeof node.type === 'function') return expand(node.type({ ...node.props, children })) + return { type: node.type, props: { ...node.props, children } } + } + let tree = null + const render = () => { + runtime.begin() + tree = expand(FeedbackPage({ actions, run: fn => fn, Section, Row })) + runtime.end() + } + const settle = async () => { + for (let step = 0; step < 20; step += 1) { + runtime.clear() + render() + await new Promise(resolve => setTimeout(resolve, 0)) + if (!runtime.dirty) return + } + throw new Error('the page never settled') + } + + await settle() + const text = (node) => [node?.props?.children].flat(Infinity).filter(child => typeof child === 'string' || typeof child === 'number').join('') + const deepText = (node) => all(node).map(part => text(part)).filter(Boolean).join(' ') + const heading = () => all(tree).find(node => node.type === 'h3' && text(node).startsWith('Recent errors')) + const rows = () => all(tree).filter(node => node.props?.className === 'bt-set-row bt-fb-entry') + const emptyRow = () => all(tree).find(node => node.props?.className === 'bt-set-row' + && deepText(node).startsWith('No errors recorded.')) + return { + get tree() { return tree }, + get rows() { return rows() }, + emptyRow, + title: () => text(heading()), + deepText: (node = tree) => deepText(node), + } +} + +// RED today: the report says the log could not be read, and the page still says there is nothing to read. +test('P-687: an unread error log is named instead of reading as "No errors recorded."', async () => { + const page = await mount({ entries: [], total: 0, unread: 'EACCES' }) + assert.equal(page.title(), 'Recent errors · 0', 'the title keeps the true count, not a guess') + assert.doesNotMatch(page.deepText(), /No errors recorded\./, `the empty state claims a clean log: ${page.deepText()}`) + assert.match(page.deepText(), /EACCES/, 'the reason the log could not be read is named') + assert.match(page.deepText(), /not shown|missing/i, 'the sentence says the errors it holds are not in the list') +}) + +// GREEN today, must stay: no `unread` field, no change — an empty log still reads as an empty log. +test('P-687 guard: a readable empty log keeps its sentence', async () => { + const page = await mount({ entries: [], total: 0 }) + assert.equal(page.title(), 'Recent errors · 0') + assert.equal(page.deepText(page.emptyRow()), 'No errors recorded. Failed model calls and FlyBot problems show here, newest first.') +}) + +// GREEN today, must stay: rows the report does carry are drawn as before; `unread` changes only the +// empty state, so a report with browser errors and an unread host log does not hide its rows. +test('P-687 guard: an unread log does not replace rows that are there', async () => { + const page = await mount({ entries: errorEntries(3), total: 3, unread: 'EACCES' }) + assert.equal(page.rows.length, 3) + assert.doesNotMatch(page.deepText(), /EACCES/, 'the notice belongs to the empty state only') +}) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index a1b38740a..a5c625b59 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -577,7 +577,7 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js const [preview, setPreview] = useState(false) const [note, setNote] = useState('') // Each action reads the report again, so it holds errors from the last few seconds. -@@ -82,12 +82,20 @@ export function FeedbackPage({ actions, run, Section, Row }) { +@@ -82,12 +82,24 @@ export function FeedbackPage({ actions, run, Section, Row }) { download('ds-bot-diagnostics.log', text || 'No entries.\n') setNote(t('Downloaded the log. Keys, tokens, and your home folder are hidden in it.')) })) @@ -594,13 +594,17 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js const entries = [...(report?.entries ?? []), ...browserErrors].sort((a, b) => new Date(b.time) - new Date(a.time)) const total = (report?.total ?? 0) + browserErrors.length - const shown = entries.slice(0, 8) ++ // The host's additive read failure (#2604): `entries` and `total` are 0 when the log could not ++ // be opened, which is not the same as a log with nothing in it. The reason is the host's own ++ // short word (EACCES), and it explains the empty list rather than any row in it. ++ const unread = typeof report?.unread === 'string' ? report.unread : undefined + // The list opens with eight errors; anything past them waits behind one control, so a + // burst of failures does not push the download and clear buttons off the page. + const shown = allErrors ? entries : entries.slice(0, 8) return [ h(Section, { key: 'report', title: t('Report a problem') }, h('div', { className: 'bt-set-row bt-set-paste bt-fb-what' }, -@@ -99,23 +99,30 @@ export function FeedbackPage({ actions, run, Section, Row }) { +@@ -99,23 +99,35 @@ export function FeedbackPage({ actions, run, Section, Row }) { h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => setPreview(!preview), 'aria-expanded': preview }, preview ? t('Hide') : t('Show')), h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: copyReport }, t('Copy'))), preview ? h('div', { className: 'bt-set-row bt-fb-preview' }, h('pre', { tabIndex: 0, 'aria-label': t('Diagnostics') }, report?.markdown || t('Loading…'))) : null, @@ -613,7 +617,12 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js h(Section, { key: 'errors', title: report ? t('Recent errors · {count}', { count: total }) : t('Recent errors') }, shown.length === 0 - ? h(Row, { label: t('No errors recorded.'), hint: t('Failed model calls and DS Bot problems show here, newest first.') }) -+ ? h(Row, { label: t('No errors recorded.'), hint: t('Failed model calls and FlyBot problems show here, newest first.') }) ++ ? h(Row, { ++ label: unread === undefined ++ ? t('No errors recorded.') ++ : t('The error log could not be read ({reason}); the errors it holds are not shown.', { reason: unread }), ++ hint: t('Failed model calls and FlyBot problems show here, newest first.'), ++ }) : shown.map((entry, index) => h('div', { key: `${entry.time}-${index}`, className: 'bt-set-row bt-fb-entry', title: entry.text }, h('span', { className: 'bt-fb-dot', 'data-level': entry.level, 'aria-hidden': true }), h('div', { className: 'bt-set-copy' }, @@ -1487,7 +1496,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Report on GitHub': '在 GitHub 上报告', 'Copied the diagnostics.': '已复制诊断信息。', 'Could not copy. Select the text below and copy it.': '无法复制。请选中下面的文字手动复制。', -@@ -735,10 +735,12 @@ const ZH = { +@@ -735,10 +735,13 @@ const ZH = { 'Opened a new GitHub issue. Copy the diagnostics below into it.': '已打开新的 GitHub issue。请把下面的诊断信息复制进去。', 'Downloaded the log. Keys, tokens, and your home folder are hidden in it.': '已下载日志。里面的密钥、令牌和用户目录都已隐藏。', 'Cleared the error log.': '已清空错误日志。', @@ -1497,6 +1506,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Recent errors · {count}': '最近的错误 · {count}', 'No errors recorded.': '没有错误记录。', - 'Failed model calls and DS Bot problems show here, newest first.': '模型调用失败和 DS Bot 的问题会显示在这里,最新的在前。', ++ 'The error log could not be read ({reason}); the errors it holds are not shown.': '错误日志读不出来({reason});里面的错误没有显示。', + 'Failed model calls and FlyBot problems show here, newest first.': '模型调用失败和 FlyBot 的问题会显示在这里,最新的在前。', 'Model call': '模型调用', 'This window': '这个窗口', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index b1aceda66..15d2f5acc 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -804,7 +804,7 @@ diff --git a/client.js b/client.js "Report on GitHub": "在 GitHub 上报告", "Copied the diagnostics.": "已复制诊断信息。", "Could not copy. Select the text below and copy it.": "无法复制。请选中下面的文字手动复制。", -@@ -785,10 +785,12 @@ var ZH = { +@@ -785,10 +785,13 @@ var ZH = { "Opened a new GitHub issue. Copy the diagnostics below into it.": "已打开新的 GitHub issue。请把下面的诊断信息复制进去。", "Downloaded the log. Keys, tokens, and your home folder are hidden in it.": "已下载日志。里面的密钥、令牌和用户目录都已隐藏。", "Cleared the error log.": "已清空错误日志。", @@ -814,6 +814,7 @@ diff --git a/client.js b/client.js "Recent errors · {count}": "最近的错误 · {count}", "No errors recorded.": "没有错误记录。", - "Failed model calls and DS Bot problems show here, newest first.": "模型调用失败和 DS Bot 的问题会显示在这里,最新的在前。", ++ "The error log could not be read ({reason}); the errors it holds are not shown.": "错误日志读不出来({reason});里面的错误没有显示。", + "Failed model calls and FlyBot problems show here, newest first.": "模型调用失败和 FlyBot 的问题会显示在这里,最新的在前。", "Model call": "模型调用", "This window": "这个窗口", @@ -23085,7 +23086,7 @@ diff --git a/client.js b/client.js const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; -@@ -10307,69 +10307,83 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10307,69 +10307,87 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { if (!inline && !copied) setPreview(true); })); }; @@ -23105,6 +23106,7 @@ diff --git a/client.js b/client.js const entries2 = [...report?.entries ?? [], ...browserErrors].sort((a, b) => new Date(b.time) - new Date(a.time)); const total = (report?.total ?? 0) + browserErrors.length; - const shown = entries2.slice(0, 8); ++ const unread = typeof report?.unread === "string" ? report.unread : void 0; + const shown = allErrors ? entries2 : entries2.slice(0, 8); return [ - (0, import_react26.createElement)( @@ -23162,7 +23164,10 @@ diff --git a/client.js b/client.js Section, { key: "errors", title: report ? t("Recent errors · {count}", { count: total }) : t("Recent errors") }, - shown.length === 0 ? (0, import_react26.createElement)(Row3, { label: t("No errors recorded."), hint: t("Failed model calls and DS Bot problems show here, newest first.") }) : shown.map((entry, index) => (0, import_react26.createElement)( -+ shown.length === 0 ? (0, import_react40.createElement)(Row4, { label: t("No errors recorded."), hint: t("Failed model calls and FlyBot problems show here, newest first.") }) : shown.map((entry, index) => (0, import_react40.createElement)( ++ shown.length === 0 ? (0, import_react40.createElement)(Row4, { ++ label: unread === void 0 ? t("No errors recorded.") : t("The error log could not be read ({reason}); the errors it holds are not shown.", { reason: unread }), ++ hint: t("Failed model calls and FlyBot problems show here, newest first.") ++ }) : shown.map((entry, index) => (0, import_react40.createElement)( "div", { key: `${entry.time}-${index}`, className: "bt-set-row bt-fb-entry", title: entry.text }, - (0, import_react26.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }), diff --git a/patches/ds-bot/files/test/feedback-unread-log.test.js b/patches/ds-bot/files/test/feedback-unread-log.test.js new file mode 100644 index 000000000..79ea28641 --- /dev/null +++ b/patches/ds-bot/files/test/feedback-unread-log.test.js @@ -0,0 +1,181 @@ +// P-687: the Feedback page drew `No errors recorded.` whenever the host sent no rows, and the +// host's `unread` field (#2604) says when no rows is not the same as "nothing was recorded": the +// error log could not be opened, so the errors it holds are missing from the report. The page +// never read the field, so a refused log (EACCES) read as a clean one. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { FeedbackPage } from './src/client/feedback.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const child of node) all(child, out); return out } + if (node && typeof node === 'object') { + out.push(node) + for (const child of [node.props?.children].flat(Infinity)) all(child, out) + } + return out +} + +function hooks() { + const states = [] + const effectSlots = [] + let cursor = 0 + let dirty = false + let pending = [] + const sameDeps = (a, b) => (a?.length === b?.length && (a ?? []).every((value, index) => value === b?.[index])) + return { + react: { + createElement: h, + Fragment: Symbol('Fragment'), + Component: class {}, + useState: (initial) => { + const slot = cursor++ + if (!(slot in states)) states[slot] = typeof initial === 'function' ? initial() : initial + const set = (value) => { + const next = typeof value === 'function' ? value(states[slot]) : value + if (next !== states[slot]) { states[slot] = next; dirty = true } + } + return [states[slot], set] + }, + useEffect: (fn, deps) => { + const slot = cursor++ + if (!effectSlots[slot] || !sameDeps(effectSlots[slot].deps, deps)) pending.push({ slot, fn, deps }) + }, + useLayoutEffect: (fn, deps) => { const slot = cursor++; if (!effectSlots[slot] || !sameDeps(effectSlots[slot].deps, deps)) pending.push({ slot, fn, deps }) }, + useMemo: (make) => make(), + useCallback: (fn) => fn, + useRef: (value) => ({ current: value }), + }, + begin() { cursor = 0; pending = [] }, + end() { + for (const { slot, fn, deps } of pending) { + effectSlots[slot]?.cleanup?.() + const cleanup = fn() + effectSlots[slot] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined } + } + pending = [] + }, + get dirty() { return dirty }, + clear() { dirty = false }, + } +} + +const Section = ({ title, children }) => h('section', { className: 'bt-set-section' }, h('h3', null, title), h('div', { className: 'bt-set-card' }, children)) +const Row = ({ label, hint, children }) => h('div', { className: 'bt-set-row' }, + h('div', { className: 'bt-set-copy' }, h('span', null, label), hint ? h('span', { className: 'bt-set-hint' }, hint) : null), + children ? h('div', { className: 'bt-set-control' }, children) : null) + +const T0 = Date.UTC(2026, 10, 3, 4, 0, 0) +const errorEntries = count => Array.from({ length: count }, (_, index) => ({ + time: T0 - index * 1000, + level: 'error', + text: `failure number ${index + 1}`, + source: 'host', +})) + +async function mount({ entries = [], total = 0, unread } = {}) { + const runtime = hooks() + const report = { + entries, + total, + ...(unread === undefined ? {} : { unread }), + markdown: '# diagnostics', + issuesUrl: 'https://github.com/ThinkFlowLab/FlyBot/issues/new', + } + const actions = { + diagnostics: () => Promise.resolve(report), + diagnosticsLog: () => Promise.resolve(''), + diagnosticsClear: () => Promise.resolve(), + } + const context = { + module: { exports: {} }, exports: {}, + document: { addEventListener() {}, removeEventListener() {}, activeElement: null, querySelector: () => null }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: { addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) }, + console, setTimeout, clearTimeout, Date, JSON, Promise, Symbol, + __flybot__: { slots: { entries: () => [], subscribe: () => () => {}, version: () => 0 }, call: () => {} }, + require: (name) => { + if (name === 'react') return runtime.react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, + } + context.module.exports = context.exports + runInNewContext(outputFiles[0].text, context) + const { FeedbackPage } = context.module.exports + + const expand = (node) => { + if (Array.isArray(node)) return node.map(expand) + if (!node || typeof node !== 'object') return node + const children = [node.props?.children].flat(Infinity).map(expand) + if (typeof node.type === 'function') return expand(node.type({ ...node.props, children })) + return { type: node.type, props: { ...node.props, children } } + } + let tree = null + const render = () => { + runtime.begin() + tree = expand(FeedbackPage({ actions, run: fn => fn, Section, Row })) + runtime.end() + } + const settle = async () => { + for (let step = 0; step < 20; step += 1) { + runtime.clear() + render() + await new Promise(resolve => setTimeout(resolve, 0)) + if (!runtime.dirty) return + } + throw new Error('the page never settled') + } + + await settle() + const text = (node) => [node?.props?.children].flat(Infinity).filter(child => typeof child === 'string' || typeof child === 'number').join('') + const deepText = (node) => all(node).map(part => text(part)).filter(Boolean).join(' ') + const heading = () => all(tree).find(node => node.type === 'h3' && text(node).startsWith('Recent errors')) + const rows = () => all(tree).filter(node => node.props?.className === 'bt-set-row bt-fb-entry') + const emptyRow = () => all(tree).find(node => node.props?.className === 'bt-set-row' + && deepText(node).startsWith('No errors recorded.')) + return { + get tree() { return tree }, + get rows() { return rows() }, + emptyRow, + title: () => text(heading()), + deepText: (node = tree) => deepText(node), + } +} + +// RED today: the report says the log could not be read, and the page still says there is nothing to read. +test('P-687: an unread error log is named instead of reading as "No errors recorded."', async () => { + const page = await mount({ entries: [], total: 0, unread: 'EACCES' }) + assert.equal(page.title(), 'Recent errors · 0', 'the title keeps the true count, not a guess') + assert.doesNotMatch(page.deepText(), /No errors recorded\./, `the empty state claims a clean log: ${page.deepText()}`) + assert.match(page.deepText(), /EACCES/, 'the reason the log could not be read is named') + assert.match(page.deepText(), /not shown|missing/i, 'the sentence says the errors it holds are not in the list') +}) + +// GREEN today, must stay: no `unread` field, no change — an empty log still reads as an empty log. +test('P-687 guard: a readable empty log keeps its sentence', async () => { + const page = await mount({ entries: [], total: 0 }) + assert.equal(page.title(), 'Recent errors · 0') + assert.equal(page.deepText(page.emptyRow()), 'No errors recorded. Failed model calls and FlyBot problems show here, newest first.') +}) + +// GREEN today, must stay: rows the report does carry are drawn as before; `unread` changes only the +// empty state, so a report with browser errors and an unread host log does not hide its rows. +test('P-687 guard: an unread log does not replace rows that are there', async () => { + const page = await mount({ entries: errorEntries(3), total: 3, unread: 'EACCES' }) + assert.equal(page.rows.length, 3) + assert.doesNotMatch(page.deepText(), /EACCES/, 'the notice belongs to the empty state only') +})