feat: cut over rust web main shell

This commit is contained in:
lix-2026
2026-04-29 12:24:44 +08:00
parent 7965c6c107
commit 048fe28a4d
97 changed files with 9396 additions and 1263 deletions
@@ -2,12 +2,13 @@
// 说明:
// - 这份 smoke 用来覆盖 tree rust family checklist 第 9 节的通用回归要求。
// - 宿主页负责验证真实导航、上下文菜单、picker 对话框与 stream fallback
// - legacy React island 负责验证真实导航、上下文菜单、picker 对话框3000 Rust gateway 负责 API 与 stream。
// - `/api/tree/shell` 直连页已退到显式 debug/internal 边界,默认不再进入这条链路。
const { chromium } = require("playwright");
const {
BASE_URL,
DOCUMENT_UI_BASE_URL,
UI_TIMEOUT_MS,
assert,
createTempDocument,
@@ -25,6 +26,7 @@ const RUN_DIRECT_TREE_SHELL_CHECKS =
String(process.env.MNOTE_TREE_SHELL_DIRECT_SMOKE || "").trim() === "1";
const ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST =
String(process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST || "").trim() === "1";
const CHROMIUM_STABLE_ARGS = ["--disable-dev-shm-usage", "--disable-gpu"];
function buildTreeShellUrl(workspaceId, params = {}) {
const search = new URLSearchParams({
@@ -302,10 +304,19 @@ async function openPageTreeContextMenuFromHost(page, driver, documentId) {
} else {
const row = driver.scope.locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"]`);
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await row.locator('[data-testid="tree-action-menu"]').click({ timeout: UI_TIMEOUT_MS, force: true });
const menuButton = row.locator('[data-testid="tree-action-menu"]');
await menuButton.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
await menuButton.evaluate((button) => {
if (!(button instanceof HTMLButtonElement)) {
throw new Error("页面树更多操作按钮不存在");
}
button.click();
});
}
await page.getByText("重命名", { exact: true }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Escape");
await page.evaluate(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
}
async function doubleClickFileTreeDocumentFromHost(page, driver, documentId) {
@@ -874,6 +885,11 @@ async function runFileTreeHostDoubleClickCheck(context, fixture) {
}
}
function isPlaywrightTargetCrash(error) {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Target crashed");
}
async function waitForPickerEmptyState(page, dialog, pickerUsesIframe) {
if (!pickerUsesIframe) {
const emptyText = dialog.getByText("没有匹配结果");
@@ -1113,56 +1129,29 @@ async function runPickerDialogChecks(context, fixture) {
}
async function runStreamFallbackCheck(context, fixture) {
const page = await context.newPage();
let streamRequestCount = 0;
const snapshot = await requestJson(context.request, `/api/sidebar?workspaceId=${encodeURIComponent(fixture.workspaceId)}`);
const response = await context.request.fetch(
`${BASE_URL}/api/tree/events?workspaceId=${encodeURIComponent(fixture.workspaceId)}&maxPolls=0`,
{ timeout: UI_TIMEOUT_MS },
);
const body = await response.text();
assert(response.ok(), `/api/tree/events 请求失败: ${response.status()} ${body.slice(0, 200)}`);
assert(
(response.headers()["content-type"] || "").includes("text/event-stream"),
"/api/tree/events 未返回 SSE 内容类型",
);
assert(response.headers()["x-mnote-web-owner"] === "mnote-web", "/api/tree/events 缺少 mnote-web owner header");
assert(
response.headers()["x-mnote-tree-stream-owner"] === "rust-web",
"/api/tree/events 缺少 rust-web stream owner header",
);
assert(body.includes("event: snapshot") || body.includes("event:snapshot"), "/api/tree/events 未返回 snapshot event");
assert(body.includes('"kind":"snapshot"'), "/api/tree/events snapshot payload 缺少 kind=snapshot");
await page.route("**/api/mnote-web/stream**", async (route) => {
streamRequestCount += 1;
await route.fulfill({
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
},
body: buildSnapshotSseBody(snapshot, fixture.workspaceId),
});
});
try {
await openDocument(page, fixture.workspaceId, fixture.parentId);
await waitForPageTitleInput(page);
await page.waitForTimeout(600);
assert(streamRequestCount > 0, "stream fallback smoke 未命中 /api/mnote-web/stream");
const sidebarRefetchResponse = page.waitForResponse(
(response) =>
response.url().includes(`/api/sidebar?workspaceId=${encodeURIComponent(fixture.workspaceId)}`) &&
response.request().method() === "GET" &&
response.ok(),
{ timeout: UI_TIMEOUT_MS },
);
await renameThroughPageHead(page, fixture.parentId, fixture.fallbackTitle);
await sidebarRefetchResponse;
await waitForBreadcrumbTitle(page, fixture.fallbackTitle);
await page.waitForFunction(
(expectedTitle) => {
const input = document.querySelector('input[aria-label="页面标题"]');
return input instanceof HTMLInputElement && input.value.includes(expectedTitle);
},
fixture.fallbackTitle,
{ timeout: UI_TIMEOUT_MS },
);
return {
streamFallback: true,
streamRequestCount,
fallbackTitle: fixture.fallbackTitle,
};
} finally {
await page.close().catch(() => undefined);
}
return {
streamFallback: false,
rustTreeEvents: true,
streamRequestCount: 1,
};
}
async function main() {
@@ -1172,11 +1161,12 @@ async function main() {
: process.env.MNOTE_SMOKE_HEADLESS === "0"
? false
: !process.env.DISPLAY;
const browser = await chromium.launch({ headless });
const context = await browser.newContext({
const browser = await chromium.launch({ headless, args: CHROMIUM_STABLE_ARGS });
const browserContextOptions = {
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
};
let context = await browser.newContext(browserContextOptions);
let page = await context.newPage();
let fixture = null;
let caughtError = null;
@@ -1186,13 +1176,35 @@ async function main() {
fixture = await prepareFixture(context.request);
const pageTreeHost = await runPageTreeHostChecks(page, fixture);
const storageState = await context.storageState();
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
context = await browser.newContext({
...browserContextOptions,
storageState,
});
page = null;
const pageTreeShell = RUN_DIRECT_TREE_SHELL_CHECKS
? await runPageTreeShellChecks(context, fixture)
: { skipped: true, reason: "direct_tree_shell_debug_disabled" };
const fileTreeShell = RUN_DIRECT_TREE_SHELL_CHECKS
? await runFileTreeShellChecks(context, fixture)
: { skipped: true, reason: "direct_tree_shell_debug_disabled" };
const fileTreeHost = await runFileTreeHostDoubleClickCheck(context, fixture);
let fileTreeHost;
try {
fileTreeHost = await runFileTreeHostDoubleClickCheck(context, fixture);
} catch (error) {
if (!isPlaywrightTargetCrash(error)) {
throw error;
}
await context.close().catch(() => undefined);
context = await browser.newContext({
...browserContextOptions,
storageState,
});
fileTreeHost = await runFileTreeHostDoubleClickCheck(context, fixture);
fileTreeHost.recoveredFromRendererCrash = true;
}
const picker = await runPickerDialogChecks(context, fixture);
const streamFallback = await runStreamFallbackCheck(context, fixture);
@@ -1201,6 +1213,7 @@ async function main() {
{
ok: true,
baseUrl: BASE_URL,
documentBaseUrl: DOCUMENT_UI_BASE_URL,
viewerUserId: viewer.userId,
workspaceId: fixture.workspaceId,
parentId: fixture.parentId,
@@ -1241,7 +1254,9 @@ async function main() {
}
}
await page.close().catch(() => undefined);
if (page) {
await page.close().catch(() => undefined);
}
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}