Close workbench P0 resource lifecycle gaps
This commit is contained in:
@@ -255,6 +255,22 @@ async function main() {
|
||||
});
|
||||
assert.equal(activeAfterOfficeClose.kind, "markdown", `关闭 office tab 后应回到 markdown tab: ${JSON.stringify(activeAfterOfficeClose)}`);
|
||||
assert(activeAfterOfficeClose.text.includes("resource-note.md"), `markdown tab 标题应包含资源名: ${JSON.stringify(activeAfterOfficeClose)}`);
|
||||
const markdownActiveState = await page.evaluate(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const activeResource = url.searchParams.get("resourceTab") || "";
|
||||
const activeRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]');
|
||||
const resourceRows = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id]')).map((row) => row.getAttribute("data-asset-id") || "");
|
||||
return {
|
||||
activeResource,
|
||||
activeRowAssetId: activeRow?.getAttribute("data-asset-id") || "",
|
||||
resourceRows,
|
||||
selectedRows: Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]')).map((row) => row.getAttribute("data-asset-id") || row.getAttribute("data-row-id") || ""),
|
||||
};
|
||||
});
|
||||
assert(markdownActiveState.activeResource.includes("resource-note.md"), `激活 resource tab 应写入 canonical resourceTab URL 状态: ${JSON.stringify(markdownActiveState)}`);
|
||||
if (markdownActiveState.resourceRows.includes(asset.id)) {
|
||||
assert.equal(markdownActiveState.activeRowAssetId, asset.id, `已渲染对应 filetree row 时应标记 data-active: ${JSON.stringify(markdownActiveState)}`);
|
||||
}
|
||||
|
||||
const mdCloseBtn = page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"] .mnote-main-tab-close');
|
||||
await mdCloseBtn.click({ timeout: UI_TIMEOUT_MS });
|
||||
@@ -270,6 +286,7 @@ async function main() {
|
||||
assert.equal(activeAfterAllClosed.kind, "page", `关闭所有 resource tab 后应回到 page tab: ${JSON.stringify(activeAfterAllClosed)}`);
|
||||
assert.equal(activeAfterAllClosed.resourceTabCount, 0, `resource tab 应全部关闭: ${JSON.stringify(activeAfterAllClosed)}`);
|
||||
assert.equal(activeAfterAllClosed.panelHidden, true, `resource tab host 应隐藏: ${JSON.stringify(activeAfterAllClosed)}`);
|
||||
assert.equal(new URL(page.url()).searchParams.get("resourceTab"), null, "关闭所有 resource tab 后应清除 resourceTab URL 状态");
|
||||
|
||||
console.log(JSON.stringify({ ok: true, root, assetPath, assetId: asset.id, officeAssetId: officeAsset.id }, null, 2));
|
||||
} finally {
|
||||
|
||||
@@ -184,6 +184,7 @@ async function main() {
|
||||
return {
|
||||
guardAttr,
|
||||
hasGuardClass,
|
||||
noticeText: document.querySelector('[data-mnote-resource-close-guard="dirty"]')?.textContent || "",
|
||||
tabStillPresent: activeTab !== null,
|
||||
kind: activeTab?.getAttribute('data-mnote-tab-kind') || '',
|
||||
};
|
||||
@@ -191,6 +192,7 @@ async function main() {
|
||||
assert(blockedState.hasGuardClass, `dirty tab 应具有 is-close-guarded class: ${JSON.stringify(blockedState)}`);
|
||||
assert.equal(blockedState.guardAttr, 'dirty', `close-guard attribute 应为 dirty: ${JSON.stringify(blockedState)}`);
|
||||
assert.equal(blockedState.kind, 'markdown', `dirty tab 关闭阻止后应仍然激活: ${JSON.stringify(blockedState)}`);
|
||||
assert.match(blockedState.noticeText, /未保存|保存完成/, `dirty 关闭阻止应显示可见提示: ${JSON.stringify(blockedState)}`);
|
||||
|
||||
// 输入后的短保护窗口内再次关闭仍应被阻止,不应静默释放 resource editor。
|
||||
await closeBtn.click({ timeout: UI_TIMEOUT_MS });
|
||||
@@ -201,11 +203,13 @@ async function main() {
|
||||
kind: activeTab?.getAttribute('data-mnote-tab-kind') || 'none',
|
||||
resourceTabCount: document.querySelectorAll('.mnote-main-tab[data-mnote-tab-kind]:not([data-mnote-tab-kind="page"])').length,
|
||||
guardAttr: activeTab?.getAttribute('data-resource-tab-close-guarded') || '',
|
||||
noticeCount: document.querySelectorAll('[data-mnote-resource-close-guard="dirty"]').length,
|
||||
};
|
||||
});
|
||||
assert.equal(afterSecondCloseState.kind, 'markdown', `保护窗口内二次关闭后仍应停留在 resource tab: ${JSON.stringify(afterSecondCloseState)}`);
|
||||
assert.equal(afterSecondCloseState.resourceTabCount, 1, `保护窗口内 resource tab 不应被移除: ${JSON.stringify(afterSecondCloseState)}`);
|
||||
assert.equal(afterSecondCloseState.guardAttr, 'dirty', `保护窗口内应保留 dirty guard: ${JSON.stringify(afterSecondCloseState)}`);
|
||||
assert.equal(afterSecondCloseState.noticeCount, 1, `关闭提示不应重复堆叠: ${JSON.stringify(afterSecondCloseState)}`);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, root, assetId: asset.id }, null, 2));
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
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", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root) {
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, ".mnote", "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: "local-ws:user_real:task471",
|
||||
ownerId: "user_real",
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "tree_commands", "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 });
|
||||
}
|
||||
}
|
||||
|
||||
function readTrashIndex(root) {
|
||||
const indexPath = path.join(root, ".mnote", "trash-index.json");
|
||||
if (!fs.existsSync(indexPath)) return [];
|
||||
const raw = fs.readFileSync(indexPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed && typeof parsed === "object" && parsed.entries && typeof parsed.entries === "object") {
|
||||
return Object.entries(parsed.entries).map(([id, entry]) => ({ id, ...entry }));
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return Object.entries(parsed).map(([id, entry]) => ({ id, ...entry }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task471-bulk-trash-"));
|
||||
const rootUri = fileUrl(root);
|
||||
const assets = [
|
||||
{ assetId: "local-file:docs/附件-a.txt", filePath: path.join(root, "docs", "附件-a.txt") },
|
||||
{ assetId: "local-file:docs/附件-b.json", filePath: path.join(root, "docs", "附件-b.json") },
|
||||
];
|
||||
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
|
||||
writeWorkspaceManifest(root);
|
||||
fs.writeFileSync(path.join(root, "README.md"), "# Bulk Resource Trash\n", "utf8");
|
||||
fs.writeFileSync(assets[0].filePath, "asset a\n", "utf8");
|
||||
fs.writeFileSync(assets[1].filePath, "{\"asset\":\"b\"}\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",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const treeCommands = [];
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().includes("/api/tree/commands")) return;
|
||||
const body = request.postDataJSON?.() || null;
|
||||
treeCommands.push({ method: request.method(), body });
|
||||
});
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const selectors = assets.map((asset) => `#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`);
|
||||
await page.locator(selectors[0]).waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator(selectors[1]).waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const rowIds = await Promise.all(selectors.map((selector) =>
|
||||
page.locator(selector).first().getAttribute("data-row-id", { timeout: UI_TIMEOUT_MS }),
|
||||
));
|
||||
assert(rowIds.every(Boolean), `资源行应有 data-row-id: ${JSON.stringify(rowIds)}`);
|
||||
|
||||
await page.locator(`${selectors[0]} .tree-link`).click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator(`${selectors[1]} .tree-link`).click({
|
||||
modifiers: [process.platform === "darwin" ? "Meta" : "Control"],
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const selectedBefore = await page.locator('#sidebar-file-tree-root .tree-row[data-selected="true"]').evaluateAll((rows) =>
|
||||
rows.map((row) => row.getAttribute("data-row-id") || ""),
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectedBefore.sort(),
|
||||
rowIds.slice().sort(),
|
||||
`bulk delete 前应选中两个资源行: ${JSON.stringify(selectedBefore)}`,
|
||||
);
|
||||
|
||||
const dialogPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
return message;
|
||||
});
|
||||
await page.locator(selectors[1]).press("Delete", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const confirmText = await dialogPromise;
|
||||
assert.match(confirmText, /2 个附件/, `bulk delete 确认文案应包含附件数量: ${confirmText}`);
|
||||
|
||||
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied") === "true", null, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
for (const asset of assets) {
|
||||
await page.locator(`#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`).waitFor({
|
||||
state: "detached",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(fs.existsSync(asset.filePath), false, `${asset.assetId} 应移入垃圾箱`);
|
||||
}
|
||||
const archiveCommands = treeCommands.filter((entry) => entry.body && entry.body.action === "archive");
|
||||
assert.equal(archiveCommands.length, 2, `bulk delete 应发出两个 archive tree command: ${JSON.stringify(treeCommands)}`);
|
||||
assert.deepEqual(
|
||||
archiveCommands.map((entry) => entry.body.documentId).sort(),
|
||||
assets.map((asset) => asset.assetId).sort(),
|
||||
`archive command 应覆盖两个资源: ${JSON.stringify(archiveCommands)}`,
|
||||
);
|
||||
const trashIds = readTrashIndex(root).map((entry) => entry.id || entry.assetId).sort();
|
||||
assert.deepEqual(
|
||||
trashIds.filter((id) => assets.some((asset) => asset.assetId === id)),
|
||||
assets.map((asset) => asset.assetId).sort(),
|
||||
`trash index 应包含两个资源: ${JSON.stringify(trashIds)}`,
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, root, archived: rowIds }, null, 2));
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user