test(web): cover new memo sync race

This commit is contained in:
tianma-if committed 2026-08-09 11:55:22 +08:00
1 parent 4017d09bff
commit 27c2ed2b73
5 files changed
+196

No files matched your search

+3
View File
@@ -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/
+9
View File
@@ -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=="],
+3
View File
@@ -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",
+31
View File
@@ -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",
},
});
+150
View File
@@ -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 = <T>(page: Page, storeName: string) => page.evaluate(
(targetStore) => new Promise<T[]>((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<void>((resolve) => {
releaseCreate = resolve;
});
const createStarted = new Promise<void>((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<Page["waitForResponse"]>,
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<void>((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<StoredQueueItem>(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<StoredQueueItem>(page, "syncQueue");
expect(queuedBeforeCreate.some((item) => item.kind === "memo.update")).toBe(false);
await finishSyncAndVerifyReload(page, heldCreate.createResponse, heldCreate.releaseCreate, title, content);
});
});