feat: align local-first workspace direction

Document the VSCode-like local-first product shape, demote Convex to a control-plane role, and retire stale architecture drafts.

Add local workspace migration/export references plus smoke coverage for no-Convex managed workspace startup, local markdown title/body/options persistence, asset upload behavior, and Convex fixture export.

Verification: git diff --cached --check; node scripts/check-local-first-convex-guard.js --staged; node scripts/task444-convex-workspace-export-local-fixture-smoke.js; node scripts/task166-local-first-managed-workspace-no-convex-smoke.js; node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js
This commit is contained in:
lix-2026
2026-05-19 08:11:58 +08:00
parent 68d321e297
commit cdff672aa5
67 changed files with 5242 additions and 932 deletions
@@ -8,6 +8,20 @@ const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000";
const UI_TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 10_000);
const ACTOR_ID = `smoke-user-${process.pid}-${Date.now()}`;
const MANAGED_DATA_ROOT = "/mnt/Data1T/Mnote_data";
function resolveChromiumExecutablePath() {
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
if (explicit && fs.existsSync(explicit)) return explicit;
return [
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
].find((candidate) => fs.existsSync(candidate)) || "";
}
function fileUrl(filePath) {
return `file://${filePath.split(path.sep).map((part, index) => (
@@ -15,6 +29,20 @@ function fileUrl(filePath) {
)).join("/")}`;
}
function managedActorDir(actorId) {
return path.join(MANAGED_DATA_ROOT, "users", actorId, "workspaces", "my-space");
}
function writeWorkspaceManifest(root, ownerId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), JSON.stringify({
workspaceId: `local-ws-${path.basename(root).replace(/[^a-zA-Z0-9_-]/g, "_")}`,
ownerId,
createdAt: new Date(0).toISOString(),
capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
}, null, 2), "utf8");
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main-local-folder-"));
const otherRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main 本地 #other-"));
@@ -23,11 +51,17 @@ async function main() {
fs.writeFileSync(path.join(root, "docs", "child.md"), "# Child Page\n", "utf8");
fs.writeFileSync(path.join(root, "plain.txt"), "plain asset\n", "utf8");
fs.writeFileSync(path.join(otherRoot, "OTHER.md"), "# Other Root\n", "utf8");
writeWorkspaceManifest(root, ACTOR_ID);
writeWorkspaceManifest(otherRoot, ACTOR_ID);
const browser = await chromium.launch({ headless: true });
const executablePath = resolveChromiumExecutablePath();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
const context = await browser.newContext({
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
},
});
@@ -40,6 +74,34 @@ async function main() {
try {
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-create-default-local-workspace"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-open-local-folder-empty"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-create-default-local-workspace"]').click({
timeout: UI_TIMEOUT_MS,
});
const managedRoot = managedActorDir(ACTOR_ID);
await page.waitForURL((url) => {
return url.origin === new URL(BASE_URL).origin &&
url.pathname === "/" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(managedRoot) &&
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
assert(
fs.existsSync(path.join(managedRoot, ".mnote", "workspace.json")),
"创建我的空间应写入受管 workspace manifest",
);
assert(
fs.existsSync(path.join(managedRoot, "pages", "我的空间.md")),
"创建我的空间应写入默认 Markdown 首页",
);
await page.locator('[data-mnote-action="open-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({
state: "visible",
@@ -57,7 +119,8 @@ async function main() {
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
const recentLocalRoots = await page.evaluate(() => {
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
const actorId = document.body.getAttribute("data-mnote-actor-id") || "";
const raw = window.localStorage.getItem(`mnote.localFolder.recentRoots:${encodeURIComponent(actorId)}`) || "[]";
return JSON.parse(raw);
});
assert.equal(recentLocalRoots[0], fileUrl(root), "打开本地文件夹后应记录最近 rootUri");
@@ -107,11 +170,16 @@ async function main() {
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
const updatedRecentLocalRoots = await page.evaluate(() => {
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
const actorId = document.body.getAttribute("data-mnote-actor-id") || "";
const raw = window.localStorage.getItem(`mnote.localFolder.recentRoots:${encodeURIComponent(actorId)}`) || "[]";
return JSON.parse(raw);
});
assert.equal(updatedRecentLocalRoots[0], fileUrl(otherRoot), "切到其他本地文件夹后应把它放到最近目录首位");
assert.equal(updatedRecentLocalRoots[1], fileUrl(root), "之前打开过的本地目录应保留在最近目录列表中");
const legacyRecentLocalRoots = await page.evaluate(() => {
return window.localStorage.getItem("mnote.localFolder.recentRoots");
});
assert.equal(legacyRecentLocalRoots, null, "最近本地目录不能写入跨用户共享的 legacy localStorage key");
await page.locator('.tree-row[data-row-id="local:markdown:OTHER.md"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
@@ -136,11 +204,20 @@ async function main() {
!url.searchParams.has("rootUri");
}, { timeout: UI_TIMEOUT_MS });
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => {
return url.pathname === "/" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(otherRoot) &&
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
console.log("task164 desktop hot local folder main entry smoke passed");
} finally {
await browser.close();
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(otherRoot, { recursive: true, force: true });
fs.rmSync(path.join(MANAGED_DATA_ROOT, "users", ACTOR_ID), { recursive: true, force: true });
}
}