diff --git a/bugs/0524.md b/bugs/0524.md index 43c1e08d..90085235 100644 --- a/bugs/0524.md +++ b/bugs/0524.md @@ -68,4 +68,26 @@ - 系统性后续: - 当前修复覆盖“选中文本删除本地附件链接”的高频路径。块菜单删除、拖拽重排、折叠标题转换等仍有 `set_content`/HTML 整文替换路径,后续应拆一个 editor command history 设计,把块级删除/重排统一迁移到 Tiptap transaction/command,而不是继续扩散整文替换。 5,md文件上传后刷新前面的黑色方块会变成灰色:/mnt/Data1T/mnote/tmp/image copy 80.png;/mnt/Data1T/mnote/tmp/image copy 81.png + + 处理记录(2026-05-26): + - 归类:`05-editor-mainline`,本质是本地 Markdown 上传附件插入后,Tiptap DOM、document session 保存、刷新后附件增强三者之间的时序合同不完整。 + - CodeGraph 定位:拆分后可直接从 `local-upload-runtime.js::insertUploadedAssetIntoEditor`、`sidebar-attachment-open-runtime.js::enhanceEditorAttachmentLink`、`document-editor-adapter-runtime.js::handleSessionChange`、`document-session-runtime.js::persistSession` 定位,不再需要在 `layout.rs` 大型 raw string 中翻找。 + - 根因: + - 上传插入成功后,链接只进入当前 Tiptap DOM,没有稳定触发 local-folder document save,刷新后会从旧 Markdown 重建。 + - 刷新后 `/api/local-folder/files/open` 本地链接增强存在首屏时序竞态,普通 local open 链接可能暂时只有灰色通用文件 badge。 + - 修复: + - `rust/crates/mnote-web/browser/local-upload-runtime.js`:上传插入后派发 Tiptap bridge change,并对 local-folder 上传直接按现有 conversion runtime 转为 `editorBlocks` 调 `/api/documents/save`;保存前登记 self-change suppression,避免 watcher 把自写入误报为外部冲突。 + - `rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js`:本地 open 链接也先补 `data-mnote-attachment-link`、`data-asset-id` 和类型 class,再做存在性刷新;并增加初始附件增强重扫。 + - `rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js`:刷新时从本地 open URL 的 `path` 重新推导附件类型,在 Markdown → Tiptap link mark 边界直接补 `mnote-uploaded-attachment-code` 等类型 class;CSS 只消费 class,不再用 `.md` href 颜色选择器兜底。 + - `scripts/task491-local-md-attachment-icon-refresh-smoke.js`:新增窄浏览器回归,覆盖上传 md 附件、写入 Markdown、刷新后仍保持黑色 md 附件图标。 + - `scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`:第 1 项补充刷新前后 md 附件图标断言,必须恢复 `mnote-uploaded-attachment-code` class,不再接受单纯 `::before` 黑色背景。 + - 验证: + - `node scripts/task491-local-md-attachment-icon-refresh-smoke.js` 通过。 + - `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` 中 `editor-upload-to-page-resource-dir` 通过;该宽脚本仍有既有 `broken-link-after-real-file-delete` 和 `editor-delete-one-attachment-preserves-adjacent` 失败,非本条阻塞。 6. 当前office文件本地可以打开,我们当前使用sakura frp映射3000端口至:https://www.aichem.dpdns.org 后,发现pdf可以正常打开,但onlyoffice打不开了,报错为:ONLYOFFICE 加载失败{"target":{"frameOrigin":"https://www.aichem.dpdns.org"},"data":{"errorCode":-4,"errorDescription":"下载失败"}}。请检查原因,同时要兼容未来更换域名。 + + 处理记录(2026-05-26): + - 归类:`05-editor-mainline` / OnlyOffice transport,问题是 DocumentServer 下载地址随浏览器公网域名变化。 + - 根因:OnlyOffice 页面在非 localhost 访问时把 `proxyOrigin` 设为 `location.origin`,导致 DocumentServer 去拉 `https://www.aichem.dpdns.org/api/onlyoffice/proxy?...`;公网映射域名对 DocumentServer 不一定可达,触发 `errorCode=-4 下载失败`。 + - 修复:`rust/crates/mnote-web/src/routes/onlyoffice.rs` 中 `proxyOrigin` 和 `callbackOrigin` 固定使用 `documentUrlBase`,即 `ONLYOFFICE_DOCUMENT_URL_BASE` / 内部可达 base,不再依赖浏览器当前域名;未来换 FRP 域名不影响 DocumentServer 回源。 + - 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_page_exposes_documentserver_fetch_base_for_local_files -- --test-threads=1` 通过。 diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index ec069da1..142e5f9a 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -122,6 +122,16 @@ import { } }; + const enhanceEditorAttachmentLinksSoon = () => { + const run = () => { + if (typeof window.__mnoteEnhanceEditorAttachmentLinks === 'function') { + window.__mnoteEnhanceEditorAttachmentLinks(); + } + }; + run(); + [120, 500, 1200].forEach((delayMs) => window.setTimeout(run, delayMs)); + }; + const pageAggregateUrl = (bootstrap) => { const url = new URL(`/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}`, window.location.origin); url.searchParams.set('sourceKind', bootstrap.sourceKind || 'local_folder'); @@ -357,6 +367,7 @@ import { runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); setStatus(runtimeDescriptor, 'mounting-editor'); + enhanceEditorAttachmentLinksSoon(); paneViewRegistry.set(paneRole, view); return view; } catch (error) { @@ -572,6 +583,7 @@ import { } else { setStatus(runtimeDescriptor, 'ready'); } + enhanceEditorAttachmentLinksSoon(); }; view.onError = (event) => { const payload = normalizeEnvelopePayload(event); @@ -580,12 +592,15 @@ import { view.onChange = (event) => { scheduleSlashMenuPosition(runtimeDescriptor.root); handleSessionChange(session, view, event); + enhanceEditorAttachmentLinksSoon(); }; view.onSave = (event) => { handleSessionChange(session, view, event); + enhanceEditorAttachmentLinksSoon(); }; view.onState = () => { scheduleSlashMenuPosition(runtimeDescriptor.root); + enhanceEditorAttachmentLinksSoon(); }; view.onKeydown = (event) => { if (event.key === '/') scheduleSlashMenuPosition(runtimeDescriptor.root); @@ -664,6 +679,7 @@ import { runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); setStatus(runtimeDescriptor, 'mounting-editor'); + enhanceEditorAttachmentLinksSoon(); paneViewRegistry.set(runtimeDescriptor.paneRole, view); } catch (error) { unmountEditorViewBinding(view); diff --git a/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js b/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js index 5aa397f1..7dc20f9d 100644 --- a/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js +++ b/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js @@ -321,6 +321,51 @@ export const localFileOpenUrlForTiptap = (value, context) => { return url.toString(); }; +export const localFileOpenPathFromTiptapHref = (href) => { + try { + const url = new URL(String(href || ''), window.location.origin); + if (url.pathname !== '/api/local-folder/files/open') return ''; + return String(url.searchParams.get('path') || '').trim(); + } catch (_) { + return ''; + } +}; + +export const fileNameFromPath = (path) => { + const value = String(path || '').trim(); + return value.includes('/') ? value.split('/').pop() : value; +}; + +export const attachmentClassForFileName = (fileName) => { + const name = String(fileName || '').trim().toLowerCase(); + const ext = name.includes('.') ? name.split('.').pop() : ''; + if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-word'; + if (['ppt', 'pptx', 'odp'].includes(ext)) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-ppt'; + if (['xls', 'xlsx', 'ods', 'csv'].includes(ext)) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-sheet'; + if (ext === 'pdf') return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-pdf'; + if (['md', 'markdown', 'txt', 'json', 'js', 'ts', 'tsx', 'jsx', 'rs', 'py', 'go', 'java', 'kt', 'swift', 'c', 'cpp', 'h', 'hpp', 'css', 'scss', 'html', 'xml', 'yaml', 'yml', 'toml', 'ini', 'sh', 'bash', 'zsh', 'sql', 'vue', 'svelte'].includes(ext)) { + return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-code'; + } + return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-file'; +}; + +export const mergeClassNames = (...values) => { + const seen = new Set(); + return values + .flatMap((value) => String(value || '').split(/\s+/)) + .filter((name) => { + if (!name || seen.has(name)) return false; + seen.add(name); + return true; + }) + .join(' '); +}; + +export const localAttachmentClassForTiptapHref = (href) => { + const localFilePath = localFileOpenPathFromTiptapHref(href); + return localFilePath ? attachmentClassForFileName(fileNameFromPath(localFilePath)) : ''; +}; + export const localizeTiptapAssetUrls = (node, context) => { if (!node || typeof node !== 'object') return node; if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') { @@ -329,7 +374,18 @@ export const localizeTiptapAssetUrls = (node, context) => { if (Array.isArray(node.marks)) { node.marks = node.marks.map((mark) => { if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark; - return { ...mark, attrs: { ...mark.attrs, href: localFileOpenUrlForTiptap(mark.attrs.href, context) } }; + const href = localFileOpenUrlForTiptap(mark.attrs.href, context); + const attachmentClass = localAttachmentClassForTiptapHref(href); + const attrs = attachmentClass + ? { + ...mark.attrs, + href, + class: mergeClassNames(mark.attrs.class, attachmentClass), + target: mark.attrs.target || '_blank', + rel: mark.attrs.rel || 'noopener noreferrer nofollow', + } + : { ...mark.attrs, href }; + return { ...mark, attrs }; }); } if (Array.isArray(node.content)) { diff --git a/rust/crates/mnote-web/browser/local-upload-runtime.js b/rust/crates/mnote-web/browser/local-upload-runtime.js index 1df63d33..9fa99aa3 100644 --- a/rust/crates/mnote-web/browser/local-upload-runtime.js +++ b/rust/crates/mnote-web/browser/local-upload-runtime.js @@ -2,6 +2,10 @@ // 当前先承接 SIDEBAR_TREE_JS 中的纯辅助函数。 // SIDEBAR_TREE_JS 会优先委托到 window.__mnoteLocalUploadRuntime; // 模块未加载时,inline fallback 保持旧行为。 +import { + editorDocumentFromTiptapDocument, + legacyBlocksFromEditorDocument, +} from './document-tiptap-conversion-runtime.js'; function uploadedAssetTitle(asset) { return String(asset && (asset.file_name || asset.title || asset.name) || '未命名附件').trim() || '未命名附件'; @@ -11,6 +15,117 @@ function uploadedAssetUrl(asset) { return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim(); } +function dispatchUploadedEditorChange(editorRoot, editor, deps) { + deps = deps || {}; + if (!(editorRoot instanceof HTMLElement) || !editor || typeof editor.getJSON !== 'function') return; + var runtimeRoot = editorRoot.closest('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + if (!(runtimeRoot instanceof HTMLElement) || typeof CustomEvent !== 'function') return; + try { + editorRoot.dispatchEvent(new Event('input', { bubbles: true })); + } catch (_) { + // ignore + } + var currentDocumentId = typeof deps.currentDocumentId === 'function' ? deps.currentDocumentId() : ''; + var payload = { + protocol: 'mnote.leptos_tiptap.bridge.v1', + runtime: '8123-leptos-tiptap-runtime', + version: '1.1.0', + source: 'mnote:leptos-tiptap-spike', + event: 'mnote:leptos-tiptap-spike:change', + payload: { + documentId: String(currentDocumentId || '').trim() || null, + workspaceId: null, + title: '', + content: editor.getJSON(), + meta: { + dirtyCount: Date.now(), + editorFocused: document.activeElement === editorRoot || editorRoot.contains(document.activeElement), + slashOpen: false, + toolbarOpen: false, + selectedBlockIndex: null, + revision: null, + conflictDetectionKey: null, + readOnly: false + } + } + }; + runtimeRoot.dispatchEvent(new CustomEvent('mnote:leptos-tiptap-spike:change', { + detail: payload, + bubbles: true + })); +} + +function currentConflictDetectionKey() { + var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__'); + if (!script) return ''; + try { + var body = JSON.parse(script.textContent || 'null'); + return String(body && (body.conflictDetectionKey || body.conflict_detection_key || body.fileVersion || body.file_version) || '').trim(); + } catch (_) { + return ''; + } +} + +function persistLocalFolderSelfChangeSuppression(documentId, expiresAt) { + var doc = String(documentId || '').trim(); + if (!doc) return; + window.__mnoteLocalFolderSelfChangeSuppressions = window.__mnoteLocalFolderSelfChangeSuppressions || new Map(); + window.__mnoteLocalFolderSelfChangeSuppressions.set(doc, expiresAt); + try { + var key = 'mnote.localFolder.selfChangeSuppressions.v1'; + var existing = JSON.parse(window.sessionStorage.getItem(key) || '{}'); + existing[doc] = expiresAt; + window.sessionStorage.setItem(key, JSON.stringify(existing)); + } catch (_) {} +} + +async function persistUploadedEditorChange(editor, asset, deps) { + deps = deps || {}; + var isLocalAsset = typeof deps.isLocalUploadedAsset === 'function' ? deps.isLocalUploadedAsset(asset) : isLocalUploadedAsset(asset); + if (!isLocalAsset || !editor || typeof editor.getJSON !== 'function') return; + var documentId = String( + asset && (asset.document_id || asset.documentId) + || (typeof deps.currentDocumentId === 'function' ? deps.currentDocumentId() : '') + || '' + ).trim(); + var rootUri = String( + asset && (asset.root_uri || asset.rootUri) + || (typeof deps.currentRootUri === 'function' ? deps.currentRootUri() : currentRootUri()) + || '' + ).trim(); + if (!documentId || !rootUri) return; + var tiptapDocument = editor.getJSON(); + var editorDocument = editorDocumentFromTiptapDocument({ documentId: documentId }, tiptapDocument); + var content = legacyBlocksFromEditorDocument(editorDocument); + var savePayload = { + documentId: documentId, + sourceKind: 'local_folder', + rootUri: rootUri, + expectedFileVersion: currentConflictDetectionKey(), + contentFormat: 'editorBlocks', + editorSource: 'local-upload-runtime', + editorDocument: editorDocument, + content: content, + tiptapDocument: tiptapDocument, + blockCount: Array.isArray(editorDocument.blocks) ? editorDocument.blocks.length : 0 + }; + persistLocalFolderSelfChangeSuppression(documentId, Date.now() + 5000); + var response = await fetch('/api/documents/save', { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(savePayload) + }); + var result = await response.json().catch(function() { return null; }); + if (!response.ok || !result || result.ok !== true) { + var message = result && (result.message || (result.error && result.error.message)) || ('save_failed_' + response.status); + document.documentElement.setAttribute('data-mnote-last-upload-save-error', message); + throw new Error(message); + } + document.documentElement.setAttribute('data-mnote-last-upload-saved', 'true'); + document.documentElement.removeAttribute('data-mnote-last-upload-save-error'); +} + function currentRootUri() { var params = new URLSearchParams(window.location.search); var fromUrl = (params.get('rootUri') || '').trim(); @@ -270,6 +385,8 @@ async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) { document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'true'); document.documentElement.setAttribute('data-mnote-last-upload-asset-id', assetId || ''); document.documentElement.removeAttribute('data-mnote-last-upload-insert-error'); + dispatchUploadedEditorChange(editorRoot, editor, deps); + await persistUploadedEditorChange(editor, asset, deps); } else { document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'false'); document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'insert_content_failed'); diff --git a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js index b1b7bf6e..cd0901cb 100644 --- a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js @@ -219,13 +219,18 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { || isOfficeFileName(fileName) || Boolean(localFilePath); if (!shouldEnhance) return; + link.setAttribute('data-mnote-attachment-link', 'true'); + var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || (localFilePath ? 'local-file:' + localFilePath : ''); + if (assetId) link.setAttribute('data-asset-id', assetId); + attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) { + if (name) link.classList.add(name); + }); + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer nofollow'); if (localFilePath && !isOnlyOfficeAttachmentHref(href)) { void refreshLocalAttachmentExistence(link); return; } - link.setAttribute('data-mnote-attachment-link', 'true'); - var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || (localFilePath ? 'local-file:' + localFilePath : ''); - if (assetId) link.setAttribute('data-asset-id', assetId); if (isOnlyOfficeAttachmentHref(href)) { link.setAttribute('href', buildOnlyOfficeOpenPath({ fileUrl: params.get('fileUrl') || '', @@ -237,11 +242,6 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { mode: params.get('mode') || 'view' })); } - attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) { - if (name) link.classList.add(name); - }); - link.setAttribute('target', '_blank'); - link.setAttribute('rel', 'noopener noreferrer nofollow'); void hydrateEditorAttachmentMeta(link); } @@ -253,6 +253,12 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { observeEditorAttachmentRoots(); enhanceEditorAttachmentLinks(); }; + var attachmentInitialEnhanceAttempts = 0; + var attachmentInitialEnhanceTimer = window.setInterval(function() { + attachmentInitialEnhanceAttempts += 1; + enhanceEditorAttachmentLinks(); + if (attachmentInitialEnhanceAttempts >= 120) window.clearInterval(attachmentInitialEnhanceTimer); + }, 500); function ensureAttachmentActions() { var existing = document.querySelector('[data-testid="mnote-attachment-actions"]'); @@ -600,6 +606,26 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { window.__mnoteEnhanceEditorAttachmentLinks(); scheduleEditorAttachmentEnhance(); }); + [ + 'mnote:leptos-tiptap-spike:ready', + 'mnote:leptos-tiptap-spike:change', + 'mnote:leptos-tiptap-spike:state' + ].forEach(function(eventName) { + window.addEventListener(eventName, function() { + enhanceEditorAttachmentLinks(); + observeEditorAttachmentRoots(); + window.setTimeout(function() { + enhanceEditorAttachmentLinks(); + observeEditorAttachmentRoots(); + }, 120); + }, true); + }); + [120, 500, 1200, 2500].forEach(function(delayMs) { + window.setTimeout(function() { + enhanceEditorAttachmentLinks(); + observeEditorAttachmentRoots(); + }, delayMs); + }); function interceptEditorAttachmentLink(event) { var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'); diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index 4a1a6db0..16be0a6e 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -1234,6 +1234,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim buildOnlyOfficeOpenPath: buildOnlyOfficeOpenPath, inferOnlyOfficeFileType: inferOnlyOfficeFileType, currentDocumentId: currentDocumentId, + currentRootUri: currentRootUri, uploadedAttachmentClass: uploadedAttachmentClass, uploadedFileSize: uploadedFileSize, cssEscape: cssEscape, diff --git a/rust/crates/mnote-web/src/routes/onlyoffice.rs b/rust/crates/mnote-web/src/routes/onlyoffice.rs index 14d1dd45..7e6faa0d 100644 --- a/rust/crates/mnote-web/src/routes/onlyoffice.rs +++ b/rust/crates/mnote-web/src/routes/onlyoffice.rs @@ -405,10 +405,9 @@ pub async fn page(Query(query): Query) -> Result= 120") + ); } #[test] diff --git a/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js b/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js index ef33666f..58cd9191 100644 --- a/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js +++ b/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js @@ -177,6 +177,24 @@ async function waitForPrimaryAttachment(page, fileName) { return link; } +async function waitForPrimaryAttachmentClass(page, fileName, className) { + await page.waitForFunction( + ({ name, className }) => { + const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + const link = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"]') || []) + .find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes(name)); + return link instanceof HTMLAnchorElement && link.classList.contains(className); + }, + { name: fileName, className }, + { timeout: UI_TIMEOUT_MS }, + ); + return await waitForPrimaryAttachment(page, fileName) + .then((link) => link.evaluate((node) => { + const badgeColor = getComputedStyle(node, "::before").backgroundColor; + return `${node.getAttribute("class") || ""} ${badgeColor}`.trim(); + })); +} + async function selectPrimaryAttachmentLink(page, fileName) { await page.evaluate((name) => { const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); @@ -348,13 +366,34 @@ async function main() { assert.equal(uploadAsset.markdownRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 markdownRelativePath"); assert.equal(uploadAsset.ownerDocumentId, "local-md:README.md", "主编辑区上传响应应返回 ownerDocumentId"); await assertNoPrimaryConflictPanel(page, "主编辑区上传附件后"); + const uploadedClassBeforeReload = await waitForPrimaryAttachmentClass( + page, + "uploaded-one.md", + "mnote-uploaded-attachment-code", + ); + assert( + uploadedClassBeforeReload.includes("mnote-uploaded-attachment-code"), + `上传后的 md 附件应使用 code/markdown 黑色图标 class,实际 class=${uploadedClassBeforeReload}`, + ); + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const uploadedClassAfterReload = await waitForPrimaryAttachmentClass( + page, + "uploaded-one.md", + "mnote-uploaded-attachment-code", + ); + assert( + uploadedClassAfterReload.includes("mnote-uploaded-attachment-code"), + `刷新后的 md 附件应保持 code/markdown 黑色图标 class,实际 class=${uploadedClassAfterReload}`, + ); checks[checkId] = { ok: true, - message: `上传文件落在资源目录 ${correctPath}`, + message: `上传文件落在资源目录 ${correctPath},刷新后仍保持 md 附件图标 class`, landedInResourceDir, landedInRoot, uploadAsset, + uploadedClassBeforeReload, + uploadedClassAfterReload, }; } catch (err) { overallOk = false; diff --git a/scripts/task491-local-md-attachment-icon-refresh-smoke.js b/scripts/task491-local-md-attachment-icon-refresh-smoke.js new file mode 100644 index 00000000..ca98533c --- /dev/null +++ b/scripts/task491-local-md-attachment-icon-refresh-smoke.js @@ -0,0 +1,243 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task491-local-md-attachment-icon-refresh-smoke"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] + .find((candidate) => fs.existsSync(candidate)); + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +function documentUrl(root, relativePath) { + const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", fileUrl(root)); + url.searchParams.set("treeView", "filetree"); + return url.toString(); +} + +function writeWorkspaceManifest(root, ownerId) { + const metadataDir = path.join(root, ".mnote"); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, "workspace.json"), + `${JSON.stringify({ + workspaceId: `local-ws:${ownerId}:task491`, + ownerId, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "markdown_edit", "asset_upload"], + }, null, 2)}\n`, + "utf8", + ); +} + +async function quickLogin(page) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); + if (await quickLoginButton.count()) { + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined); + } +} + +async function openDocument(page, root, relativePath) { + await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function installChangeEventProbe(page) { + await page.evaluate(() => { + window.__task491ChangeEvents = []; + const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + if (!(root instanceof HTMLElement)) throw new Error("runtime_root_missing"); + root.addEventListener("mnote:leptos-tiptap-spike:change", (event) => { + const payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; + window.__task491ChangeEvents.push({ + at: Date.now(), + hasPayload: Boolean(event.detail && event.detail.payload), + documentId: payload && payload.documentId, + contentText: JSON.stringify(payload && payload.content || {}).slice(0, 500), + text: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.innerText || "", + }); + }); + }); +} + +async function uploadAttachmentViaSlash(page, fileName, content) { + const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("End").catch(() => undefined); + await page.keyboard.type("/"); + const item = page.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]').first(); + await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const [fileChooser] = await Promise.all([ + page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }), + item.click({ timeout: UI_TIMEOUT_MS }), + ]); + await fileChooser.setFiles({ + name: fileName, + mimeType: "text/markdown", + buffer: Buffer.from(content, "utf8"), + }); +} + +async function waitForMdAttachmentClass(page, fileName) { + await page.waitForFunction( + (name) => { + const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + const links = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"]') || []); + const link = links.find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes(name)); + return link instanceof HTMLAnchorElement && link.classList.contains("mnote-uploaded-attachment-code"); + }, + fileName, + { timeout: UI_TIMEOUT_MS }, + ); + return await page.evaluate((name) => { + const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + const links = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"]') || []); + const link = links.find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes(name)); + if (!(link instanceof HTMLAnchorElement)) return null; + return { + href: link.getAttribute("href") || "", + className: link.getAttribute("class") || "", + assetId: link.getAttribute("data-asset-id") || "", + text: link.textContent || "", + }; + }, fileName); +} + +async function waitForFileContent(filePath, predicate, timeoutMs) { + const startedAt = Date.now(); + let lastContent = ""; + while (Date.now() - startedAt < timeoutMs) { + if (fs.existsSync(filePath)) { + lastContent = fs.readFileSync(filePath, "utf8"); + if (predicate(lastContent)) return lastContent; + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new Error(`文件内容未达到预期: ${filePath}; lastContent=${JSON.stringify(lastContent)}`); +} + +async function changeEventProbe(page) { + return await page.evaluate(() => window.__task491ChangeEvents || []).catch(() => []); +} + +(async () => { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task491-")); + const relativePath = "README.md"; + const resourceDir = path.join(root, "README"); + const fileName = "uploaded-one.md"; + writeWorkspaceManifest(root, "mnote-e2e"); + fs.mkdirSync(resourceDir, { recursive: true }); + fs.writeFileSync(path.join(root, relativePath), "# Attachment Icon\n\n正文\n", "utf8"); + + const browser = await chromium.launch({ + headless: process.env.HEADFUL !== "1", + executablePath: CHROMIUM_EXECUTABLE_PATH, + }); + const context = await browser.newContext({ + viewport: { width: 1366, height: 900 }, + extraHTTPHeaders: { + "x-mnote-actor-id": "mnote-e2e", + "x-mnote-actor-type": "user", + }, + }); + const page = await context.newPage(); + const result = { root, fileName, states: [], console: [], pageErrors: [] }; + const saveResponses = []; + page.on("console", (message) => { + result.console.push({ type: message.type(), text: message.text() }); + }); + page.on("pageerror", (error) => { + result.pageErrors.push(String(error && error.stack || error)); + }); + page.on("response", async (response) => { + if (!response.url().includes("/api/documents/save")) return; + saveResponses.push({ + status: response.status(), + payload: await response.json().catch(() => null), + }); + }); + + try { + await quickLogin(page); + await openDocument(page, root, relativePath); + await installChangeEventProbe(page); + await uploadAttachmentViaSlash(page, fileName, "# Uploaded One\n\n第一个上传附件\n"); + const beforeReload = await waitForMdAttachmentClass(page, fileName); + result.states.push({ step: "before-reload", link: beforeReload, changeEvents: await changeEventProbe(page), saveResponses }); + + const expectedPath = path.join(resourceDir, fileName); + assert(fs.existsSync(expectedPath), `上传后的 md 附件应落盘到页面资源目录: ${expectedPath}`); + const savedMarkdown = await waitForFileContent( + path.join(root, relativePath), + (content) => content.includes(fileName) && content.includes("README/uploaded-one.md"), + UI_TIMEOUT_MS, + ); + result.states.push({ step: "saved-markdown", savedMarkdown }); + + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const afterReload = await waitForMdAttachmentClass(page, fileName); + result.states.push({ step: "after-reload", link: afterReload }); + + assert(beforeReload.className.includes("mnote-uploaded-attachment-code"), "刷新前 md 附件应使用 code 图标 class"); + assert(afterReload.className.includes("mnote-uploaded-attachment-code"), "刷新后 md 附件应保留 code 图标 class"); + fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, ...result }, null, 2)}\n`, "utf8"); + console.log(JSON.stringify({ ok: true, resultPath: RESULT_PATH, root, beforeReload, afterReload }, null, 2)); + } catch (error) { + const diagnostics = await page.evaluate(() => { + const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); + const status = document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || ""; + const statusError = document.querySelector('[data-runtime-editor-error]')?.getAttribute('data-runtime-editor-error') || ""; + return { + url: location.href, + status, + statusError, + editorText: editor instanceof HTMLElement ? editor.innerText : "", + links: Array.from(editor?.querySelectorAll("a") || []).map((node) => ({ + href: node.getAttribute("href") || "", + className: node.getAttribute("class") || "", + text: node.textContent || "", + })), + }; + }).catch((err) => ({ diagnosticsError: String(err) })); + await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined); + fs.writeFileSync( + RESULT_PATH, + `${JSON.stringify({ ok: false, root, fileName, states: result.states, saveResponses, diagnostics, console: result.console, pageErrors: result.pageErrors, error: String(error && error.stack || error) }, null, 2)}\n`, + "utf8", + ); + console.error(JSON.stringify({ ok: false, resultPath: RESULT_PATH, root, diagnostics, error: String(error && error.stack || error) }, null, 2)); + process.exitCode = 1; + } finally { + await browser.close().catch(() => undefined); + } +})();