From 1117dc584e881482379ed14dddc8feffe38bf112 Mon Sep 17 00:00:00 2001 From: Zimeng Xiong Date: Sat, 7 Feb 2026 19:24:00 -0800 Subject: [PATCH] resolve e2e --- e2e/tests/drawing-crud.spec.ts | 12 ++++++++ e2e/tests/helpers/api.ts | 53 +++++++++++++++++++------------- frontend/src/pages/Editor.tsx | 55 +++++++++++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/e2e/tests/drawing-crud.spec.ts b/e2e/tests/drawing-crud.spec.ts index 44e056a..7a65d1b 100644 --- a/e2e/tests/drawing-crud.spec.ts +++ b/e2e/tests/drawing-crud.spec.ts @@ -18,6 +18,12 @@ import { * - Auto-save functionality */ +const revealEditorHeader = async (page: import("@playwright/test").Page) => { + // Editor header auto-hides after a short delay unless pointer is near the top edge. + await page.mouse.move(24, 2); + await page.waitForTimeout(150); +}; + test.describe("Drawing Creation", () => { let createdDrawingIds: string[] = []; @@ -104,8 +110,11 @@ test.describe("Drawing Creation", () => { await page.goto(`/editor/${drawing.id}`); await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 }); + await revealEditorHeader(page); + // Click on the drawing name to edit it - it's a button that becomes an input const nameElement = page.getByText(originalName); + await expect(nameElement).toBeInViewport(); await nameElement.dblclick(); // Wait for edit mode @@ -132,9 +141,12 @@ test.describe("Drawing Creation", () => { await page.goto(`/editor/${drawing.id}`); await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 }); + await revealEditorHeader(page); + // Find and click the back button (arrow left icon in header) // The back button is a button element containing an ArrowLeft icon const backButton = page.locator("header button").first(); + await expect(backButton).toBeInViewport(); await backButton.click(); // Should navigate back to dashboard diff --git a/e2e/tests/helpers/api.ts b/e2e/tests/helpers/api.ts index ae493fd..6759168 100644 --- a/e2e/tests/helpers/api.ts +++ b/e2e/tests/helpers/api.ts @@ -13,11 +13,20 @@ type CsrfTokenResponse = { type CsrfInfo = { token: string; headerName: string; + cookieHeader: string; }; -// Cache CSRF tokens per Playwright request context so parallel tests don't race. -const csrfInfoByRequest = new WeakMap(); -const csrfFetchByRequest = new WeakMap>(); +let sharedCsrfInfo: CsrfInfo | null = null; +let sharedCsrfFetch: Promise | null = null; + +const extractCookieHeader = (response: { headersArray: () => Array<{ name: string; value: string }> }): string => { + const cookiePairs = response + .headersArray() + .filter((h) => h.name.toLowerCase() === "set-cookie") + .map((h) => h.value.split(";")[0] || "") + .filter((v) => v.length > 0); + return cookiePairs.join("; "); +}; const fetchCsrfInfo = async (request: APIRequestContext): Promise => { const response = await request.get(`${API_URL}/csrf-token`); @@ -38,48 +47,50 @@ const fetchCsrfInfo = async (request: APIRequestContext): Promise => { ? data.header : "x-csrf-token"; - return { token: data.token, headerName }; + const cookieHeader = extractCookieHeader(response); + if (!cookieHeader) { + throw new Error("Failed to fetch CSRF token: missing csrf client cookie"); + } + + return { token: data.token, headerName, cookieHeader }; }; const getCsrfInfo = async (request: APIRequestContext): Promise => { - const cached = csrfInfoByRequest.get(request); - if (cached) return cached; + if (sharedCsrfInfo) return sharedCsrfInfo; + if (sharedCsrfFetch) return sharedCsrfFetch; - const inFlight = csrfFetchByRequest.get(request); - if (inFlight) return inFlight; - - const promise = fetchCsrfInfo(request) + sharedCsrfFetch = fetchCsrfInfo(request) .then((info) => { - csrfInfoByRequest.set(request, info); + sharedCsrfInfo = info; return info; }) .finally(() => { - csrfFetchByRequest.delete(request); + sharedCsrfFetch = null; }); - csrfFetchByRequest.set(request, promise); - return promise; + return sharedCsrfFetch; }; const refreshCsrfInfo = async (request: APIRequestContext): Promise => { - const promise = fetchCsrfInfo(request) + sharedCsrfFetch = fetchCsrfInfo(request) .then((info) => { - csrfInfoByRequest.set(request, info); + sharedCsrfInfo = info; return info; }) .finally(() => { - csrfFetchByRequest.delete(request); + sharedCsrfFetch = null; }); - - csrfFetchByRequest.set(request, promise); - return promise; + return sharedCsrfFetch; }; export async function getCsrfHeaders( request: APIRequestContext ): Promise> { const info = await getCsrfInfo(request); - return { [info.headerName]: info.token }; + return { + [info.headerName]: info.token, + Cookie: info.cookieHeader, + }; } const withCsrfHeaders = async ( diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index f691bb5..30a4f04 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -91,6 +91,47 @@ export const Editor: React.FC = () => { return latestElementsRef.current; }, []); + const hasIntentionalDeletionDelta = useCallback( + (baseline: readonly any[] = [], candidate: readonly any[] = []): boolean => { + if (!Array.isArray(candidate) || candidate.length === 0) return false; + if (!hasRenderableElements(baseline)) return false; + if (hasRenderableElements(candidate)) return false; + + const baselineById = new Map( + baseline.map((element: any) => [element?.id, element]) + ); + + const getVersion = (element: any): number => + typeof element?.version === "number" ? element.version : 0; + const getUpdated = (element: any): number => { + const value = element?.updated; + return typeof value === "number" ? value : Number(value) || 0; + }; + + return candidate.some((element: any) => { + if (!element || element.isDeleted !== true || typeof element.id !== "string") { + return false; + } + + const previous = baselineById.get(element.id); + if (!previous) return false; + if (previous.isDeleted === true) return false; + + const nextVersion = getVersion(element); + const prevVersion = getVersion(previous); + if (nextVersion > prevVersion) return true; + + const nextUpdated = getUpdated(element); + const prevUpdated = getUpdated(previous); + if (nextVersion === prevVersion && nextUpdated > prevUpdated) return true; + + // Fallback for callers that may not bump version/updated consistently. + return nextVersion === prevVersion && nextUpdated === prevUpdated; + }); + }, + [] + ); + const resolveSafeSnapshot = useCallback( (candidateSnapshot: readonly any[] = []) => { const baseline = getRenderableBaselineSnapshot(); @@ -99,8 +140,11 @@ export const Editor: React.FC = () => { baseline, candidateSnapshot ); + const intentionalDeletionDelta = staleNonRenderableSnapshot + ? hasIntentionalDeletionDelta(baseline, candidateSnapshot) + : false; - if (staleEmptySnapshot || staleNonRenderableSnapshot) { + if (staleEmptySnapshot || (staleNonRenderableSnapshot && !intentionalDeletionDelta)) { return { snapshot: baseline, prevented: true, @@ -307,7 +351,7 @@ export const Editor: React.FC = () => { if (shouldUpdateFiles && typeof excalidrawAPI.current.addFiles === "function") { // Excalidraw manages binary files separately from scene elements; updateScene(files) // is not reliable for syncing pasted images across tabs. - excalidrawAPI.current.addFiles(incomingFiles); + excalidrawAPI.current.addFiles(Object.values(incomingFiles)); } excalidrawAPI.current.updateScene({ elements: mergedElements }); @@ -378,8 +422,11 @@ export const Editor: React.FC = () => { if (api && typeof api.addFiles === "function" && !patchedAddFilesApisRef.current.has(api as object)) { patchedAddFilesApisRef.current.add(api as object); const originalAddFiles = api.addFiles.bind(api); - api.addFiles = (files: Record) => { - originalAddFiles(files); + api.addFiles = (filesInput: Record | any[]) => { + const normalizedFiles = Array.isArray(filesInput) + ? filesInput + : Object.values(filesInput || {}); + originalAddFiles(normalizedFiles); // Avoid rebroadcast loops when we are applying remote updates. if (isSyncing.current) return;