feat: consolidate local-first mnote web runtime

This commit is contained in:
lix-2026
2026-05-28 22:01:44 +08:00
parent 7354807ee9
commit 39b9a0183a
154 changed files with 13591 additions and 12728 deletions
+274 -87
View File
@@ -3,45 +3,45 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::{
build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order,
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
execute_runtime_command_via_legacy_cloud_with_artifacts, read_optional_non_empty,
};
use crate::routes::local_folder_source::{
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state,
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
local_workspace_id_from_root_uri, LocalAccessMode,
LocalAccessMode, ensure_local_workspace_access_with_state,
ensure_local_workspace_read_access_with_state, execute_local_tree_command_with_sort,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
};
use crate::routes::query_support::{
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
use crate::transport::convex::execute_retired_mutation_by_name;
use crate::tree_shell::filetree_renderer::{
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html,
};
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
use crate::tree_shell::page_renderer::{
render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow,
PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html,
};
use crate::tree_shell::picker_renderer::{
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html,
};
use crate::tree_shell::renderer_input::{
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
TreeShellRendererInput,
};
use crate::tree_shell::runtime_api::{
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
TreeShellRuntimeResult,
TreeShellRuntimeRequest, TreeShellRuntimeResult,
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request,
};
use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{Html, IntoResponse, Response};
use bridge_runtime::RuntimeCommandEnvelopeWire;
use core_protocol::KernelProjectionKind;
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -137,6 +137,69 @@ impl TreeCommandEnvelopeContext {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub struct TreeCompatAliasCatalogEntry {
pub compat_command: &'static str,
pub preferred_command: &'static str,
pub source_kind: &'static str,
pub retained_for: &'static str,
pub retirement_condition: &'static str,
}
#[allow(dead_code)]
pub const TREE_DOCUMENT_COMPAT_ALIAS_CATALOG: &[TreeCompatAliasCatalogEntry] = &[
TreeCompatAliasCatalogEntry {
compat_command: "documents.create",
preferred_command: "tree.node.create",
source_kind: "convex_workspace",
retained_for: "legacy cloud / remote callers still emitting documents.*",
retirement_condition: "all cloud and remote callers emit tree.node.create",
},
TreeCompatAliasCatalogEntry {
compat_command: "documents.title.update",
preferred_command: "tree.node.rename",
source_kind: "convex_workspace",
retained_for: "legacy cloud / remote callers still emitting documents.*",
retirement_condition: "all cloud and remote callers emit tree.node.rename",
},
TreeCompatAliasCatalogEntry {
compat_command: "documents.move",
preferred_command: "tree.subtree.move",
source_kind: "convex_workspace",
retained_for: "legacy cloud / remote callers still emitting documents.*",
retirement_condition: "all cloud and remote callers emit tree.subtree.move",
},
TreeCompatAliasCatalogEntry {
compat_command: "documents.delete",
preferred_command: "tree.node.archive",
source_kind: "convex_workspace",
retained_for: "legacy cloud / remote callers still emitting documents.*",
retirement_condition: "all cloud and remote callers emit tree.node.archive",
},
TreeCompatAliasCatalogEntry {
compat_command: "documents.restore",
preferred_command: "tree.node.restore",
source_kind: "convex_workspace",
retained_for: "legacy cloud / remote callers still emitting documents.*",
retirement_condition: "all cloud and remote callers emit tree.node.restore",
},
TreeCompatAliasCatalogEntry {
compat_command: "documents.purge",
preferred_command: "tree.node.purge",
source_kind: "convex_workspace",
retained_for: "legacy cloud / remote callers still emitting documents.*",
retirement_condition: "all cloud and remote callers emit tree.node.purge",
},
TreeCompatAliasCatalogEntry {
compat_command: "documents.copy_tree",
preferred_command: "tree.subtree.copy",
source_kind: "convex_workspace",
retained_for: "legacy cloud / remote callers still emitting documents.*",
retirement_condition: "all cloud and remote callers emit tree.subtree.copy",
},
];
#[derive(Debug)]
pub enum TreeCommandRequest {
Create {
@@ -2074,7 +2137,7 @@ async fn resolve_tree_create_workspace_id(
if let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) {
let parent_meta =
fetch_documents_meta_via_convex(state.config(), context, None, parent_id).await?;
fetch_documents_meta_via_legacy_cloud(state.config(), context, None, parent_id).await?;
if let Some(workspace_id) = parent_meta
.get("workspace_id")
.or_else(|| parent_meta.get("workspaceId"))
@@ -2086,7 +2149,7 @@ async fn resolve_tree_create_workspace_id(
}
}
let bootstrap = execute_convex_mutation_by_name(
let bootstrap = execute_retired_mutation_by_name(
state.config(),
context,
"workspaces:ensureDefaultWorkspace",
@@ -2201,7 +2264,15 @@ pub async fn tree_command(
.with_context(&context)
.with_header("x-error-phase", "tree_command_decode")
})?;
let envelope_context = TreeCommandEnvelopeContext::from_envelope(&raw_request);
let mut envelope_context = TreeCommandEnvelopeContext::from_envelope(&raw_request);
if envelope_context.source_kind.is_none()
&& envelope_context
.root_uri
.as_deref()
.is_some_and(|root_uri| root_uri.trim().starts_with("file://"))
{
envelope_context.source_kind = Some("local_folder".into());
}
let request = match raw_request.action.trim() {
"create" => TreeCommandRequest::Create {
workspace_id: raw_request.workspace_id,
@@ -2453,7 +2524,7 @@ pub async fn tree_command(
command_wire.preflight_data =
Some(load_tree_move_preflight_data(&state, &context, &effective_workspace_id).await?);
}
let execution = execute_runtime_command_via_convex_with_artifacts(
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
Some(&effective_workspace_id),
@@ -2513,10 +2584,10 @@ mod tests {
include_str!("../../browser/tree-shell-filetree-dnd-runtime.js");
use super::{
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
TreeCommandRequest,
TREE_DOCUMENT_COMPAT_ALIAS_CATALOG, TreeCommandEnvelopeContext, TreeCommandRequest,
collect_filetree_render_rows, create_command_wire,
};
use crate::app::{build_app, AppConfig, AppState};
use crate::app::{AppConfig, AppState, build_app};
use crate::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body;
@@ -2540,7 +2611,7 @@ mod tests {
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"table","file_name":"预算.table","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"},"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:purge":{"ok":true,"deletedCount":1},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
@@ -2681,14 +2752,20 @@ mod tests {
assert!(html.contains("data-rust-action=\"toggle\""));
assert!(html.contains("data-testid=\"tree-node-toggle\""));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree"));
assert!(TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")
);
assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally"));
assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally"));
assert!(TREE_SHELL_RUNTIME_JS
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
assert!(TREE_SHELL_RUNTIME_JS
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")
);
assert!(
TREE_SHELL_RUNTIME_JS
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")
);
assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到"));
assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")"));
@@ -2713,8 +2790,11 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("id=\"tree-shell-state\""));
assert!(html
.contains("type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""));
assert!(
html.contains(
"type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""
)
);
assert!(
!html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"),
"debug /tree runtime should live in browser/tree-shell-runtime.js, not inline Rust HTML"
@@ -2749,17 +2829,23 @@ mod tests {
assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction"));
assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost"));
assert!(TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })"));
assert!(TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")
);
assert!(
TREE_SHELL_RUNTIME_JS
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")
);
assert!(TREE_SHELL_RUNTIME_JS.contains("const shouldFocusDom = options.focusDom === true"));
assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement"));
assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom"));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree"));
assert!(html.contains("tabindex=\""));
assert!(TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
);
assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
}
@@ -2799,12 +2885,18 @@ mod tests {
assert!(
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
);
assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS
.contains("function buildFileTreeMenuTarget(context"));
assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS
.contains("function createTreeShellFileTreeDndRuntime(context)"));
assert!(TREE_SHELL_RENDER_RUNTIME_JS
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
assert!(
TREE_SHELL_FILETREE_MENU_RUNTIME_JS
.contains("function buildFileTreeMenuTarget(context")
);
assert!(
TREE_SHELL_FILETREE_DND_RUNTIME_JS
.contains("function createTreeShellFileTreeDndRuntime(context)")
);
assert!(
TREE_SHELL_RENDER_RUNTIME_JS
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")
);
assert!(
TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))")
);
@@ -2812,8 +2904,10 @@ mod tests {
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("documentId: ownerDocumentId || null"));
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover"));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree"));
assert!(TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
assert!(
TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
);
}
#[tokio::test]
@@ -3026,8 +3120,8 @@ mod tests {
}
#[tokio::test]
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint(
) {
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint()
{
let root =
std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
@@ -3142,11 +3236,12 @@ mod tests {
String::from_utf8_lossy(&move_body)
);
assert!(!root.join("重命名页面").exists());
assert!(root
.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists());
assert!(
root.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists()
);
let move_payload: Value = serde_json::from_slice(&move_body).expect("move json");
let moved_document_id = move_payload["result"]["documentId"]
.as_str()
@@ -3186,11 +3281,12 @@ mod tests {
.as_str()
.expect("copied document id")
.to_string();
assert!(root
.join("docs")
.join("重命名页面 2")
.join("重命名页面 2.md")
.exists());
assert!(
root.join("docs")
.join("重命名页面 2")
.join("重命名页面 2.md")
.exists()
);
let folder_response = app()
.oneshot(
@@ -3223,12 +3319,13 @@ mod tests {
.expect("response");
assert_eq!(delete_response.status(), StatusCode::OK);
assert!(!root.join("docs").join("重命名页面").exists());
assert!(root
.join(".mnote")
.join("trash")
.join("重命名页面")
.join("重命名页面.md")
.exists());
assert!(
root.join(".mnote")
.join("trash")
.join("重命名页面")
.join("重命名页面.md")
.exists()
);
assert!(root.join(".mnote").join("trash-index.json").exists());
assert!(!root.join(".mnote").join("page-ids.json").exists());
@@ -3255,11 +3352,12 @@ mod tests {
"{}",
String::from_utf8_lossy(&restore_body)
);
assert!(root
.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists());
assert!(
root.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists()
);
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
assert_eq!(
restore_payload["result"]["documentId"].as_str(),
@@ -3353,7 +3451,21 @@ mod tests {
)
.await
.expect("response");
assert_eq!(restore_response.status(), StatusCode::OK);
let restore_status = restore_response.status();
let restore_body = axum::body::to_bytes(restore_response.into_body(), usize::MAX)
.await
.expect("restore body");
assert_eq!(
restore_status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&restore_body)
);
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
assert_eq!(
restore_payload["result"]["execution"]["canonicalCommand"],
"tree.resource.restore"
);
assert!(root.join("docs").join("photo.png").exists());
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
@@ -3364,13 +3476,28 @@ mod tests {
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
r#"{{"action":"delete","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
)))
.expect("request"),
)
.await
.expect("response");
assert_eq!(delete_again_response.status(), StatusCode::OK);
let delete_again_status = delete_again_response.status();
let delete_again_body = axum::body::to_bytes(delete_again_response.into_body(), usize::MAX)
.await
.expect("delete again body");
assert_eq!(
delete_again_status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&delete_again_body)
);
let delete_again_payload: Value =
serde_json::from_slice(&delete_again_body).expect("delete again json");
assert_eq!(
delete_again_payload["result"]["execution"]["canonicalCommand"],
"tree.resource.archive"
);
let purge_response = app()
.oneshot(
@@ -3385,7 +3512,21 @@ mod tests {
)
.await
.expect("response");
assert_eq!(purge_response.status(), StatusCode::OK);
let purge_status = purge_response.status();
let purge_body = axum::body::to_bytes(purge_response.into_body(), usize::MAX)
.await
.expect("purge body");
assert_eq!(
purge_status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&purge_body)
);
let purge_payload: Value = serde_json::from_slice(&purge_body).expect("purge json");
assert_eq!(
purge_payload["result"]["execution"]["canonicalCommand"],
"tree.resource.purge"
);
assert!(!root.join("docs").join("photo.png").exists());
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
let trash_index_after =
@@ -3432,10 +3573,12 @@ mod tests {
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "local_folder_root_escape");
assert!(payload["message"]
.as_str()
.unwrap_or_default()
.contains("root"));
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("root")
);
assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0);
assert_eq!(
headers
@@ -3604,8 +3747,10 @@ mod tests {
assert!(filetree_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(filetree_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
assert!(
filetree_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
);
assert!(filetree_html.contains(
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
));
@@ -3633,8 +3778,10 @@ mod tests {
assert!(picker_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(picker_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
assert!(
picker_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
);
}
#[tokio::test]
@@ -4050,11 +4197,13 @@ mod tests {
Some("convex://workspace/ws_demo")
);
assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo"));
assert!(create_wire
.source
.capabilities
.iter()
.any(|capability| capability == "execute-command"));
assert!(
create_wire
.source
.capabilities
.iter()
.any(|capability| capability == "execute-command")
);
let rename_wire = create_command_wire(
&context,
@@ -4242,4 +4391,42 @@ mod tests {
assert_eq!(tree_move_plan.function_name, "documents:move");
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
}
#[test]
fn tree_documents_compat_alias_catalog_marks_cloud_retirement_boundary() {
let aliases = TREE_DOCUMENT_COMPAT_ALIAS_CATALOG;
assert!(
aliases
.iter()
.all(|entry| entry.compat_command.starts_with("documents."))
);
assert!(
aliases
.iter()
.all(|entry| entry.preferred_command.starts_with("tree."))
);
assert!(
aliases
.iter()
.all(|entry| entry.source_kind == "convex_workspace")
);
assert!(
aliases
.iter()
.all(|entry| entry.retained_for.contains("legacy cloud"))
);
assert!(
aliases
.iter()
.all(|entry| entry.retirement_condition.contains("emit tree."))
);
assert!(aliases.iter().any(|entry| {
entry.compat_command == "documents.delete"
&& entry.preferred_command == "tree.node.archive"
}));
assert!(aliases.iter().any(|entry| {
entry.compat_command == "documents.copy_tree"
&& entry.preferred_command == "tree.subtree.copy"
}));
}
}