From 08af0964f2a22ec0c59bc56cc1a3666308c72e2b Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 23 Mar 2026 17:30:16 +1100 Subject: [PATCH] test: cover element schema migration flow --- packages/excalidraw/clipboard.test.ts | 31 +++- packages/excalidraw/data/schema.test.ts | 161 +++++++----------- .../excalidraw/tests/data/restore.test.ts | 8 +- packages/excalidraw/tests/library.test.tsx | 6 +- 4 files changed, 98 insertions(+), 108 deletions(-) diff --git a/packages/excalidraw/clipboard.test.ts b/packages/excalidraw/clipboard.test.ts index 00faea4adb..2dfd342141 100644 --- a/packages/excalidraw/clipboard.test.ts +++ b/packages/excalidraw/clipboard.test.ts @@ -62,7 +62,6 @@ describe("parseClipboard()", () => { schemaVersion: SCHEMA_VERSIONS.latest, }), ]); - expect(clipboardData.schemaVersion).toBe(SCHEMA_VERSIONS.latest); }); it("should parse valid excalidraw JSON if inside text/html", async () => { @@ -109,12 +108,11 @@ describe("parseClipboard()", () => { // ------------------------------------------------------------------------- }); - it("should preserve per-element schema when payload schema is missing", async () => { + it("should preserve per-element schema on clipboard payload", async () => { const rect = API.createElement({ type: "rectangle" }); const clipboardPayload = JSON.parse( serializeAsClipboardJSON({ elements: [rect], files: null }), ); - delete clipboardPayload.schemaVersion; const clipboardData = await parseClipboard( await parseDataTransferEvent( @@ -126,7 +124,6 @@ describe("parseClipboard()", () => { ), ); - expect(clipboardData.schemaVersion).toBeUndefined(); expect(clipboardData.elements?.[0]).toEqual( expect.objectContaining({ id: rect.id, @@ -135,6 +132,32 @@ describe("parseClipboard()", () => { ); }); + it("should not upcast legacy elements to latest schema on clipboard serialize", async () => { + const rect = API.createElement({ type: "rectangle" }); + const legacyRect = { ...rect } as typeof rect & { schemaVersion?: number }; + delete legacyRect.schemaVersion; + + const clipboardPayload = JSON.parse( + serializeAsClipboardJSON({ + elements: [legacyRect as typeof rect], + files: null, + }), + ); + expect(clipboardPayload.elements[0]).not.toHaveProperty("schemaVersion"); + + const clipboardData = await parseClipboard( + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/plain": JSON.stringify(clipboardPayload), + }, + }), + ), + ); + + expect(clipboardData.elements?.[0]).not.toHaveProperty("schemaVersion"); + }); + it("should parse `src` urls out of text/html", async () => { let clipboardData; // ------------------------------------------------------------------------- diff --git a/packages/excalidraw/data/schema.test.ts b/packages/excalidraw/data/schema.test.ts index f8a5803716..b33564ce40 100644 --- a/packages/excalidraw/data/schema.test.ts +++ b/packages/excalidraw/data/schema.test.ts @@ -3,13 +3,8 @@ import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common"; import { API } from "../tests/helpers/api"; import { - ALL_SCOPES, - hasElementSchemaVersion, type SchemaMigration, - migrateAPIElements, - migrateClipboardElements, - migrateLibraryElements, - migrateSceneElements, + migrateElements, resolveSchemaVersion, SCHEMA_MIGRATIONS, SCHEMA_VERSIONS, @@ -18,16 +13,20 @@ import { describe("schema migration", () => { it("should migrate legacy frame backgrounds to transparent", () => { - const frame = API.createElement({ - type: "frame", - backgroundColor: "#ffc9c9", - }); + const frame = { + ...API.createElement({ + type: "frame", + backgroundColor: "#ffc9c9", + }), + schemaVersion: undefined, + }; - const migrated = migrateSceneElements([frame], SCHEMA_VERSIONS.initial)!; + const migrated = migrateElements([frame])!; expect(migrated[0].backgroundColor).toBe( DEFAULT_ELEMENT_PROPS.backgroundColor, ); + expect(migrated[0].schemaVersion).toBe(SCHEMA_VERSIONS.latest); }); it("should keep latest-schema frame backgrounds unchanged", () => { @@ -35,44 +34,36 @@ describe("schema migration", () => { type: "frame", backgroundColor: "#ffc9c9", }); + const latestFrame = { + ...frame, + schemaVersion: SCHEMA_VERSIONS.latest, + } as typeof frame & { schemaVersion: number }; - const migrated = migrateSceneElements([frame], SCHEMA_VERSIONS.latest)!; + const migrated = migrateElements([latestFrame])!; expect(migrated[0].backgroundColor).toBe("#ffc9c9"); + expect(migrated[0].schemaVersion).toBe(SCHEMA_VERSIONS.latest); }); - it("should normalize legacy frame backgrounds across all scopes", () => { - const frame = API.createElement({ - type: "frame", - backgroundColor: "#a5d8ff", - }); + it("should normalize legacy frame backgrounds", () => { + const frame = { + ...API.createElement({ + type: "frame", + backgroundColor: "#a5d8ff", + }), + schemaVersion: undefined, + }; - const migrationsByScope = { - scene: migrateSceneElements, - library: migrateLibraryElements, - clipboard: migrateClipboardElements, - api: migrateAPIElements, - } as const; - - for (const scope of ALL_SCOPES) { - const migrated = migrationsByScope[scope]( - [frame], - SCHEMA_VERSIONS.initial, - )!; - expect(migrated[0].backgroundColor).toBe( - DEFAULT_ELEMENT_PROPS.backgroundColor, - ); - } + const migrated = migrateElements([frame])!; + expect(migrated[0].backgroundColor).toBe( + DEFAULT_ELEMENT_PROPS.backgroundColor, + ); }); - it("should resolve invalid schema versions using fallback", () => { - expect(resolveSchemaVersion(undefined, SCHEMA_VERSIONS.initial)).toBe( - SCHEMA_VERSIONS.initial, - ); - expect(resolveSchemaVersion(0, SCHEMA_VERSIONS.latest)).toBe( - SCHEMA_VERSIONS.latest, - ); - expect(resolveSchemaVersion(2, SCHEMA_VERSIONS.initial)).toBe(2); + it("should resolve invalid schema versions to initial", () => { + expect(resolveSchemaVersion(undefined)).toBe(SCHEMA_VERSIONS.initial); + expect(resolveSchemaVersion(0)).toBe(SCHEMA_VERSIONS.initial); + expect(resolveSchemaVersion(2)).toBe(2); }); it("should have a valid migration registry configuration", () => { @@ -85,14 +76,12 @@ describe("schema migration", () => { version: 2.1, title: "", description: " ", - scope: [], apply: (elements) => elements, }, { version: 2.1, title: "duplicate", description: "duplicate version", - scope: ["scene"], apply: (elements) => elements, }, ]; @@ -112,7 +101,6 @@ describe("schema migration", () => { version: SCHEMA_VERSIONS.initial, title: "invalid start", description: "bad version", - scope: ["scene"], apply: (elements) => elements, }, ]); @@ -126,7 +114,6 @@ describe("schema migration", () => { version: SCHEMA_VERSIONS.latest + 1, title: "future migration", description: "future migration for test", - scope: ["scene"], apply: (elements) => elements, }, ]); @@ -137,23 +124,20 @@ describe("schema migration", () => { }); it("should not depend on temporary fields during migration", () => { - const frame = API.createElement({ - type: "frame", - backgroundColor: "#a5d8ff", - }); + const frame = { + ...API.createElement({ + type: "frame", + backgroundColor: "#a5d8ff", + }), + schemaVersion: undefined, + }; const withTempField = { ...frame, backgroundEnabled: false, } as typeof frame & { backgroundEnabled: boolean }; - const migratedBase = migrateSceneElements( - [frame], - SCHEMA_VERSIONS.initial, - )!; - const migratedWithTempField = migrateSceneElements( - [withTempField], - SCHEMA_VERSIONS.initial, - )!; + const migratedBase = migrateElements([frame])!; + const migratedWithTempField = migrateElements([withTempField])!; expect(migratedBase[0].backgroundColor).toBe( DEFAULT_ELEMENT_PROPS.backgroundColor, @@ -163,7 +147,7 @@ describe("schema migration", () => { ); }); - it("should use per-element schema when payload schema is missing", () => { + it("should use per-element schema hints", () => { const frame = API.createElement({ type: "frame", backgroundColor: "#ff0000", @@ -173,19 +157,19 @@ describe("schema migration", () => { schemaVersion: SCHEMA_VERSIONS.latest, } as typeof frame & { schemaVersion: number }; - const migrated = migrateClipboardElements([frameFromModernSource], { - payloadSchemaVersion: undefined, - fallbackVersion: SCHEMA_VERSIONS.initial, - })!; + const migrated = migrateElements([frameFromModernSource])!; expect(migrated[0].backgroundColor).toBe("#ff0000"); }); - it("should migrate mixed-hint elements individually when payload schema is missing", () => { - const legacyFrame = API.createElement({ - type: "frame", - backgroundColor: "#ff0000", - }); + it("should migrate mixed-hint elements individually", () => { + const legacyFrame = { + ...API.createElement({ + type: "frame", + backgroundColor: "#ff0000", + }), + schemaVersion: undefined, + }; const modernFrame = API.createElement({ type: "frame", backgroundColor: "#00ff00", @@ -195,10 +179,7 @@ describe("schema migration", () => { schemaVersion: SCHEMA_VERSIONS.latest, } as typeof modernFrame & { schemaVersion: number }; - const migrated = migrateSceneElements([legacyFrame, modernFrameWithHint], { - payloadSchemaVersion: undefined, - fallbackVersion: SCHEMA_VERSIONS.initial, - })!; + const migrated = migrateElements([legacyFrame, modernFrameWithHint])!; expect(migrated[0].backgroundColor).toBe( DEFAULT_ELEMENT_PROPS.backgroundColor, @@ -206,34 +187,20 @@ describe("schema migration", () => { expect(migrated[1].backgroundColor).toBe("#00ff00"); }); - it("should prefer payload schema over per-element schema", () => { - const frame = API.createElement({ - type: "frame", - backgroundColor: "#ff0000", - }); - const frameFromModernSource = { - ...frame, - schemaVersion: SCHEMA_VERSIONS.latest, - } as typeof frame & { schemaVersion: number }; - - const migrated = migrateClipboardElements([frameFromModernSource], { - payloadSchemaVersion: SCHEMA_VERSIONS.initial, - fallbackVersion: SCHEMA_VERSIONS.latest, - })!; - - expect(migrated[0].backgroundColor).toBe( - DEFAULT_ELEMENT_PROPS.backgroundColor, - ); + it("should stamp schemaVersion to latest after migration", () => { + const rect = API.createElement({ type: "rectangle" }); + const migrated = migrateElements([rect])!; + expect(migrated[0].schemaVersion).toBe(SCHEMA_VERSIONS.latest); }); - it("should detect schema hints on elements", () => { - const frame = API.createElement({ type: "frame" }); - const withHint = { - ...frame, - schemaVersion: SCHEMA_VERSIONS.latest, - } as typeof frame & { schemaVersion: number }; + it("should preserve higher-than-latest schema versions", () => { + const rect = API.createElement({ type: "rectangle" }); + const futureRect = { + ...rect, + schemaVersion: SCHEMA_VERSIONS.latest + 1, + } as typeof rect & { schemaVersion: number }; - expect(hasElementSchemaVersion([frame])).toBe(false); - expect(hasElementSchemaVersion([withHint])).toBe(true); + const migrated = migrateElements([futureRect])!; + expect(migrated[0].schemaVersion).toBe(SCHEMA_VERSIONS.latest + 1); }); }); diff --git a/packages/excalidraw/tests/data/restore.test.ts b/packages/excalidraw/tests/data/restore.test.ts index cdbeb7d128..2cba3fdd68 100644 --- a/packages/excalidraw/tests/data/restore.test.ts +++ b/packages/excalidraw/tests/data/restore.test.ts @@ -24,7 +24,6 @@ import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types"; import { API } from "../helpers/api"; import * as restore from "../../data/restore"; -import { SCHEMA_VERSIONS } from "../../data/schema"; import { getDefaultAppState } from "../../appState"; import type { ImportedDataState } from "../../data/types"; @@ -131,18 +130,21 @@ describe("restoreElements", () => { type: "frame", backgroundColor: "#a5d8ff", }); + const legacyFrame = { + ...frame, + schemaVersion: undefined, + } as typeof frame & { schemaVersion?: number }; const restoredLibraryItems = restore.restoreLibraryItems( [ { id: "library-item-1", status: "published", - elements: [frame], + elements: [legacyFrame], created: Date.now(), }, ], "published", - SCHEMA_VERSIONS.initial, ); expect( diff --git a/packages/excalidraw/tests/library.test.tsx b/packages/excalidraw/tests/library.test.tsx index e3f0ee2233..a6f8ea02dc 100644 --- a/packages/excalidraw/tests/library.test.tsx +++ b/packages/excalidraw/tests/library.test.tsx @@ -11,7 +11,6 @@ import type { ExcalidrawGenericElement } from "@excalidraw/element/types"; import { parseLibraryJSON } from "../data/blob"; import { serializeLibraryAsJSON } from "../data/json"; import { distributeLibraryItemsOnSquareGrid } from "../data/library"; -import { SCHEMA_VERSIONS } from "../data/schema"; import { Excalidraw } from "../index"; import { API } from "./helpers/api"; @@ -168,11 +167,10 @@ describe("library", () => { }); }); - it("should include schema version when serializing library", () => { + it("should serialize library payload", () => { const serialized = serializeLibraryAsJSON([]); const parsed = JSON.parse(serialized); - - expect(parsed.schemaVersion).toBe(SCHEMA_VERSIONS.latest); + expect(parsed.type).toBe("excalidrawlib"); }); // NOTE: mocked to test logic, not actual drag&drop via UI