2026-05-20 21:45:11 +08:00
#!/usr/bin/env node
"use strict" ;
2026-07-28 17:04:27 +08:00
const { loginViaAuthForm } = require ( './lib/browser-auth-login' );
2026-05-20 21:45:11 +08:00
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 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 , secondaryRelativePath = "" ) {
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" );
if ( secondaryRelativePath ) {
url . searchParams . set ( "secondaryDocumentId" , localMdDocumentId ( secondaryRelativePath ));
url . searchParams . set ( "secondarySourceKind" , "local_folder" );
url . searchParams . set ( "secondaryRootUri" , fileUrl ( root ));
}
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 } :task472` ,
ownerId ,
createdAt : new Date (). toISOString (),
capabilities : [ "local_files" , "markdown_edit" , "asset_upload" ],
} , null, 2)}\n` ,
"utf8" ,
);
}
async function quickLogin ( page ) {
2026-07-28 17:04:27 +08:00
// 7-76 P0: 标准表单登录(无测试快速登录按钮)
const base =
( typeof BASE_URL !== "undefined" && BASE_URL ) ||
( typeof baseUrl !== "undefined" && baseUrl ) ||
process . env . MNOTE_UI_BASE_URL ||
"http://127.0.0.1:3000" ;
const timeout =
( typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS ) ||
( typeof TIMEOUT !== "undefined" && TIMEOUT ) ||
30_000 ;
if ( ! String ( page . url () || "" ). includes ( "/auth" )) {
await page . goto ( String ( base ). replace ( /\/+$/ , "" ) + "/auth" , {
waitUntil : "commit" ,
timeout ,
});
2026-05-20 21:45:11 +08:00
}
2026-07-28 17:04:27 +08:00
await loginViaAuthForm ( page , {
baseUrl : base ,
timeoutMs : timeout ,
gotoAuth : false ,
});
await page
. waitForURL (( url ) => ! String ( url ). includes ( "/auth" ), { timeout })
. catch (() => {});
2026-05-20 21:45:11 +08:00
}
async function uploadLocalAsset ( page , root , documentId , fileName , mimeType , bytes , kind ) {
return await page . evaluate (
async ({ rootUri , documentId , fileName , mimeType , bytes , kind }) => {
const form = new FormData ();
form . append ( "rootUri" , rootUri );
form . append ( "documentId" , documentId );
2026-05-24 23:43:22 +08:00
form . append ( "uploadIntent" , "editor.markdown.attach" );
2026-05-20 21:45:11 +08:00
form . append ( "kind" , kind );
form . append ( "file" , new File ([ new Uint8Array ( bytes )], fileName , { type : mimeType }));
const response = await fetch ( "/api/local-folder/assets/upload" , { method : "POST" , body : form });
const payload = await response . json (). catch (() => null );
if ( ! response . ok || ! payload || payload . ok !== true ) {
throw new Error ( `upload_failed_ ${ response . status } : ${ JSON . stringify ( payload ) } ` );
}
return payload . asset ;
},
{ rootUri : fileUrl ( root ), documentId , fileName , mimeType , bytes : Array . from ( bytes ), kind },
);
}
2026-05-23 23:38:42 +08:00
async function uploadAttachmentViaSecondarySlash ( page , fileName , markdown , action ) {
const editor = page
. locator ( '.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror' )
. first ();
await editor . click ({ timeout : UI_TIMEOUT_MS });
await page . keyboard . press ( "End" ). catch (() => undefined );
await page . keyboard . type ( "/" );
const item = page
. locator ( '.mnote-resource-tab-panel[data-pane-role="secondary"] [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 ( markdown , "utf8" ),
});
await page . waitForFunction (
({ name }) => {
const editor = document . querySelector ( '.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror' );
return ( editor instanceof HTMLElement && ( editor . textContent || "" ). includes ( name ))
|| document . documentElement . getAttribute ( "data-mnote-last-upload-inserted" ) === "false" ;
},
{ name : fileName },
{ timeout : UI_TIMEOUT_MS },
);
const inserted = await page . evaluate (( name ) => {
const editor = document . querySelector ( '.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror' );
return {
hasText : editor instanceof HTMLElement && ( editor . textContent || "" ). includes ( name ),
inserted : document . documentElement . getAttribute ( "data-mnote-last-upload-inserted" ) || "" ,
error : document . documentElement . getAttribute ( "data-mnote-last-upload-insert-error" ) || "" ,
text : editor ? . textContent || "" ,
};
}, fileName );
assert ( inserted . hasText , ` ${ action } 上传后未插入 secondary 编辑器: ${ JSON . stringify ( inserted ) } ` );
}
2026-05-20 21:45:11 +08:00
async function waitForPrimaryReady ( page ) {
await page . locator ( ".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror" ). first (). waitFor ({
state : "visible" ,
timeout : UI_TIMEOUT_MS ,
});
}
async function waitForSecondaryDocument ( page , expectedDocumentId ) {
await page . waitForFunction (
({ expected }) => {
const pane = document . querySelector ( '.document-pane[data-pane-role="secondary"]' );
const editor = pane ? . querySelector ( ".editor-surface .ProseMirror" );
return pane instanceof HTMLElement
&& pane . getAttribute ( "data-pane-visible" ) === "true"
&& pane . getAttribute ( "data-pane-document-id" ) === expected
&& editor instanceof HTMLElement
&& editor . isContentEditable ;
},
{ expected : expectedDocumentId },
{ timeout : UI_TIMEOUT_MS },
);
}
async function readSideTargetState ( page ) {
return await page . evaluate (() => {
const url = new URL ( window . location . href );
const pane = document . querySelector ( '.document-pane[data-pane-role="secondary"]' );
2026-05-23 23:38:42 +08:00
const activeTab = document . querySelector ( '.mnote-main-tab.is-active[data-pane-role="primary"]' );
2026-05-20 21:45:11 +08:00
const placeholder = document . querySelector ( "[data-mnote-side-target-placeholder=\"true\"]" );
return {
resourceTab : url . searchParams . get ( "resourceTab" ) || "" ,
secondaryDocumentId : url . searchParams . get ( "secondaryDocumentId" ),
secondarySourceKind : url . searchParams . get ( "secondarySourceKind" ),
secondaryRootUri : url . searchParams . get ( "secondaryRootUri" ),
secondaryVisible : pane instanceof HTMLElement && pane . getAttribute ( "data-pane-visible" ) === "true" && ! pane . hidden ,
secondaryDocumentDomId : pane ? . getAttribute ( "data-pane-document-id" ) || "" ,
secondarySideTarget : pane ? . getAttribute ( "data-mnote-side-target" ) || "" ,
activeTabKind : activeTab ? . getAttribute ( "data-mnote-tab-kind" ) || "" ,
activeTabText : activeTab ? . textContent || "" ,
2026-05-23 23:38:42 +08:00
secondaryActiveTabKind : document . querySelector ( '.mnote-main-tab.is-active[data-pane-role="secondary"]' ) ? . getAttribute ( "data-mnote-tab-kind" ) || "" ,
secondaryActiveTabText : document . querySelector ( '.mnote-main-tab.is-active[data-pane-role="secondary"]' ) ? . textContent || "" ,
secondaryResourceText : document . querySelector ( '.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror' ) ? . textContent || "" ,
primaryVisibleEditorText : document . querySelector ( '.document-pane[data-pane-role="primary"] .mnote-resource-tab-panel:not([hidden]) .ProseMirror, .document-pane[data-pane-role="primary"] [data-mnote-page-tab-panel]:not([hidden]) .ProseMirror' ) ? . textContent || "" ,
secondaryOfficeFrameSrc : document . querySelector ( '.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) iframe.mnote-resource-tab-frame' ) ? . getAttribute ( "src" ) || "" ,
primarySlashVisible : Array . from ( document . querySelectorAll ( '.document-pane[data-pane-role="primary"] [data-testid="mnote-leptos-tiptap-slash-menu"]' )). some (( node ) => node instanceof HTMLElement && getComputedStyle ( node ). display !== "none" ),
secondarySlashVisible : Array . from ( document . querySelectorAll ( '.document-pane[data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]' )). some (( node ) => node instanceof HTMLElement && getComputedStyle ( node ). display !== "none" ),
2026-05-20 21:45:11 +08:00
placeholderText : placeholder ? . textContent || "" ,
unsupportedFlag : document . documentElement . getAttribute ( "data-mnote-side-target-unsupported" ) || "" ,
popupCount : window . __mnoteSideTargetPopupCount || 0 ,
};
});
}
2026-05-23 23:38:42 +08:00
async function waitForDiskTextContains ( filePath , expectedNames ) {
const deadline = Date . now () + UI_TIMEOUT_MS ;
while ( Date . now () < deadline ) {
const text = fs . existsSync ( filePath ) ? fs . readFileSync ( filePath , "utf8" ) : "" ;
if ( expectedNames . every (( name ) => text . includes ( name ))) return text ;
await new Promise (( resolve ) => setTimeout ( resolve , 150 ));
}
const text = fs . existsSync ( filePath ) ? fs . readFileSync ( filePath , "utf8" ) : "" ;
throw new Error ( `等待资源文件保存超时: ${ filePath } text= ${ JSON . stringify ( text ) } ` );
}
async function waitForSecondaryAttachmentLinks ( page , expectedNames ) {
await page . waitForFunction (
({ names }) => {
const editor = document . querySelector ( '.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror' );
if ( ! ( editor instanceof HTMLElement )) return false ;
return names . every (( name ) => {
const link = Array . from ( editor . querySelectorAll ( "a[href]" ))
. find (( node ) => ( node . textContent || "" ). includes ( name ));
if ( ! ( link instanceof HTMLAnchorElement )) return false ;
const href = link . getAttribute ( "href" ) || "" ;
const className = link . getAttribute ( "class" ) || "" ;
const styledAsAttachment = getComputedStyle ( link ). display === "inline-flex" ;
const enhancedAsAttachment = link . getAttribute ( "data-mnote-attachment-link" ) === "true"
|| className . includes ( "mnote-uploaded-attachment-row" );
return href . includes ( "/api/local-folder/files/open" )
&& ( styledAsAttachment || enhancedAsAttachment );
});
},
{ names : expectedNames },
{ timeout : UI_TIMEOUT_MS },
);
}
async function clickSecondaryAttachment ( page , fileName ) {
await page . evaluate (( name ) => {
const editor = document . querySelector ( '.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror' );
const link = Array . from ( editor ? . querySelectorAll ( 'a[href*="/api/local-folder/files/open"], a[data-mnote-attachment-link="true"]' ) || [])
. find (( node ) => ( node . textContent || "" ). includes ( name ));
if ( ! ( link instanceof HTMLAnchorElement )) {
throw new Error ( `secondary_attachment_link_missing: ${ name } ` );
}
link . dispatchEvent ( new MouseEvent ( "mousedown" , { bubbles : true , cancelable : true , view : window }));
link . dispatchEvent ( new MouseEvent ( "click" , { bubbles : true , cancelable : true , view : window }));
}, fileName );
}
2026-05-20 21:45:11 +08:00
async function main () {
const root = fs . mkdtempSync ( path . join ( os . tmpdir (), "mnote-task472-side-target-" ));
const relativePath = "README.md" ;
const firstSideRelativePath = "Side.md" ;
const secondSideRelativePath = "Second.md" ;
const documentId = localMdDocumentId ( relativePath );
const firstSideDocumentId = localMdDocumentId ( firstSideRelativePath );
const secondSideDocumentId = localMdDocumentId ( secondSideRelativePath );
writeWorkspaceManifest ( root , "user_real" );
fs . writeFileSync ( path . join ( root , relativePath ), "# Page\n\n主页面\n" , "utf8" );
fs . writeFileSync ( path . join ( root , firstSideRelativePath ), "# Side\n\n第一侧栏\n" , "utf8" );
fs . writeFileSync ( path . join ( root , secondSideRelativePath ), "# Second\n\n第二侧栏\n" , "utf8" );
const browser = await chromium . launch ({
headless : true ,
executablePath : CHROMIUM_EXECUTABLE_PATH ,
});
const context = await browser . newContext ({
viewport : { width : 1360 , height : 900 },
extraHTTPHeaders : {
"x-mnote-actor-id" : "user_real" ,
"x-mnote-actor-type" : "user" ,
},
});
await context . addInitScript (() => {
window . __mnoteSideTargetPopupCount = 0 ;
const originalOpen = window . open ;
window . open = function (... args ) {
window . __mnoteSideTargetPopupCount += 1 ;
return originalOpen . apply ( window , args );
};
});
const page = await context . newPage ();
try {
await quickLogin ( page );
await page . goto ( documentUrl ( root , relativePath , firstSideRelativePath ), { waitUntil : "domcontentloaded" , timeout : UI_TIMEOUT_MS });
await waitForPrimaryReady ( page );
await waitForSecondaryDocument ( page , firstSideDocumentId );
const asset = await uploadLocalAsset (
page ,
root ,
documentId ,
"side-target-resource.md" ,
"text/markdown" ,
Buffer . from ( "# Resource\n\n资源正文\n" , "utf8" ),
"attachment" ,
);
2026-05-23 23:38:42 +08:00
const officeAsset = await uploadLocalAsset (
page ,
root ,
documentId ,
"side-target-office.docx" ,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" ,
Buffer . from ( "task472 secondary office probe" , "utf8" ),
"attachment" ,
);
2026-05-20 21:45:11 +08:00
await page . evaluate (({ asset , documentId }) => {
window . dispatchEvent ( new CustomEvent ( "tree.asset.open" , {
detail : {
assetId : asset . id ,
documentId ,
title : asset . file_name || "side-target-resource.md" ,
assetType : asset . asset_type || "attachment" ,
},
}));
}, { asset , documentId });
await page . locator ( '.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"]' ). waitFor ({
state : "visible" ,
timeout : UI_TIMEOUT_MS ,
});
const activeResourceBeforeSide = await readSideTargetState ( page );
assert ( activeResourceBeforeSide . resourceTab . includes ( "side-target-resource.md" ), `active resource tab 应写入 URL: ${ JSON . stringify ( activeResourceBeforeSide ) } ` );
assert . equal ( activeResourceBeforeSide . secondaryDocumentId , firstSideDocumentId , `资源 tab 不应清理既有 secondaryDocumentId: ${ JSON . stringify ( activeResourceBeforeSide ) } ` );
await page . evaluate (({ documentId }) => {
window . dispatchEvent ( new CustomEvent ( "tree.page.open" , {
detail : {
documentId ,
openTarget : "side" ,
},
}));
}, { documentId : secondSideDocumentId });
await waitForSecondaryDocument ( page , secondSideDocumentId );
const afterDocumentSideOpen = await readSideTargetState ( page );
assert . equal ( afterDocumentSideOpen . secondaryDocumentId , secondSideDocumentId , `document openTarget=side 应更新 secondaryDocumentId: ${ JSON . stringify ( afterDocumentSideOpen ) } ` );
assert . equal ( afterDocumentSideOpen . secondarySourceKind , "local_folder" , `document openTarget=side 应维护 secondarySourceKind: ${ JSON . stringify ( afterDocumentSideOpen ) } ` );
assert . equal ( afterDocumentSideOpen . secondaryRootUri , fileUrl ( root ), `document openTarget=side 应维护 secondaryRootUri: ${ JSON . stringify ( afterDocumentSideOpen ) } ` );
assert ( afterDocumentSideOpen . resourceTab . includes ( "side-target-resource.md" ), `更新 secondary pane 不应清空 active resource tab URL: ${ JSON . stringify ( afterDocumentSideOpen ) } ` );
assert . equal ( afterDocumentSideOpen . activeTabKind , "markdown" , `active resource tab 与 secondary pane 应同时存在: ${ JSON . stringify ( afterDocumentSideOpen ) } ` );
await page . locator ( '[data-mnote-pane-close="secondary"]' ). first (). click ({ timeout : UI_TIMEOUT_MS });
await page . waitForTimeout ( 250 );
const afterCloseSecondary = await readSideTargetState ( page );
assert . equal ( afterCloseSecondary . secondaryDocumentId , null , `关闭 secondary pane 应清理 secondaryDocumentId: ${ JSON . stringify ( afterCloseSecondary ) } ` );
assert . equal ( afterCloseSecondary . secondarySourceKind , null , `关闭 secondary pane 应清理 secondarySourceKind: ${ JSON . stringify ( afterCloseSecondary ) } ` );
assert . equal ( afterCloseSecondary . secondaryRootUri , null , `关闭 secondary pane 应清理 secondaryRootUri: ${ JSON . stringify ( afterCloseSecondary ) } ` );
assert ( afterCloseSecondary . resourceTab . includes ( "side-target-resource.md" ), `关闭 secondary pane 不应清空 active resource tab URL: ${ JSON . stringify ( afterCloseSecondary ) } ` );
assert . equal ( afterCloseSecondary . activeTabKind , "markdown" , `关闭 secondary pane 不应切走 active resource tab: ${ JSON . stringify ( afterCloseSecondary ) } ` );
await page . evaluate (({ asset , documentId }) => {
window . dispatchEvent ( new CustomEvent ( "tree.asset.open" , {
detail : {
assetId : asset . id ,
documentId ,
title : asset . file_name || "side-target-resource.md" ,
assetType : asset . asset_type || "attachment" ,
openTarget : "side" ,
},
}));
}, { asset , documentId });
2026-05-23 23:38:42 +08:00
await page . locator ( '.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]' ). waitFor ({
state : "visible" ,
timeout : UI_TIMEOUT_MS ,
});
await page . locator ( '.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror' ). first (). waitFor ({
state : "visible" ,
timeout : UI_TIMEOUT_MS ,
});
2026-05-20 21:45:11 +08:00
const afterResourceSideOpen = await readSideTargetState ( page );
2026-05-23 23:38:42 +08:00
assert . equal ( afterResourceSideOpen . secondaryDocumentId , null , `资源 openTarget=side 不应保留旧 secondaryDocumentId: ${ JSON . stringify ( afterResourceSideOpen ) } ` );
assert . equal ( afterResourceSideOpen . secondaryActiveTabKind , "markdown" , `资源 openTarget=side 应在 secondary 资源标签打开 markdown: ${ JSON . stringify ( afterResourceSideOpen ) } ` );
assert ( afterResourceSideOpen . secondaryActiveTabText . includes ( "side-target-resource.md" ), `secondary 资源标签标题应可观测: ${ JSON . stringify ( afterResourceSideOpen ) } ` );
assert ( afterResourceSideOpen . secondaryResourceText . includes ( "资源正文" ), `secondary 资源标签应渲染附件内容: ${ JSON . stringify ( afterResourceSideOpen ) } ` );
assert ( afterResourceSideOpen . resourceTab . includes ( "side-target-resource.md" ), `资源 side open 不应清空 primary active resource tab URL: ${ JSON . stringify ( afterResourceSideOpen ) } ` );
assert . equal ( afterResourceSideOpen . activeTabKind , "markdown" , `资源 side open 不应切走 primary active resource tab: ${ JSON . stringify ( afterResourceSideOpen ) } ` );
2026-05-20 21:45:11 +08:00
assert . equal ( afterResourceSideOpen . popupCount , 0 , `资源 openTarget=side 不应误开新窗口: ${ JSON . stringify ( afterResourceSideOpen ) } ` );
2026-05-23 23:38:42 +08:00
const secondaryResourceEditor = page . locator ( '.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror' ). first ();
await secondaryResourceEditor . click ({ timeout : UI_TIMEOUT_MS });
await page . waitForTimeout ( 150 );
const afterSecondaryFirstClick = await readSideTargetState ( page );
assert . equal ( afterSecondaryFirstClick . primarySlashVisible , false , `secondary 首次点击资源正文不应让 primary 菜单闪现: ${ JSON . stringify ( afterSecondaryFirstClick ) } ` );
2026-05-20 21:45:11 +08:00
2026-05-23 23:38:42 +08:00
await uploadAttachmentViaSecondarySlash (
page ,
"secondary-real-upload-1.md" ,
"# Upload One\n\n第一个真实上传\n" ,
"secondary 第一个真实 md" ,
);
await page . waitForTimeout ( 250 );
const afterFirstRealSecondaryUpload = await readSideTargetState ( page );
if ( ! afterFirstRealSecondaryUpload . secondaryResourceText . includes ( "secondary-real-upload-1.md" )) {
const debug = await page . evaluate (() => {
const root = document . querySelector ( '.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) [data-testid="mnote-leptos-tiptap-island-editor-root"]' );
const editor = root ? . querySelector ( ".editor-surface .ProseMirror" );
return {
rootKind : root instanceof HTMLElement ? root . getAttribute ( "data-editor-host-kind" ) : "" ,
rootDocumentId : root instanceof HTMLElement ? root . getAttribute ( "data-document-id" ) : "" ,
rootWorkspaceId : root instanceof HTMLElement ? root . getAttribute ( "data-workspace-id" ) : "" ,
rootStatus : root instanceof HTMLElement ? root . getAttribute ( "data-runtime-editor-status" ) : "" ,
hasEditorHandle : Boolean ( editor ? . editor ? . chain ),
lastRootKind : window . __mnoteLastEditorUploadRoot instanceof HTMLElement ? window . __mnoteLastEditorUploadRoot . getAttribute ( "data-editor-host-kind" ) : "" ,
lastRootPane : window . __mnoteLastEditorUploadRoot instanceof HTMLElement ? window . __mnoteLastEditorUploadRoot . getAttribute ( "data-pane-role" ) : "" ,
visibleResourceEditors : Array . from ( document . querySelectorAll ( '.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) .ProseMirror' )). map (( node ) => ({
text : node . textContent ,
hasEditorHandle : Boolean ( node . editor ? . chain ),
})),
};
});
throw new Error ( `secondary 真实上传第一个 md 未插入 secondary 资源编辑器,debug= ${ JSON . stringify ( debug ) } state= ${ JSON . stringify ( afterFirstRealSecondaryUpload ) } ` );
}
assert ( afterFirstRealSecondaryUpload . secondaryResourceText . includes ( "secondary-real-upload-1.md" ), `secondary 真实上传第一个 md 应插入 secondary 资源编辑器: ${ JSON . stringify ( afterFirstRealSecondaryUpload ) } ` );
assert ( ! afterFirstRealSecondaryUpload . primaryVisibleEditorText . includes ( "secondary-real-upload-1.md" ), `secondary 真实上传第一个 md 不应插入 primary 编辑器: ${ JSON . stringify ( afterFirstRealSecondaryUpload ) } ` );
assert . equal ( afterFirstRealSecondaryUpload . primarySlashVisible , false , `secondary 真实上传第一个 md 后 primary 菜单不应可见: ${ JSON . stringify ( afterFirstRealSecondaryUpload ) } ` );
await uploadAttachmentViaSecondarySlash (
page ,
"secondary-real-upload-2.md" ,
"# Upload Two\n\n第二个真实上传\n" ,
"secondary 第二个真实 md" ,
);
await page . waitForTimeout ( 250 );
const afterSecondRealSecondaryUpload = await readSideTargetState ( page );
assert ( afterSecondRealSecondaryUpload . secondaryResourceText . includes ( "secondary-real-upload-2.md" ), `secondary 真实上传第二个 md 应插入 secondary 资源编辑器: ${ JSON . stringify ( afterSecondRealSecondaryUpload ) } ` );
assert ( ! afterSecondRealSecondaryUpload . primaryVisibleEditorText . includes ( "secondary-real-upload-2.md" ), `secondary 真实上传第二个 md 不应插入 primary 编辑器: ${ JSON . stringify ( afterSecondRealSecondaryUpload ) } ` );
assert . equal ( afterSecondRealSecondaryUpload . primarySlashVisible , false , `secondary 真实上传第二个 md 后 primary 菜单不应可见: ${ JSON . stringify ( afterSecondRealSecondaryUpload ) } ` );
const sideResourceDiskPath = path . join ( root , "README" , "side-target-resource.md" );
await waitForDiskTextContains ( sideResourceDiskPath , [
"secondary-real-upload-1.md" ,
"secondary-real-upload-2.md" ,
]);
await page . reload ({ waitUntil : "domcontentloaded" , timeout : UI_TIMEOUT_MS });
await waitForPrimaryReady ( page );
await page . evaluate (({ asset , documentId }) => {
window . dispatchEvent ( new CustomEvent ( "tree.asset.open" , {
detail : {
assetId : asset . id ,
documentId ,
title : asset . file_name || "side-target-resource.md" ,
assetType : asset . asset_type || "attachment" ,
openTarget : "side" ,
},
}));
}, { asset , documentId });
await page . locator ( '.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror' ). first (). waitFor ({
state : "visible" ,
timeout : UI_TIMEOUT_MS ,
});
await waitForSecondaryAttachmentLinks ( page , [
"secondary-real-upload-1.md" ,
"secondary-real-upload-2.md" ,
]);
await clickSecondaryAttachment ( page , "secondary-real-upload-1.md" );
await page . locator ( '.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]' ). waitFor ({
state : "visible" ,
timeout : UI_TIMEOUT_MS ,
});
const afterReloadFirstAttachmentClick = await readSideTargetState ( page );
assert ( afterReloadFirstAttachmentClick . secondaryActiveTabText . includes ( "secondary-real-upload-1.md" ), `刷新后第一个 md 附件应在 secondary tab 打开: ${ JSON . stringify ( afterReloadFirstAttachmentClick ) } ` );
assert . equal ( afterReloadFirstAttachmentClick . popupCount , 0 , `刷新后第一个 md 附件不应新开窗口: ${ JSON . stringify ( afterReloadFirstAttachmentClick ) } ` );
await page . evaluate (({ asset , documentId }) => {
window . dispatchEvent ( new CustomEvent ( "tree.asset.open" , {
detail : {
assetId : asset . id ,
documentId ,
title : asset . file_name || "side-target-resource.md" ,
assetType : asset . asset_type || "attachment" ,
openTarget : "side" ,
},
}));
}, { asset , documentId });
await waitForSecondaryAttachmentLinks ( page , [
"secondary-real-upload-1.md" ,
"secondary-real-upload-2.md" ,
]);
await clickSecondaryAttachment ( page , "secondary-real-upload-2.md" );
await page . locator ( '.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]' ). waitFor ({
state : "visible" ,
timeout : UI_TIMEOUT_MS ,
});
const afterReloadSecondAttachmentClick = await readSideTargetState ( page );
assert ( afterReloadSecondAttachmentClick . secondaryActiveTabText . includes ( "secondary-real-upload-2.md" ), `刷新后第二个 md 附件应在 secondary tab 打开: ${ JSON . stringify ( afterReloadSecondAttachmentClick ) } ` );
assert . equal ( afterReloadSecondAttachmentClick . popupCount , 0 , `刷新后第二个 md 附件不应新开窗口: ${ JSON . stringify ( afterReloadSecondAttachmentClick ) } ` );
fs . writeFileSync ( path . join ( root , "Second-resource.md" ), "# Second Resource\n\n第二个资源正文\n" , "utf8" );
const secondAsset = await uploadLocalAsset (
page ,
root ,
documentId ,
"Second-resource.md" ,
"text/markdown" ,
Buffer . from ( "# Second Resource\n\n第二个资源正文\n" , "utf8" ),
"attachment" ,
);
await page . evaluate (({ secondAsset , documentId }) => {
window . dispatchEvent ( new CustomEvent ( "tree.asset.open" , {
detail : {
assetId : secondAsset . id ,
documentId ,
title : secondAsset . file_name || "Second-resource.md" ,
assetType : secondAsset . asset_type || "attachment" ,
openTarget : "side" ,
},
}));
}, { secondAsset , documentId });
await page . locator ( '.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="markdown"]' ). waitFor ({
state : "visible" ,
timeout : UI_TIMEOUT_MS ,
});
await page . locator ( '.mnote-resource-tab-panel[data-pane-role="secondary"][data-resource-kind="markdown"]:not([hidden]) .ProseMirror' ). first (). waitFor ({
state : "visible" ,
timeout : UI_TIMEOUT_MS ,
});
const afterSecondResourceSideOpen = await readSideTargetState ( page );
assert ( afterSecondResourceSideOpen . secondaryActiveTabText . includes ( "Second-resource.md" ), `secondary 第二个 md 资源应切到新标签: ${ JSON . stringify ( afterSecondResourceSideOpen ) } ` );
assert ( afterSecondResourceSideOpen . secondaryResourceText . includes ( "第二个资源正文" ), `secondary 第二个 md 资源应渲染新正文: ${ JSON . stringify ( afterSecondResourceSideOpen ) } ` );
assert . equal ( afterSecondResourceSideOpen . secondaryActiveTabKind , "markdown" , `secondary 第二个 md 资源不应回到 primary: ${ JSON . stringify ( afterSecondResourceSideOpen ) } ` );
assert . equal ( afterSecondResourceSideOpen . popupCount , 0 , `secondary 第二个 md 资源不应误开新窗口: ${ JSON . stringify ( afterSecondResourceSideOpen ) } ` );
await page . evaluate (({ officeAsset , documentId }) => {
window . dispatchEvent ( new CustomEvent ( "tree.asset.open" , {
detail : {
assetId : officeAsset . id ,
documentId ,
title : officeAsset . file_name || "side-target-office.docx" ,
assetType : officeAsset . asset_type || "attachment" ,
openTarget : "side" ,
},
}));
}, { officeAsset , documentId });
await page . locator ( '.mnote-main-tab.is-active[data-pane-role="secondary"][data-mnote-tab-kind="office"]' ). waitFor ({
state : "visible" ,
timeout : UI_TIMEOUT_MS ,
});
await page . locator ( '.mnote-resource-tab-panel[data-pane-role="secondary"]:not([hidden]) iframe.mnote-resource-tab-frame' ). first (). waitFor ({
state : "visible" ,
timeout : UI_TIMEOUT_MS ,
});
const afterOfficeSideOpen = await readSideTargetState ( page );
assert . equal ( afterOfficeSideOpen . secondaryDocumentId , null , `Office openTarget=side 不应恢复 secondaryDocumentId: ${ JSON . stringify ( afterOfficeSideOpen ) } ` );
assert . equal ( afterOfficeSideOpen . secondaryActiveTabKind , "office" , `Office openTarget=side 应在 secondary 资源标签打开 office: ${ JSON . stringify ( afterOfficeSideOpen ) } ` );
assert ( afterOfficeSideOpen . secondaryActiveTabText . includes ( "side-target-office.docx" ), `secondary Office 标签标题应可观测: ${ JSON . stringify ( afterOfficeSideOpen ) } ` );
assert ( afterOfficeSideOpen . secondaryOfficeFrameSrc , `secondary Office 应创建 iframe: ${ JSON . stringify ( afterOfficeSideOpen ) } ` );
const secondaryOfficeFrameUrl = new URL ( afterOfficeSideOpen . secondaryOfficeFrameSrc , BASE_URL );
assert . equal ( secondaryOfficeFrameUrl . pathname , "/onlyoffice" , `secondary Office iframe 应指向 /onlyoffice: ${ JSON . stringify ( afterOfficeSideOpen ) } ` );
assert . equal ( secondaryOfficeFrameUrl . searchParams . get ( "assetId" ), officeAsset . id , `secondary Office iframe 应携带 assetId: ${ JSON . stringify ( afterOfficeSideOpen ) } ` );
assert . equal ( secondaryOfficeFrameUrl . searchParams . get ( "mode" ), "view" , `secondary Office iframe 应使用 view 模式: ${ JSON . stringify ( afterOfficeSideOpen ) } ` );
assert . equal ( afterOfficeSideOpen . popupCount , 0 , `Office openTarget=side 不应误开新窗口: ${ JSON . stringify ( afterOfficeSideOpen ) } ` );
console . log ( JSON . stringify ({ ok : true , root , assetId : asset . id , officeAssetId : officeAsset . id }, null , 2 ));
2026-05-20 21:45:11 +08:00
} finally {
await context . close (). catch (() => undefined );
await browser . close (). catch (() => undefined );
}
}
main (). catch (( error ) => {
console . error ( error && error . stack ? error . stack : error );
process . exit ( 1 );
});