From 27c2ed2b738c2a15f5350d2af304eea92e205edf Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sun, 9 Aug 2026 11:55:22 +0800 Subject: [PATCH] test(web): cover new memo sync race --- .gitignore | 3 + bun.lock | 9 ++ package.json | 3 + playwright.config.ts | 31 +++++++ tests/e2e/new-memo-sync.spec.ts | 150 ++++++++++++++++++++++++++++++++ 5 files changed, 196 insertions(+) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/new-memo-sync.spec.ts diff --git a/.gitignore b/.gitignore index 0604aa36..cd0cee43 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ apps/extension/dist-firefox/ apps/extension/web-ext-artifacts/ release/desktop/ coverage/ +playwright-report/ +test-results/ +blob-report/ # Rust sidecar build artifacts. target/ diff --git a/bun.lock b/bun.lock index af295334..bdd74eeb 100644 --- a/bun.lock +++ b/bun.lock @@ -54,6 +54,7 @@ "devDependencies": { "@cloudflare/workers-types": "latest", "@electron/asar": "3.4.1", + "@playwright/test": "^1.62.1", "@types/marked": "^6.0.0", "@types/node": "latest", "@types/react": "latest", @@ -894,6 +895,8 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], "@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="], @@ -3062,6 +3065,10 @@ "pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], @@ -4368,6 +4375,8 @@ "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], diff --git a/package.json b/package.json index 8c7a3f35..8e440d3d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "lint:extension:firefox": "bun run --cwd apps/extension lint:firefox", "package:extension:firefox": "bun run --cwd apps/extension package:firefox", "test:extension": "bun run --cwd apps/extension test", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install chromium", "dev:extension": "vite --config apps/extension/vite.config.ts", "build:web": "vite build --config apps/web/vite.config.ts", "build:desktop:sidecar": "cargo build --manifest-path crates/desktop-sidecar/Cargo.toml --release", @@ -143,6 +145,7 @@ "devDependencies": { "@cloudflare/workers-types": "latest", "@electron/asar": "3.4.1", + "@playwright/test": "^1.62.1", "@types/marked": "^6.0.0", "@types/node": "latest", "@types/react": "latest", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..0eb803fb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: false, + workers: 1, + timeout: 45_000, + expect: { + timeout: 10_000, + }, + use: { + baseURL: "http://127.0.0.1:5173", + locale: "zh-CN", + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "bun run dev", + url: "http://127.0.0.1:5173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/tests/e2e/new-memo-sync.spec.ts b/tests/e2e/new-memo-sync.spec.ts new file mode 100644 index 00000000..d32308da --- /dev/null +++ b/tests/e2e/new-memo-sync.spec.ts @@ -0,0 +1,150 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +type StoredQueueItem = { + kind?: string; + memoId?: string; + status?: string; + payload?: unknown; +}; + +const readIndexedDbStore = (page: Page, storeName: string) => page.evaluate( + (targetStore) => new Promise((resolve, reject) => { + const openRequest = indexedDB.open("edgeever-local"); + openRequest.onerror = () => reject(openRequest.error); + openRequest.onsuccess = () => { + const database = openRequest.result; + if (!database.objectStoreNames.contains(targetStore)) { + database.close(); + resolve([]); + return; + } + + const transaction = database.transaction(targetStore, "readonly"); + const getAllRequest = transaction.objectStore(targetStore).getAll(); + getAllRequest.onerror = () => reject(getAllRequest.error); + getAllRequest.onsuccess = () => { + database.close(); + resolve(getAllRequest.result as T[]); + }; + }; + }), + storeName, +); + +const storeContains = async (page: Page, storeName: string, marker: string) => + JSON.stringify(await readIndexedDbStore(page, storeName)).includes(marker); + +const holdNextMemoCreate = async (page: Page) => { + let releaseCreate!: () => void; + let markCreateStarted!: () => void; + const createGate = new Promise((resolve) => { + releaseCreate = resolve; + }); + const createStarted = new Promise((resolve) => { + markCreateStarted = resolve; + }); + const createResponse = page.waitForResponse( + (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/v1/memos", + ); + + await page.route("**/api/v1/memos", async (route: Route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + + markCreateStarted(); + await createGate; + await route.continue(); + }); + + return { createResponse, createStarted, releaseCreate }; +}; + +const editNewMemo = async (page: Page, title: string, content: string) => { + await page.getByRole("button", { name: "新建笔记", exact: true }).click(); + + const titleInput = page.getByPlaceholder("无标题"); + const editor = page.locator(".ProseMirror[contenteditable='true']"); + await expect(titleInput).toBeEditable(); + await expect(editor).toBeEditable(); + await expect(titleInput).toHaveValue(""); + await expect(editor).toBeEmpty(); + await titleInput.fill(title); + await editor.click(); + await page.keyboard.insertText(content); + + await expect.poll(() => storeContains(page, "drafts", content)).toBe(true); +}; + +const finishSyncAndVerifyReload = async ( + page: Page, + createResponsePromise: ReturnType, + releaseCreate: () => void, + title: string, + content: string, +) => { + const updateResponsePromise = page.waitForResponse( + (response) => response.request().method() === "PATCH" && new URL(response.url()).pathname.startsWith("/api/v1/memos/"), + ); + const createSyncCompletedPromise = page.evaluate(() => new Promise((resolve) => { + window.addEventListener("edgeever:sync-completed", () => resolve(), { once: true }); + })); + releaseCreate(); + const createResponse = await createResponsePromise; + expect(createResponse.status()).toBe(201); + const created = await createResponse.json() as { memo: { id: string } }; + const memoId = created.memo.id; + + try { + await expect.poll(async () => { + const items = await readIndexedDbStore(page, "syncQueue"); + return !items.some((item) => item.kind === "memo.create"); + }).toBe(true); + + await createSyncCompletedPromise; + await page.evaluate(() => window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed"))); + const updateResponse = await updateResponsePromise; + expect(new URL(updateResponse.url()).pathname).toBe(`/api/v1/memos/${memoId}`); + expect(updateResponse.ok()).toBe(true); + const updated = await updateResponse.json() as { memo: { title: string; contentJson: unknown } }; + expect(updated.memo.title).toBe(title); + expect(JSON.stringify(updated.memo.contentJson)).toContain(content); + + await expect.poll(async () => { + const response = await page.request.get(`/api/v1/memos/${memoId}`); + if (!response.ok()) return false; + const body = await response.json() as { memo: { title: string; contentJson: unknown } }; + return body.memo.title === title && JSON.stringify(body.memo.contentJson).includes(content); + }).toBe(true); + + await page.reload(); + const memoCard = page.locator(`[data-memo-id="${memoId}"]`); + await expect(memoCard).toContainText(title); + await memoCard.locator("button").first().click(); + await expect(page.getByPlaceholder("无标题")).toHaveValue(title); + await expect(page.locator(".ProseMirror[contenteditable='true']")).toContainText(content); + } finally { + await page.request.delete(`/api/v1/memos/${memoId}`); + await page.request.delete(`/api/v1/memos/${memoId}?permanent=1`); + } +}; + +test.describe("new memo synchronization", () => { + test("preserves a draft written while memo creation is in flight", async ({ page }) => { + const marker = `${Date.now()}-draft-only`; + const title = `E2E create race ${marker}`; + const content = `Content pasted before create completed ${marker}`; + const heldCreate = await holdNextMemoCreate(page); + + await page.goto("/"); + const editing = editNewMemo(page, title, content); + await heldCreate.createStarted; + await editing; + + const queuedBeforeCreate = await readIndexedDbStore(page, "syncQueue"); + expect(queuedBeforeCreate.some((item) => item.kind === "memo.update")).toBe(false); + + await finishSyncAndVerifyReload(page, heldCreate.createResponse, heldCreate.releaseCreate, title, content); + }); +});