resolve e2e
This commit is contained in:
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user