From ca5ac5bb45c9ae666d268928fd5d703eaf257e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 10 Oct 2026 14:03:53 -0400 Subject: [PATCH] ElyMark: a failed editor is told, leaves nothing, and can rebuild (M-13) A failed build removes its holder. When both editors fail, the page tells the app and keeps the text; the next mode request rebuilds it. mark-broken.mjs breaks the editors through setAttribute and checks the fallback, the told failures, no leftover holders and the rebuild. Co-Authored-By: Claude Opus 5.5 --- agents.md | 1 + apps/ely-mark/web/src/main.js | 26 ++++++++++-- tasks/BUGS.md | 2 +- tasks/reviews/M-13-r1.md | 7 +++ tasks/reviews/M-13-r2.md | 1 + tests/scripts/mark-broken.mjs | 80 +++++++++++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 tasks/reviews/M-13-r1.md create mode 100644 tasks/reviews/M-13-r2.md create mode 100644 tests/scripts/mark-broken.mjs diff --git a/agents.md b/agents.md index 899173d..53d5843 100644 --- a/agents.md +++ b/agents.md @@ -158,6 +158,7 @@ tests/scripts/mark-places.sh 真窗口 ElyMark:文件夹树、大纲跳转 tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久、四种导出、打印、Git 状态与打开 ElyTree、大文件(需辅助功能权限与 pdftotext;ELY_CLIPBOARD_TEST=1 再测富文本复制) tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限) tests/scripts/mark-links.mjs ElyMark 页面(Playwright,三端):可视模式单击编辑,Cmd(他处 Ctrl)单击交应用,预览单击交应用,地址取文档原文(需 site 的依赖) +tests/scripts/mark-broken.mjs ElyMark 页面(Playwright,三端):可视编辑器建不成即退回源码并告知,两者都建不成即报失败(需 site 的依赖) tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴) tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错 tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖 diff --git a/apps/ely-mark/web/src/main.js b/apps/ely-mark/web/src/main.js index 4e54f22..b9e4c0e 100644 --- a/apps/ely-mark/web/src/main.js +++ b/apps/ely-mark/web/src/main.js @@ -17,6 +17,8 @@ let pending = null let current = null // A mode asked for while an editor builds. let asked = null +// The text no editor could show; a mode retries it. +let broken = null // A place to show once the editor is built. let revealing = null // The search in force, kept across modes. @@ -67,11 +69,21 @@ function changed(text) { revised(text) } -/** An editor for `mode`; visual falls back to source. */ +/** An editor for `mode`; a failed one leaves no holder. */ async function build(text, mode) { const holder = document.createElement('div') holder.hidden = true document.getElementById('document').append(holder) + try { + return await fill(holder, text, mode) + } catch (error) { + holder.remove() + throw error + } +} + +/** Builds in `holder`; visual falls back to source. */ +async function fill(holder, text, mode) { const entry = { mode, holder, fallback: false } if (mode !== 'source') { const [front, body] = split(text) @@ -126,15 +138,20 @@ function drop(entry) { async function show(text, want) { const turn = ++latest asked = null + broken = null if (current) drop(current) current = null let entry try { entry = await build(text, want) } catch (error) { - // Source failed too: tell the app, never blank. - if (turn === latest) tell({ kind: 'failed', generation, what: error.name }) - return + if (turn !== latest) return + // Source failed too: tell the app, keep the text. + broken = text + tell({ kind: 'failed', generation, what: error.name }) + const next = asked + asked = null + return next && mode(next) } if (turn !== latest) return drop(entry) current = entry @@ -233,6 +250,7 @@ window.addEventListener('scroll', placed, { passive: true }) /** Another mode for the same text; edits are sent first. */ function mode(next) { if (!current) { + if (broken !== null) return show(broken, next) asked = next return } diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 6511ebc..df0648e 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -73,7 +73,7 @@ - [x] M-10 关闭查找框只清当前标签的高亮 — 关闭查找框对每个标签的页面清除高亮(审查 1 轮) - [x] M-11 页面把搜索词写进应用日志 — 控制台的文字与来源都可由文档伪造,只记行号;页面自己的诊断经 elyQuery;R1 前缀可伪造、R2 data 来源、R3 sourceURL、R4 编码路径与目录、R5 缺回归测试:已补并经变异核对(五轮上限,未再审) - [x] M-12 浏览器创建完成前 `WebView` 已释放,浏览器泄漏 — 浏览器在泵里同步建好即登记,视图要么在建前已走、要么有浏览器可关;webprobe 加建前丢弃与 0–27 毫秒丢弃,35 页都在保持前释放(审查 4 轮) -- [ ] M-13 编辑器构建失败时页面空白且不上报 +- [x] M-13 编辑器构建失败时页面空白且不上报 — 可视建不成退回源码并告知,两者都建不成即报失败、留下文字、模式请求重建、不留容器;新测 mark-broken.mjs(审查 2 轮) - [ ] M-14 `render_pdf` 在界面线程内嵌图片并写文件 - [ ] M-15 打印与导出 PDF 的暂存页执行文档里的脚本 - [ ] G-01 打开“文件历史”或“逐行追溯”后,中间区不再响应任何别的选择 diff --git a/tasks/reviews/M-13-r1.md b/tasks/reviews/M-13-r1.md new file mode 100644 index 0000000..9ebcdc6 --- /dev/null +++ b/tasks/reviews/M-13-r1.md @@ -0,0 +1,7 @@ +FAIL + +- apps/ely-mark/web/src/main.js:137:两种编辑器构建失败后,`current` 保持 `null`,且没有构建任务运行;`mode()` 在第 235–237 行仍把它当作正在构建,只记录 `asked`。Windows 与 WSL 均复现:按新测试的方式让两种编辑器失败,恢复 `setAttribute` 后请求源码模式,没有新的 `shown`,可见编辑器仍为零,模式切换无法恢复文档(MARK-02、UX-06)。区分构建中与构建失败,保留文本并允许源码模式重新构建;补充失败后恢复的回归断言。证据:`.work/m13-r1-review/windows-probe.log`、`linux-probe.log`。 + +- apps/ely-mark/web/src/main.js:134:构建失败后直接返回,未移除 `build()` 已挂入页面的隐藏容器,也未释放部分构建产生的监听器。沿用新测试的故障注入,初次打开失败后再重载四次,两端都留下五个隐藏容器和十个 `document.selectionchange` 监听器;恢复构建并成功打开源码后变为六个容器、十一个监听器,旧资源仍保留。失败重载会持续累积编辑器资源。构建失败时清理容器及已分配的编辑器资源,并在回归测试中检查连续失败、成功重开后的资源数量。证据同上述两份探针日志。 + +- tests/scripts/mark-broken.mjs:1:本行与第 35 行的注释各有 11 个词,违反 agents.md 第 5 条的十词上限;逐行按空白分词即可复现,记录见 `.work/m13-r1-review/comment-count.log`。将这两条注释缩短至十词以内。 diff --git a/tasks/reviews/M-13-r2.md b/tasks/reviews/M-13-r2.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/M-13-r2.md @@ -0,0 +1 @@ +PASS diff --git a/tests/scripts/mark-broken.mjs b/tests/scripts/mark-broken.mjs new file mode 100644 index 0000000..9e5374c --- /dev/null +++ b/tests/scripts/mark-broken.mjs @@ -0,0 +1,80 @@ +// ElyMark's page: a failed editor says so (M-13). +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '../..') +const page = join(root, 'engines/content/ely-mark/index.html') +if (!existsSync(page)) { + console.log('SKIP: no page; run scripts/build-mark-content.sh') + process.exit(0) +} +let chromium +try { + ;({ chromium } = createRequire(join(root, 'site/package.json'))('@playwright/test')) +} catch { + console.log('SKIP: no Playwright; run pnpm install in site') + process.exit(0) +} +const fail = (why) => { + console.error(`FAIL: ${why}`) + process.exit(1) +} + +const browser = await chromium.launch() +const tab = await browser.newPage() +await tab.addInitScript(() => { + window.asked = [] + window.elyQuery = ({ request, onSuccess }) => { + window.asked.push(JSON.parse(request)) + onSuccess('') + } + // Setting a role throws; `spare` keeps CodeMirror's. + const set = Element.prototype.setAttribute + window.breaks = (spare) => { + Element.prototype.setAttribute = function (name, value) { + const kept = spare && this.classList.contains('cm-content') + if (name === 'role' && !kept) throw new Error('broken') + return set.call(this, name, value) + } + } + window.mends = () => { + Element.prototype.setAttribute = set + } +}) +await tab.goto(pathToFileURL(page).href) +// What the app heard since the last look. +let seen = 0 +const heard = async () => { + await tab.waitForTimeout(800) + const all = await tab.evaluate(() => window.asked.filter((a) => a.kind === 'shown' || a.kind === 'failed')) + const fresh = all.slice(seen) + seen = all.length + return JSON.stringify(fresh) +} +const holders = () => tab.evaluate(() => document.querySelectorAll('#document > div').length) +const shown = () => tab.evaluate(() => document.querySelector('#document .cm-content')?.textContent ?? '') +const open = (mode) => tab.evaluate((mode) => window.ely.open('# Kept\n', mode, {}, 1, '', null), mode) + +await tab.evaluate(() => window.breaks(true)) +await open('visual') +const fell = JSON.stringify([{ kind: 'shown', mode: 'source', fallback: true, broke: 'Error' }]) +if ((await heard()) !== fell) fail('a broken visual editor did not fall back to source') +if ((await shown()) !== '# Kept') fail(`the fallback shows ${JSON.stringify(await shown())}`) + +await tab.evaluate(() => window.breaks(false)) +const failed = JSON.stringify([{ kind: 'failed', generation: 1, what: 'Error' }]) +for (let n = 0; n < 3; n++) { + await open('visual') + if ((await heard()) !== failed) fail(`two broken editors, try ${n}: the app was not told`) +} +if ((await holders()) !== 0) fail(`failed builds left ${await holders()} holders`) + +await tab.evaluate(() => window.mends()) +await tab.evaluate(() => window.ely.mode('source')) +const back = JSON.stringify([{ kind: 'shown', mode: 'source', fallback: false, broke: null }]) +if ((await heard()) !== back) fail('a mode after the failure did not rebuild') +if ((await shown()) !== '# Kept' || (await holders()) !== 1) fail('the rebuilt editor lost the text') +await browser.close() +console.log('mark-broken: fallback, told failures, no leftover holders, a mode rebuilds')