test: cover element schema migration flow

This commit is contained in:
Ryan Di
2026-03-23 17:30:16 +11:00
parent 0e4ae079ac
commit 08af0964f2
4 changed files with 98 additions and 108 deletions
+27 -4
View File
@@ -62,7 +62,6 @@ describe("parseClipboard()", () => {
schemaVersion: SCHEMA_VERSIONS.latest, schemaVersion: SCHEMA_VERSIONS.latest,
}), }),
]); ]);
expect(clipboardData.schemaVersion).toBe(SCHEMA_VERSIONS.latest);
}); });
it("should parse valid excalidraw JSON if inside text/html", async () => { 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 rect = API.createElement({ type: "rectangle" });
const clipboardPayload = JSON.parse( const clipboardPayload = JSON.parse(
serializeAsClipboardJSON({ elements: [rect], files: null }), serializeAsClipboardJSON({ elements: [rect], files: null }),
); );
delete clipboardPayload.schemaVersion;
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
await parseDataTransferEvent( await parseDataTransferEvent(
@@ -126,7 +124,6 @@ describe("parseClipboard()", () => {
), ),
); );
expect(clipboardData.schemaVersion).toBeUndefined();
expect(clipboardData.elements?.[0]).toEqual( expect(clipboardData.elements?.[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
id: rect.id, 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 <image> `src` urls out of text/html", async () => { it("should parse <image> `src` urls out of text/html", async () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
+64 -97
View File
@@ -3,13 +3,8 @@ import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { import {
ALL_SCOPES,
hasElementSchemaVersion,
type SchemaMigration, type SchemaMigration,
migrateAPIElements, migrateElements,
migrateClipboardElements,
migrateLibraryElements,
migrateSceneElements,
resolveSchemaVersion, resolveSchemaVersion,
SCHEMA_MIGRATIONS, SCHEMA_MIGRATIONS,
SCHEMA_VERSIONS, SCHEMA_VERSIONS,
@@ -18,16 +13,20 @@ import {
describe("schema migration", () => { describe("schema migration", () => {
it("should migrate legacy frame backgrounds to transparent", () => { it("should migrate legacy frame backgrounds to transparent", () => {
const frame = API.createElement({ const frame = {
type: "frame", ...API.createElement({
backgroundColor: "#ffc9c9", type: "frame",
}); backgroundColor: "#ffc9c9",
}),
schemaVersion: undefined,
};
const migrated = migrateSceneElements([frame], SCHEMA_VERSIONS.initial)!; const migrated = migrateElements([frame])!;
expect(migrated[0].backgroundColor).toBe( expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor, DEFAULT_ELEMENT_PROPS.backgroundColor,
); );
expect(migrated[0].schemaVersion).toBe(SCHEMA_VERSIONS.latest);
}); });
it("should keep latest-schema frame backgrounds unchanged", () => { it("should keep latest-schema frame backgrounds unchanged", () => {
@@ -35,44 +34,36 @@ describe("schema migration", () => {
type: "frame", type: "frame",
backgroundColor: "#ffc9c9", 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].backgroundColor).toBe("#ffc9c9");
expect(migrated[0].schemaVersion).toBe(SCHEMA_VERSIONS.latest);
}); });
it("should normalize legacy frame backgrounds across all scopes", () => { it("should normalize legacy frame backgrounds", () => {
const frame = API.createElement({ const frame = {
type: "frame", ...API.createElement({
backgroundColor: "#a5d8ff", type: "frame",
}); backgroundColor: "#a5d8ff",
}),
schemaVersion: undefined,
};
const migrationsByScope = { const migrated = migrateElements([frame])!;
scene: migrateSceneElements, expect(migrated[0].backgroundColor).toBe(
library: migrateLibraryElements, DEFAULT_ELEMENT_PROPS.backgroundColor,
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,
);
}
}); });
it("should resolve invalid schema versions using fallback", () => { it("should resolve invalid schema versions to initial", () => {
expect(resolveSchemaVersion(undefined, SCHEMA_VERSIONS.initial)).toBe( expect(resolveSchemaVersion(undefined)).toBe(SCHEMA_VERSIONS.initial);
SCHEMA_VERSIONS.initial, expect(resolveSchemaVersion(0)).toBe(SCHEMA_VERSIONS.initial);
); expect(resolveSchemaVersion(2)).toBe(2);
expect(resolveSchemaVersion(0, SCHEMA_VERSIONS.latest)).toBe(
SCHEMA_VERSIONS.latest,
);
expect(resolveSchemaVersion(2, SCHEMA_VERSIONS.initial)).toBe(2);
}); });
it("should have a valid migration registry configuration", () => { it("should have a valid migration registry configuration", () => {
@@ -85,14 +76,12 @@ describe("schema migration", () => {
version: 2.1, version: 2.1,
title: "", title: "",
description: " ", description: " ",
scope: [],
apply: (elements) => elements, apply: (elements) => elements,
}, },
{ {
version: 2.1, version: 2.1,
title: "duplicate", title: "duplicate",
description: "duplicate version", description: "duplicate version",
scope: ["scene"],
apply: (elements) => elements, apply: (elements) => elements,
}, },
]; ];
@@ -112,7 +101,6 @@ describe("schema migration", () => {
version: SCHEMA_VERSIONS.initial, version: SCHEMA_VERSIONS.initial,
title: "invalid start", title: "invalid start",
description: "bad version", description: "bad version",
scope: ["scene"],
apply: (elements) => elements, apply: (elements) => elements,
}, },
]); ]);
@@ -126,7 +114,6 @@ describe("schema migration", () => {
version: SCHEMA_VERSIONS.latest + 1, version: SCHEMA_VERSIONS.latest + 1,
title: "future migration", title: "future migration",
description: "future migration for test", description: "future migration for test",
scope: ["scene"],
apply: (elements) => elements, apply: (elements) => elements,
}, },
]); ]);
@@ -137,23 +124,20 @@ describe("schema migration", () => {
}); });
it("should not depend on temporary fields during migration", () => { it("should not depend on temporary fields during migration", () => {
const frame = API.createElement({ const frame = {
type: "frame", ...API.createElement({
backgroundColor: "#a5d8ff", type: "frame",
}); backgroundColor: "#a5d8ff",
}),
schemaVersion: undefined,
};
const withTempField = { const withTempField = {
...frame, ...frame,
backgroundEnabled: false, backgroundEnabled: false,
} as typeof frame & { backgroundEnabled: boolean }; } as typeof frame & { backgroundEnabled: boolean };
const migratedBase = migrateSceneElements( const migratedBase = migrateElements([frame])!;
[frame], const migratedWithTempField = migrateElements([withTempField])!;
SCHEMA_VERSIONS.initial,
)!;
const migratedWithTempField = migrateSceneElements(
[withTempField],
SCHEMA_VERSIONS.initial,
)!;
expect(migratedBase[0].backgroundColor).toBe( expect(migratedBase[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor, 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({ const frame = API.createElement({
type: "frame", type: "frame",
backgroundColor: "#ff0000", backgroundColor: "#ff0000",
@@ -173,19 +157,19 @@ describe("schema migration", () => {
schemaVersion: SCHEMA_VERSIONS.latest, schemaVersion: SCHEMA_VERSIONS.latest,
} as typeof frame & { schemaVersion: number }; } as typeof frame & { schemaVersion: number };
const migrated = migrateClipboardElements([frameFromModernSource], { const migrated = migrateElements([frameFromModernSource])!;
payloadSchemaVersion: undefined,
fallbackVersion: SCHEMA_VERSIONS.initial,
})!;
expect(migrated[0].backgroundColor).toBe("#ff0000"); expect(migrated[0].backgroundColor).toBe("#ff0000");
}); });
it("should migrate mixed-hint elements individually when payload schema is missing", () => { it("should migrate mixed-hint elements individually", () => {
const legacyFrame = API.createElement({ const legacyFrame = {
type: "frame", ...API.createElement({
backgroundColor: "#ff0000", type: "frame",
}); backgroundColor: "#ff0000",
}),
schemaVersion: undefined,
};
const modernFrame = API.createElement({ const modernFrame = API.createElement({
type: "frame", type: "frame",
backgroundColor: "#00ff00", backgroundColor: "#00ff00",
@@ -195,10 +179,7 @@ describe("schema migration", () => {
schemaVersion: SCHEMA_VERSIONS.latest, schemaVersion: SCHEMA_VERSIONS.latest,
} as typeof modernFrame & { schemaVersion: number }; } as typeof modernFrame & { schemaVersion: number };
const migrated = migrateSceneElements([legacyFrame, modernFrameWithHint], { const migrated = migrateElements([legacyFrame, modernFrameWithHint])!;
payloadSchemaVersion: undefined,
fallbackVersion: SCHEMA_VERSIONS.initial,
})!;
expect(migrated[0].backgroundColor).toBe( expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor, DEFAULT_ELEMENT_PROPS.backgroundColor,
@@ -206,34 +187,20 @@ describe("schema migration", () => {
expect(migrated[1].backgroundColor).toBe("#00ff00"); expect(migrated[1].backgroundColor).toBe("#00ff00");
}); });
it("should prefer payload schema over per-element schema", () => { it("should stamp schemaVersion to latest after migration", () => {
const frame = API.createElement({ const rect = API.createElement({ type: "rectangle" });
type: "frame", const migrated = migrateElements([rect])!;
backgroundColor: "#ff0000", expect(migrated[0].schemaVersion).toBe(SCHEMA_VERSIONS.latest);
});
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 detect schema hints on elements", () => { it("should preserve higher-than-latest schema versions", () => {
const frame = API.createElement({ type: "frame" }); const rect = API.createElement({ type: "rectangle" });
const withHint = { const futureRect = {
...frame, ...rect,
schemaVersion: SCHEMA_VERSIONS.latest, schemaVersion: SCHEMA_VERSIONS.latest + 1,
} as typeof frame & { schemaVersion: number }; } as typeof rect & { schemaVersion: number };
expect(hasElementSchemaVersion([frame])).toBe(false); const migrated = migrateElements([futureRect])!;
expect(hasElementSchemaVersion([withHint])).toBe(true); expect(migrated[0].schemaVersion).toBe(SCHEMA_VERSIONS.latest + 1);
}); });
}); });
@@ -24,7 +24,6 @@ import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
import { API } from "../helpers/api"; import { API } from "../helpers/api";
import * as restore from "../../data/restore"; import * as restore from "../../data/restore";
import { SCHEMA_VERSIONS } from "../../data/schema";
import { getDefaultAppState } from "../../appState"; import { getDefaultAppState } from "../../appState";
import type { ImportedDataState } from "../../data/types"; import type { ImportedDataState } from "../../data/types";
@@ -131,18 +130,21 @@ describe("restoreElements", () => {
type: "frame", type: "frame",
backgroundColor: "#a5d8ff", backgroundColor: "#a5d8ff",
}); });
const legacyFrame = {
...frame,
schemaVersion: undefined,
} as typeof frame & { schemaVersion?: number };
const restoredLibraryItems = restore.restoreLibraryItems( const restoredLibraryItems = restore.restoreLibraryItems(
[ [
{ {
id: "library-item-1", id: "library-item-1",
status: "published", status: "published",
elements: [frame], elements: [legacyFrame],
created: Date.now(), created: Date.now(),
}, },
], ],
"published", "published",
SCHEMA_VERSIONS.initial,
); );
expect( expect(
+2 -4
View File
@@ -11,7 +11,6 @@ import type { ExcalidrawGenericElement } from "@excalidraw/element/types";
import { parseLibraryJSON } from "../data/blob"; import { parseLibraryJSON } from "../data/blob";
import { serializeLibraryAsJSON } from "../data/json"; import { serializeLibraryAsJSON } from "../data/json";
import { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { SCHEMA_VERSIONS } from "../data/schema";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { API } from "./helpers/api"; 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 serialized = serializeLibraryAsJSON([]);
const parsed = JSON.parse(serialized); const parsed = JSON.parse(serialized);
expect(parsed.type).toBe("excalidrawlib");
expect(parsed.schemaVersion).toBe(SCHEMA_VERSIONS.latest);
}); });
// NOTE: mocked to test logic, not actual drag&drop via UI // NOTE: mocked to test logic, not actual drag&drop via UI