test(web): cover new memo sync race
This commit is contained in:
1 parent
4017d09bff
commit
27c2ed2b73
5 files changed
+196
No files matched your search
@@ -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/
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user