推进本地缓冲区与资源对象壳验证
This commit is contained in:
@@ -181,6 +181,91 @@ pub struct KernelObjectIdentity {
|
|||||||
pub asset_id: Option<String>,
|
pub asset_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DocumentBuffer 打开状态 — 表达当前编辑器缓冲区对文件的打开态、dirty 状态和文件版本仲裁。
|
||||||
|
///
|
||||||
|
/// 五种状态:Clean(未修改)、Dirty(已修改未保存)、Stale(版本过时)、
|
||||||
|
/// ExternalModified(外部已修改)、Deleted(外部已删除)。
|
||||||
|
///
|
||||||
|
/// Phase A2 收口后,tiptap autosave、AI 写入、外部 watcher 均围绕此状态仲裁。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DocBufferDirtyState {
|
||||||
|
Clean,
|
||||||
|
Dirty,
|
||||||
|
Stale,
|
||||||
|
ExternalModified,
|
||||||
|
Deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DocBufferDirtyState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Clean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DocumentBuffer 最小字段模型 — 表示一个已打开文档的缓冲区状态。
|
||||||
|
///
|
||||||
|
/// 不是持久化结构,而是运行时模型,用于仲裁写入冲突和外部修改检测。
|
||||||
|
/// 每个打开的编辑器(tiptap / AI session / external editor)对应一个实例。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentBuffer {
|
||||||
|
/// 文档在 workspace 中的统一路径身份
|
||||||
|
pub workspace_path: ObjectWorkspacePath,
|
||||||
|
/// 最后已知文件版本(来自 aggregate.fileVersion 或 disk mtime content-hash)
|
||||||
|
pub file_version: Option<String>,
|
||||||
|
/// 最后加载/保存时的内容 hash
|
||||||
|
pub base_content_hash: Option<String>,
|
||||||
|
/// 当前编辑器内容 hash(dirty 时与 base 不同)
|
||||||
|
pub current_content_hash: Option<String>,
|
||||||
|
/// 缓冲区 dirty 状态
|
||||||
|
pub dirty_state: DocBufferDirtyState,
|
||||||
|
/// 最后加载时间戳 (ms since epoch)
|
||||||
|
pub last_loaded_at: Option<i64>,
|
||||||
|
/// 最后保存时间戳 (ms since epoch)
|
||||||
|
pub last_saved_at: Option<i64>,
|
||||||
|
/// 外部修改者
|
||||||
|
pub external_actor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocumentBuffer {
|
||||||
|
pub fn is_dirty(&self) -> bool {
|
||||||
|
self.dirty_state == DocBufferDirtyState::Dirty
|
||||||
|
|| self.dirty_state == DocBufferDirtyState::Stale
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_dirty(&mut self, content_hash: String) {
|
||||||
|
self.current_content_hash = Some(content_hash);
|
||||||
|
self.dirty_state = DocBufferDirtyState::Dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_saved(&mut self, file_version: String, content_hash: String) {
|
||||||
|
self.file_version = Some(file_version);
|
||||||
|
self.base_content_hash = Some(content_hash);
|
||||||
|
self.current_content_hash = None;
|
||||||
|
self.dirty_state = DocBufferDirtyState::Clean;
|
||||||
|
self.last_saved_at = Some(
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_external_modified(&mut self, actor: Option<String>) {
|
||||||
|
self.external_actor = actor;
|
||||||
|
if self.is_dirty() {
|
||||||
|
self.dirty_state = DocBufferDirtyState::Stale;
|
||||||
|
} else {
|
||||||
|
self.dirty_state = DocBufferDirtyState::ExternalModified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_deleted(&mut self) {
|
||||||
|
self.dirty_state = DocBufferDirtyState::Deleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct KernelBlockAssetRelation {
|
pub struct KernelBlockAssetRelation {
|
||||||
@@ -190,6 +275,28 @@ pub struct KernelBlockAssetRelation {
|
|||||||
pub asset_kind: KernelProjectionAssetKind,
|
pub asset_kind: KernelProjectionAssetKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 统一 workspace 路径身份 — 表达"哪个 workspace root 下的哪个对象/文件/资源"。
|
||||||
|
///
|
||||||
|
/// 这是 Phase A1 收口后的主身份类型,用于:
|
||||||
|
/// - File Tree / Page Tree / Resource Tree 的 resourceMeta
|
||||||
|
/// - 本地文件写入时的身份验证
|
||||||
|
/// - 前端 sidebar/filetree 的统一 identity 消费
|
||||||
|
///
|
||||||
|
/// 相比旧的 `KernelObjectIdentity`,增加了 workspace 上下文:
|
||||||
|
/// `workspace_id`、`source_kind`、`root_uri`、`relative_path`。
|
||||||
|
///
|
||||||
|
/// 所有新代码应优先使用此类型;`KernelObjectIdentity` 保留为内核投影的兼容子集。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ObjectWorkspacePath {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub source_kind: WorkspaceSourceKind,
|
||||||
|
pub root_uri: String,
|
||||||
|
pub relative_path: String,
|
||||||
|
pub object_identity: KernelObjectIdentity,
|
||||||
|
pub resource_kind: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum KernelGraphDirection {
|
pub enum KernelGraphDirection {
|
||||||
@@ -817,4 +924,151 @@ mod tests {
|
|||||||
assert_eq!(request.base_content_hash, None);
|
assert_eq!(request.base_content_hash, None);
|
||||||
assert_eq!(request.editor_source, None);
|
assert_eq!(request.editor_source, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_workspace_path_round_trip() {
|
||||||
|
let path = ObjectWorkspacePath {
|
||||||
|
workspace_id: "local-ws:user:my-space".into(),
|
||||||
|
source_kind: WorkspaceSourceKind::LocalFolder,
|
||||||
|
root_uri: "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space".into(),
|
||||||
|
relative_path: "docs/README.md".into(),
|
||||||
|
object_identity: KernelObjectIdentity {
|
||||||
|
object_kind: KernelObjectKind::Page,
|
||||||
|
document_id: Some("local-md:docs~2FREADME.md".into()),
|
||||||
|
block_id: None,
|
||||||
|
asset_id: None,
|
||||||
|
},
|
||||||
|
resource_kind: Some("document".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&path).expect("ObjectWorkspacePath 应可序列化");
|
||||||
|
assert_eq!(value["workspaceId"], json!("local-ws:user:my-space"));
|
||||||
|
assert_eq!(value["sourceKind"], json!("local_folder"));
|
||||||
|
assert_eq!(
|
||||||
|
value["rootUri"],
|
||||||
|
json!("file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space")
|
||||||
|
);
|
||||||
|
assert_eq!(value["relativePath"], json!("docs/README.md"));
|
||||||
|
assert_eq!(value["objectIdentity"]["objectKind"], json!("page"));
|
||||||
|
assert_eq!(
|
||||||
|
value["objectIdentity"]["documentId"],
|
||||||
|
json!("local-md:docs~2FREADME.md")
|
||||||
|
);
|
||||||
|
assert_eq!(value["resourceKind"], json!("document"));
|
||||||
|
|
||||||
|
let decoded: ObjectWorkspacePath =
|
||||||
|
serde_json::from_value(value).expect("ObjectWorkspacePath 应可反序列化");
|
||||||
|
assert_eq!(decoded, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_workspace_path_accepts_resource_kind_null() {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Minimal {
|
||||||
|
resource_kind: Option<String>,
|
||||||
|
}
|
||||||
|
let value = serde_json::json!({
|
||||||
|
"workspaceId": "ws_1",
|
||||||
|
"sourceKind": "convex_workspace",
|
||||||
|
"rootUri": "convex://ws",
|
||||||
|
"relativePath": "doc.md",
|
||||||
|
"objectIdentity": {
|
||||||
|
"objectKind": "page",
|
||||||
|
"documentId": "doc_1",
|
||||||
|
"blockId": null,
|
||||||
|
"assetId": null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let path: ObjectWorkspacePath =
|
||||||
|
serde_json::from_value(value).expect("resource_kind null 应接受");
|
||||||
|
assert_eq!(path.resource_kind, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_buffer_round_trip() {
|
||||||
|
let identity = KernelObjectIdentity {
|
||||||
|
object_kind: KernelObjectKind::Page,
|
||||||
|
document_id: Some("local-md:doc.md".into()),
|
||||||
|
block_id: None,
|
||||||
|
asset_id: None,
|
||||||
|
};
|
||||||
|
let path = ObjectWorkspacePath {
|
||||||
|
workspace_id: "ws_1".into(),
|
||||||
|
source_kind: WorkspaceSourceKind::LocalFolder,
|
||||||
|
root_uri: "file:///tmp/root".into(),
|
||||||
|
relative_path: "doc.md".into(),
|
||||||
|
object_identity: identity,
|
||||||
|
resource_kind: Some("document".into()),
|
||||||
|
};
|
||||||
|
let buf = DocumentBuffer {
|
||||||
|
workspace_path: path,
|
||||||
|
file_version: Some("v1".into()),
|
||||||
|
base_content_hash: Some("sha256:abc".into()),
|
||||||
|
current_content_hash: None,
|
||||||
|
dirty_state: DocBufferDirtyState::Clean,
|
||||||
|
last_loaded_at: Some(1_700_000_000_000i64),
|
||||||
|
last_saved_at: Some(1_700_000_000_001i64),
|
||||||
|
external_actor: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&buf).expect("DocumentBuffer 应可序列化");
|
||||||
|
assert_eq!(value["dirtyState"], json!("clean"));
|
||||||
|
assert_eq!(value["fileVersion"], json!("v1"));
|
||||||
|
assert_eq!(value["workspacePath"]["relativePath"], json!("doc.md"));
|
||||||
|
|
||||||
|
let decoded: DocumentBuffer =
|
||||||
|
serde_json::from_value(value).expect("DocumentBuffer 应可反序列化");
|
||||||
|
assert_eq!(decoded, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_buffer_state_transitions() {
|
||||||
|
let identity = KernelObjectIdentity {
|
||||||
|
object_kind: KernelObjectKind::Page,
|
||||||
|
document_id: Some("local-md:doc.md".into()),
|
||||||
|
block_id: None,
|
||||||
|
asset_id: None,
|
||||||
|
};
|
||||||
|
let path = ObjectWorkspacePath {
|
||||||
|
workspace_id: "ws_1".into(),
|
||||||
|
source_kind: WorkspaceSourceKind::LocalFolder,
|
||||||
|
root_uri: "file:///tmp/root".into(),
|
||||||
|
relative_path: "doc.md".into(),
|
||||||
|
object_identity: identity,
|
||||||
|
resource_kind: Some("document".into()),
|
||||||
|
};
|
||||||
|
let mut buf = DocumentBuffer {
|
||||||
|
workspace_path: path,
|
||||||
|
file_version: Some("v1".into()),
|
||||||
|
base_content_hash: Some("sha256:abc".into()),
|
||||||
|
current_content_hash: None,
|
||||||
|
dirty_state: DocBufferDirtyState::Clean,
|
||||||
|
last_loaded_at: None,
|
||||||
|
last_saved_at: None,
|
||||||
|
external_actor: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!buf.is_dirty());
|
||||||
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
|
||||||
|
|
||||||
|
buf.mark_dirty("sha256:dirty".into());
|
||||||
|
assert!(buf.is_dirty());
|
||||||
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::Dirty);
|
||||||
|
|
||||||
|
buf.mark_external_modified(Some("external-editor".into()));
|
||||||
|
assert!(buf.is_dirty());
|
||||||
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::Stale);
|
||||||
|
assert_eq!(buf.external_actor.as_deref(), Some("external-editor"));
|
||||||
|
|
||||||
|
buf.mark_saved("v2".into(), "sha256:saved".into());
|
||||||
|
assert!(!buf.is_dirty());
|
||||||
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
|
||||||
|
assert_eq!(buf.file_version.as_deref(), Some("v2"));
|
||||||
|
|
||||||
|
buf.mark_external_modified(None);
|
||||||
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::ExternalModified);
|
||||||
|
|
||||||
|
buf.mark_deleted();
|
||||||
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::Deleted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,19 +37,20 @@ pub use editor::{
|
|||||||
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
|
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
|
||||||
};
|
};
|
||||||
pub use kernel::{
|
pub use kernel::{
|
||||||
DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode,
|
DocBufferDirtyState, DocumentBuffer, DocumentContentResult, DocumentReadEvidenceItem,
|
||||||
DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree,
|
DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta, DocumentReadNodeType,
|
||||||
DocumentReadStats, DocumentReadSubtree, KernelAttachEdge, KernelAuditStamp,
|
DocumentReadOutlineEntry, DocumentReadPageSubtree, DocumentReadStats, DocumentReadSubtree,
|
||||||
KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge,
|
KernelAttachEdge, KernelAuditStamp, KernelBlockAssetRelation, KernelContentPayload,
|
||||||
KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
|
KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType,
|
||||||
KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
|
KernelGetNode, KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult,
|
||||||
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelObjectIdentity,
|
KernelGraphVisit, KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode,
|
||||||
KernelObjectKind, KernelProjectionAssetKind, KernelProjectionCapability,
|
KernelNodeMetadata, KernelNodeType, KernelObjectIdentity, KernelObjectKind,
|
||||||
KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest,
|
KernelProjectionAssetKind, KernelProjectionCapability, KernelProjectionFilter,
|
||||||
|
KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest,
|
||||||
KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult,
|
KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult,
|
||||||
KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult,
|
KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult,
|
||||||
KernelTraverseGraph, KernelUpdateNode, PageBodyWriteRequest, WorkspaceSource,
|
KernelTraverseGraph, KernelUpdateNode, ObjectWorkspacePath, PageBodyWriteRequest,
|
||||||
WorkspaceSourceCapability, WorkspaceSourceKind,
|
WorkspaceSource, WorkspaceSourceCapability, WorkspaceSourceKind,
|
||||||
};
|
};
|
||||||
pub use mindmap::{
|
pub use mindmap::{
|
||||||
MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities,
|
MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const fs = require("node:fs/promises");
|
const fs = require("node:fs/promises");
|
||||||
|
const fsSync = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { chromium } = require("playwright");
|
const { chromium } = require("playwright");
|
||||||
const {
|
const {
|
||||||
@@ -20,6 +21,10 @@ const {
|
|||||||
const TASK = "task456-resource-object-shell-sync-smoke";
|
const TASK = "task456-resource-object-shell-sync-smoke";
|
||||||
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||||
const RESULT_PATH = path.join(OUT_DIR, "result.json");
|
const RESULT_PATH = path.join(OUT_DIR, "result.json");
|
||||||
|
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
const PROBE_DOCX_PATH =
|
||||||
|
process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
|
||||||
|
"/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
|
||||||
|
|
||||||
async function writeResult(result) {
|
async function writeResult(result) {
|
||||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||||
@@ -38,6 +43,30 @@ async function createMindmap(request, workspaceId, documentId, mindmapId, title)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadOfficeAsset(request, workspaceId, documentId) {
|
||||||
|
assert(fsSync.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`);
|
||||||
|
const buffer = await fs.readFile(PROBE_DOCX_PATH);
|
||||||
|
const response = await request.fetch(`${BASE_URL}/api/media/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
multipart: {
|
||||||
|
file: {
|
||||||
|
name: "task456-office.docx",
|
||||||
|
mimeType: DOCX_MIME,
|
||||||
|
buffer,
|
||||||
|
},
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(async () => ({ raw: await response.text() }));
|
||||||
|
assert(response.ok(), `/api/media/upload 请求失败:${response.status()} ${JSON.stringify(payload)}`);
|
||||||
|
const asset = payload && payload.asset && typeof payload.asset === "object" ? payload.asset : null;
|
||||||
|
const assetId = asset && typeof asset.id === "string" ? asset.id : "";
|
||||||
|
assert(assetId, `上传结果缺少 asset.id:${JSON.stringify(payload)}`);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
async function readResourceState(page, documentId, mindmapId, officeAssetId) {
|
async function readResourceState(page, documentId, mindmapId, officeAssetId) {
|
||||||
return await page.evaluate(
|
return await page.evaluate(
|
||||||
({ documentId: docId, mindmapId: mapId, officeId }) => {
|
({ documentId: docId, mindmapId: mapId, officeId }) => {
|
||||||
@@ -62,29 +91,23 @@ async function readResourceState(page, documentId, mindmapId, officeAssetId) {
|
|||||||
currentObjectIdentity: document.querySelector("[data-mnote-object-identity]")?.getAttribute("data-mnote-object-identity") || "",
|
currentObjectIdentity: document.querySelector("[data-mnote-object-identity]")?.getAttribute("data-mnote-object-identity") || "",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ documentId, mindmapId, officeAssetId },
|
{ documentId, mindmapId, officeId: officeAssetId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function injectOfficeRow(page, documentId, assetId) {
|
async function readObjectShellState(page) {
|
||||||
await page.evaluate(
|
return await page.evaluate(() => ({
|
||||||
({ docId, officeId }) => {
|
currentObjectEditor: document.querySelector("[data-mnote-object-editor]")?.getAttribute("data-mnote-object-editor") || "",
|
||||||
const root = document.getElementById("sidebar-file-tree-root");
|
currentObjectIdentity: document.querySelector("[data-mnote-object-identity]")?.getAttribute("data-mnote-object-identity") || "",
|
||||||
if (!(root instanceof HTMLElement)) throw new Error("缺少 file tree root");
|
currentUrl: window.location.href,
|
||||||
if (document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(officeId)}"]`)) return;
|
}));
|
||||||
const row = document.createElement("div");
|
}
|
||||||
row.className = "tree-row";
|
|
||||||
row.dataset.testid = "filetree-asset-row";
|
async function readFileTreeProjection(request, workspaceId, rootNodeId) {
|
||||||
row.dataset.assetId = officeId;
|
return await requestJson(
|
||||||
row.dataset.objectKind = "onlyoffice";
|
request,
|
||||||
row.dataset.objectIdentity = JSON.stringify({ objectKind: "onlyoffice", documentId: docId, assetId: officeId });
|
`/api/tree/projections/file?workspaceId=${encodeURIComponent(workspaceId)}&rootNodeId=${encodeURIComponent(rootNodeId)}&depth=3`,
|
||||||
row.innerHTML = `<button class="tree-link" type="button" data-rust-action="open"><span class="tree-link-title">task456-office.docx</span></button>`;
|
{ method: "GET" },
|
||||||
row.querySelector("button").addEventListener("click", () => {
|
|
||||||
window.location.href = `/office/${encodeURIComponent(docId)}/${encodeURIComponent(officeId)}?fileName=task456-office.docx&fileType=docx&mode=edit`;
|
|
||||||
});
|
|
||||||
root.appendChild(row);
|
|
||||||
},
|
|
||||||
{ docId: documentId, officeId: assetId },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,18 +135,33 @@ async function injectOfficeRow(page, documentId, assetId) {
|
|||||||
const title = `TEST-456-resource-${stamp}`;
|
const title = `TEST-456-resource-${stamp}`;
|
||||||
await renameDocument(context.request, doc.workspaceId, doc.documentId, title);
|
await renameDocument(context.request, doc.workspaceId, doc.documentId, title);
|
||||||
const mindmapId = `mindmap_456_${stamp}`;
|
const mindmapId = `mindmap_456_${stamp}`;
|
||||||
const officeAssetId = `asset_office_456_${stamp}`;
|
|
||||||
await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-456-mind-${stamp}`);
|
await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-456-mind-${stamp}`);
|
||||||
result.fixture = { ...doc, title, mindmapId, officeAssetId };
|
const officeAsset = await uploadOfficeAsset(context.request, doc.workspaceId, doc.documentId);
|
||||||
|
const officeAssetId = officeAsset.id;
|
||||||
|
result.fixture = { ...doc, title, mindmapId, officeAssetId, officeAsset };
|
||||||
|
result.projectionAfterUpload = await readFileTreeProjection(context.request, doc.workspaceId, doc.documentId).catch((error) => ({
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}));
|
||||||
|
|
||||||
await openDocument(page, doc.workspaceId, doc.documentId);
|
await openDocument(page, doc.workspaceId, doc.documentId);
|
||||||
await openFilesystemView(page);
|
await openFilesystemView(page);
|
||||||
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS });
|
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS });
|
||||||
await injectOfficeRow(page, doc.documentId, officeAssetId);
|
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${officeAssetId}"]`, { timeout: UI_TIMEOUT_MS });
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.__task456FileRoot = document.getElementById("sidebar-file-tree-root");
|
window.__task456FileRoot = document.getElementById("sidebar-file-tree-root");
|
||||||
});
|
});
|
||||||
result.before = await readResourceState(page, doc.documentId, mindmapId, officeAssetId);
|
result.before = await readResourceState(page, doc.documentId, mindmapId, officeAssetId);
|
||||||
|
result.beforeFileTreeRows = await page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
|
||||||
|
rowId: row.getAttribute("data-row-id") || "",
|
||||||
|
rowKind: row.getAttribute("data-row-kind") || "",
|
||||||
|
documentId: row.getAttribute("data-document-id") || "",
|
||||||
|
ownerDocumentId: row.getAttribute("data-owner-document-id") || "",
|
||||||
|
assetId: row.getAttribute("data-asset-id") || "",
|
||||||
|
objectKind: row.getAttribute("data-object-kind") || "",
|
||||||
|
title: row.querySelector(".tree-link-title")?.textContent?.trim() || "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
assert(result.before.mindmapExists, "filetree 应显示 mindmap 资源行");
|
assert(result.before.mindmapExists, "filetree 应显示 mindmap 资源行");
|
||||||
assert(result.before.officeExists, "filetree 应显示 office 资源行");
|
assert(result.before.officeExists, "filetree 应显示 office 资源行");
|
||||||
|
|
||||||
@@ -147,17 +185,17 @@ async function injectOfficeRow(page, documentId, assetId) {
|
|||||||
});
|
});
|
||||||
await openDocument(page, doc.workspaceId, doc.documentId);
|
await openDocument(page, doc.workspaceId, doc.documentId);
|
||||||
await openFilesystemView(page);
|
await openFilesystemView(page);
|
||||||
await injectOfficeRow(page, doc.documentId, officeAssetId);
|
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${officeAssetId}"]`, { timeout: UI_TIMEOUT_MS });
|
||||||
result.afterMindmapUpdate = await readResourceState(page, doc.documentId, mindmapId, officeAssetId);
|
result.afterMindmapUpdate = await readResourceState(page, doc.documentId, mindmapId, officeAssetId);
|
||||||
assert(result.afterMindmapUpdate.mindmapExists, "mindmap 更新后 filetree 资源行不应丢失");
|
assert(result.afterMindmapUpdate.mindmapExists, "mindmap 更新后 filetree 资源行不应丢失");
|
||||||
assert(result.afterMindmapUpdate.pageExists, "mindmap 更新后页面行不应丢失");
|
assert(result.afterMindmapUpdate.pageExists, "mindmap 更新后页面行不应丢失");
|
||||||
|
|
||||||
|
const officePopupPromise = page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS });
|
||||||
await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${officeAssetId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
|
await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${officeAssetId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
|
||||||
await page.waitForURL((url) => url.pathname.includes(`/office/${encodeURIComponent(doc.documentId)}/${encodeURIComponent(officeAssetId)}`), {
|
const officePage = await officePopupPromise;
|
||||||
timeout: UI_TIMEOUT_MS,
|
await officePage.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS });
|
||||||
});
|
await officePage.waitForSelector("[data-mnote-object-editor='onlyoffice']", { timeout: UI_TIMEOUT_MS });
|
||||||
await page.waitForSelector("[data-mnote-object-editor='onlyoffice']", { timeout: UI_TIMEOUT_MS });
|
result.afterOfficeOpen = await readObjectShellState(officePage);
|
||||||
result.afterOfficeOpen = await readResourceState(page, doc.documentId, mindmapId, officeAssetId);
|
|
||||||
assert(result.afterOfficeOpen.currentObjectIdentity.includes(`resource:onlyoffice:${doc.documentId}:${officeAssetId}`), "office 对象壳应暴露 resource identity");
|
assert(result.afterOfficeOpen.currentObjectIdentity.includes(`resource:onlyoffice:${doc.documentId}:${officeAssetId}`), "office 对象壳应暴露 resource identity");
|
||||||
|
|
||||||
await writeResult({ ...result, ok: true, finalUrl: page.url() });
|
await writeResult({ ...result, ok: true, finalUrl: page.url() });
|
||||||
|
|||||||
Reference in New Issue
Block a user