resolve e2e

This commit is contained in:
Zimeng Xiong
2026-02-07 19:24:00 -08:00
parent 70103e18fb
commit 1117dc584e
3 changed files with 95 additions and 25 deletions
+12
View File
@@ -18,6 +18,12 @@ import {
* - Auto-save functionality * - 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", () => { test.describe("Drawing Creation", () => {
let createdDrawingIds: string[] = []; let createdDrawingIds: string[] = [];
@@ -104,8 +110,11 @@ test.describe("Drawing Creation", () => {
await page.goto(`/editor/${drawing.id}`); await page.goto(`/editor/${drawing.id}`);
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 }); 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 // Click on the drawing name to edit it - it's a button that becomes an input
const nameElement = page.getByText(originalName); const nameElement = page.getByText(originalName);
await expect(nameElement).toBeInViewport();
await nameElement.dblclick(); await nameElement.dblclick();
// Wait for edit mode // Wait for edit mode
@@ -132,9 +141,12 @@ test.describe("Drawing Creation", () => {
await page.goto(`/editor/${drawing.id}`); await page.goto(`/editor/${drawing.id}`);
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 }); await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
await revealEditorHeader(page);
// Find and click the back button (arrow left icon in header) // Find and click the back button (arrow left icon in header)
// The back button is a button element containing an ArrowLeft icon // The back button is a button element containing an ArrowLeft icon
const backButton = page.locator("header button").first(); const backButton = page.locator("header button").first();
await expect(backButton).toBeInViewport();
await backButton.click(); await backButton.click();
// Should navigate back to dashboard // Should navigate back to dashboard
+32 -21
View File
@@ -13,11 +13,20 @@ type CsrfTokenResponse = {
type CsrfInfo = { type CsrfInfo = {
token: string; token: string;
headerName: string; headerName: string;
cookieHeader: string;
}; };
// Cache CSRF tokens per Playwright request context so parallel tests don't race. let sharedCsrfInfo: CsrfInfo | null = null;
const csrfInfoByRequest = new WeakMap<APIRequestContext, CsrfInfo>(); let sharedCsrfFetch: Promise<CsrfInfo> | null = null;
const csrfFetchByRequest = new WeakMap<APIRequestContext, Promise<CsrfInfo>>();
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<CsrfInfo> => { const fetchCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
const response = await request.get(`${API_URL}/csrf-token`); const response = await request.get(`${API_URL}/csrf-token`);
@@ -38,48 +47,50 @@ const fetchCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
? data.header ? data.header
: "x-csrf-token"; : "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<CsrfInfo> => { const getCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
const cached = csrfInfoByRequest.get(request); if (sharedCsrfInfo) return sharedCsrfInfo;
if (cached) return cached; if (sharedCsrfFetch) return sharedCsrfFetch;
const inFlight = csrfFetchByRequest.get(request); sharedCsrfFetch = fetchCsrfInfo(request)
if (inFlight) return inFlight;
const promise = fetchCsrfInfo(request)
.then((info) => { .then((info) => {
csrfInfoByRequest.set(request, info); sharedCsrfInfo = info;
return info; return info;
}) })
.finally(() => { .finally(() => {
csrfFetchByRequest.delete(request); sharedCsrfFetch = null;
}); });
csrfFetchByRequest.set(request, promise); return sharedCsrfFetch;
return promise;
}; };
const refreshCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => { const refreshCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
const promise = fetchCsrfInfo(request) sharedCsrfFetch = fetchCsrfInfo(request)
.then((info) => { .then((info) => {
csrfInfoByRequest.set(request, info); sharedCsrfInfo = info;
return info; return info;
}) })
.finally(() => { .finally(() => {
csrfFetchByRequest.delete(request); sharedCsrfFetch = null;
}); });
return sharedCsrfFetch;
csrfFetchByRequest.set(request, promise);
return promise;
}; };
export async function getCsrfHeaders( export async function getCsrfHeaders(
request: APIRequestContext request: APIRequestContext
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
const info = await getCsrfInfo(request); const info = await getCsrfInfo(request);
return { [info.headerName]: info.token }; return {
[info.headerName]: info.token,
Cookie: info.cookieHeader,
};
} }
const withCsrfHeaders = async ( const withCsrfHeaders = async (
+51 -4
View File
@@ -91,6 +91,47 @@ export const Editor: React.FC = () => {
return latestElementsRef.current; 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( const resolveSafeSnapshot = useCallback(
(candidateSnapshot: readonly any[] = []) => { (candidateSnapshot: readonly any[] = []) => {
const baseline = getRenderableBaselineSnapshot(); const baseline = getRenderableBaselineSnapshot();
@@ -99,8 +140,11 @@ export const Editor: React.FC = () => {
baseline, baseline,
candidateSnapshot candidateSnapshot
); );
const intentionalDeletionDelta = staleNonRenderableSnapshot
? hasIntentionalDeletionDelta(baseline, candidateSnapshot)
: false;
if (staleEmptySnapshot || staleNonRenderableSnapshot) { if (staleEmptySnapshot || (staleNonRenderableSnapshot && !intentionalDeletionDelta)) {
return { return {
snapshot: baseline, snapshot: baseline,
prevented: true, prevented: true,
@@ -307,7 +351,7 @@ export const Editor: React.FC = () => {
if (shouldUpdateFiles && typeof excalidrawAPI.current.addFiles === "function") { if (shouldUpdateFiles && typeof excalidrawAPI.current.addFiles === "function") {
// Excalidraw manages binary files separately from scene elements; updateScene(files) // Excalidraw manages binary files separately from scene elements; updateScene(files)
// is not reliable for syncing pasted images across tabs. // is not reliable for syncing pasted images across tabs.
excalidrawAPI.current.addFiles(incomingFiles); excalidrawAPI.current.addFiles(Object.values(incomingFiles));
} }
excalidrawAPI.current.updateScene({ elements: mergedElements }); 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)) { if (api && typeof api.addFiles === "function" && !patchedAddFilesApisRef.current.has(api as object)) {
patchedAddFilesApisRef.current.add(api as object); patchedAddFilesApisRef.current.add(api as object);
const originalAddFiles = api.addFiles.bind(api); const originalAddFiles = api.addFiles.bind(api);
api.addFiles = (files: Record<string, any>) => { api.addFiles = (filesInput: Record<string, any> | any[]) => {
originalAddFiles(files); const normalizedFiles = Array.isArray(filesInput)
? filesInput
: Object.values(filesInput || {});
originalAddFiles(normalizedFiles);
// Avoid rebroadcast loops when we are applying remote updates. // Avoid rebroadcast loops when we are applying remote updates.
if (isSyncing.current) return; if (isSyncing.current) return;