// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Account, FileStream, ImageDefinition } from "../../../";
import { Image } from "../../media/image";
import { createJazzTestAccount } from "../../testing";
import { render, screen, waitFor } from "../testUtils";
beforeEach(() => {
vi.clearAllMocks();
});
describe("Image", async () => {
const account = await createJazzTestAccount({
isCurrentActiveAccount: true,
});
vi.spyOn(Account, "getMe").mockReturnValue(account);
describe("initial rendering", () => {
it("should render a blank placeholder while waiting for the coValue to load", async () => {
const { container } = render(
,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe(null);
expect(img!.getAttribute("height")).toBe(null);
expect(img!.alt).toBe("test");
expect(img!.src).toBe(
"data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==",
);
});
it("should render nothing if coValue is not found", async () => {
const { container } = render(
,
{ account },
);
await waitFor(() => {
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe(null);
expect(img!.getAttribute("height")).toBe(null);
expect(img!.alt).toBe("test");
expect(img!.src).toBe("");
});
});
it("should render an empty image if the image is not loaded yet", async () => {
const original = FileStream.create({ owner: account });
original.start({ mimeType: "image/jpeg" });
// Don't end original, so it has no chunks
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(, {
account,
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe(null);
expect(img!.getAttribute("height")).toBe(null);
expect(img!.alt).toBe("test");
expect(img!.src).toBe("");
});
it("should render the placeholder image if the image is not loaded yet", async () => {
const placeholderDataUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
const original = FileStream.create({ owner: account });
original.start({ mimeType: "image/jpeg" });
// Don't end original, so it has no chunks
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: false,
placeholderDataURL: placeholderDataUrl,
},
{
owner: account,
},
);
const { container } = render(, {
account,
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.src).toBe(placeholderDataUrl);
});
it("should not override actual placeholders", async () => {
const placeholderDataUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
const customPlaceholder =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXVzZXItaWNvbiBsdWNpZGUtdXNlciI+PHBhdGggZD0iTTE5IDIxdi0yYTQgNCAwIDAgMC00LTRIOWE0IDQgMCAwIDAtNCA0djIiLz48Y2lyY2xlIGN4PSIxMiIgY3k9IjciIHI9IjQiLz48L3N2Zz4=";
const original = FileStream.create({ owner: account });
original.start({ mimeType: "image/jpeg" });
// Don't end original, so it has no chunks
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: false,
placeholderDataURL: placeholderDataUrl,
},
{
owner: account,
},
);
const { container } = render(
,
{
account,
},
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.src).toBe(placeholderDataUrl);
});
it("should show custom placeholder while loading and replace with loaded image", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const customPlaceholder =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXVzZXItaWNvbiBsdWNpZGUtdXNlciI+PHBhdGggZD0iTTE5IDIxdi0yYTQgNCAwIDAgMC00LTRIOWE0IDQgMCAwIDAtNCA0djIiLz48Y2lyY2xlIGN4PSIxMiIgY3k9IjciIHI9IjQiLz48L3N2Zz4=";
// Create an image with no chunks initially (loading state)
const original = FileStream.create({ owner: account });
original.start({ mimeType: "image/jpeg" });
// Don't end original, so it has no chunks
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
,
{ account },
);
// Initially should show custom placeholder
let img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.src).toBe(customPlaceholder);
// Now add the actual image data
const imageData = await createDummyFileStream(100, account);
im.$jazz.set("100x100", imageData);
// Wait for the image to load and replace the placeholder
await waitFor(() => {
img = container.querySelector("img");
expect(img!.src).toBe("blob:test-100");
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
});
it("should render the original image once loaded", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
render(, { account });
await waitFor(() => {
expect(
(screen.getByAltText("test-loading") as HTMLImageElement).src,
).toBe("blob:test-100");
});
expect(createObjectURLSpy).toHaveBeenCalledOnce();
});
});
describe("dimensions", () => {
it("should render the original image if the width and height are not set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(, {
account,
});
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe(null);
expect(img!.getAttribute("height")).toBe(null);
});
it("should render the original sizes", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
,
{
account,
},
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe("100");
expect(img!.getAttribute("height")).toBe("100");
});
it("should render the original size keeping the aspect ratio", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
,
{
account,
},
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe("300");
expect(img!.getAttribute("height")).toBe("300");
});
it("should render the width attribute if it is set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBe("50");
expect(img!.getAttribute("height")).toBeNull();
});
it("should render the height attribute if it is set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.getAttribute("width")).toBeNull();
expect(img!.getAttribute("height")).toBe("50");
});
it("should render the class attribute if it is set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.classList.contains("test-class")).toBe(true);
});
});
describe("progressive loading", () => {
it("should render the resized image if progressive loading is enabled", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(500, account);
const im = ImageDefinition.create(
{
original,
originalSize: [500, 500],
progressive: true,
},
{
owner: account,
},
);
im.$jazz.set("500x500", original);
im.$jazz.set("256x256", await createDummyFileStream(256, account));
const { container } = render(
,
{ account },
);
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-500",
);
});
expect(createObjectURLSpy).toHaveBeenCalledOnce();
});
it("should show the highest resolution images as they are loaded", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(1920, account);
const im = ImageDefinition.create(
{
original,
originalSize: [1920, 1080],
progressive: true,
},
{
owner: account,
},
);
im.$jazz.set("1920x1080", original);
im.$jazz.set("256x256", await createDummyFileStream(256, account));
const { container } = render(
,
{ account },
);
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-1920",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
// Load higher resolution image
im.$jazz.set("1024x1024", await createDummyFileStream(1024, account));
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-1024",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(2);
});
it("should show the best loaded resolution if width is set", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await FileStream.createFromBlob(createDummyBlob(1), {
owner: account,
});
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: true,
},
{
owner: account,
},
);
im.$jazz.set("100x100", original);
im.$jazz.set("256x256", await createDummyFileStream(256, account));
im.$jazz.set("1024x1024", await createDummyFileStream(1024, account));
const { container } = render(
,
{ account },
);
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-256",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
});
it("should show the original image if asked resolution matches", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(100, account);
const im = ImageDefinition.create(
{
original,
originalSize: [100, 100],
progressive: true,
},
{
owner: account,
},
);
im.$jazz.set("100x100", original);
im.$jazz.set("256x256", await createDummyFileStream(256, account));
const { container } = render(
,
{ account },
);
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-100",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
});
it("should update to a higher resolution image when width/height props are changed at runtime", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const original = await createDummyFileStream(256, account);
const im = ImageDefinition.create(
{
original,
originalSize: [256, 256],
progressive: true,
},
{
owner: account,
},
);
im.$jazz.set("256x256", original);
im.$jazz.set("1024x1024", await createDummyFileStream(1024, account));
const { container, rerender } = render(
,
{ account },
);
// Initially, should load 256x256
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-256",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
rerender(
,
);
// After prop change, should load 1024x1024
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-1024",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(2);
});
});
describe("ref forwarding", () => {
it("should forward ref to the img element", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const ref = { current: null as HTMLImageElement | null };
const { container } = render(
,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(ref.current).toBe(img);
});
});
describe("lazy loading", () => {
it("should return an empty png if loading is lazy and placeholder is not set", async () => {
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
,
{ account },
);
const img = container.querySelector("img");
expect(img).toBeDefined();
expect(img!.src).toBe("blob:test-68");
});
it("should load the image when threshold is reached", async () => {
const createObjectURLSpy = vi
.spyOn(URL, "createObjectURL")
.mockImplementation((blob) => {
if (!(blob instanceof Blob)) {
throw new Error("Blob expected");
}
return `blob:test-${blob.size}`;
});
const im = ImageDefinition.create(
{
original: await createDummyFileStream(100, account),
originalSize: [100, 100],
progressive: false,
},
{
owner: account,
},
);
const { container } = render(
,
{ account },
);
const img = container.querySelector("img");
// simulate the load event when the browser's viewport reach the image
img!.dispatchEvent(new Event("load"));
await waitFor(() => {
expect((container.querySelector("img") as HTMLImageElement).src).toBe(
"blob:test-100",
);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(2);
});
});
});
function createDummyBlob(size: number): Blob {
const blob = new Blob([new Uint8Array(size)], { type: "image/png" });
return blob;
}
function createDummyFileStream(
size: number,
account: Awaited>,
) {
return FileStream.createFromBlob(createDummyBlob(size), {
owner: account,
});
}