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,
}),
]);
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 <image> `src` urls out of text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
+64 -97
View File
@@ -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);
});
});
@@ -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(
+2 -4
View File
@@ -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