mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
c17d6941be
* chore: migrate to vite plus
* Disable typeAware and typeCheck
* Update CI
* Fix CI
* Fix test
* Clean
* Run test with vp
* Try revert
* react: false In test
* Fix test
* Revert "Try revert"
This reverts commit 009da10473.
* Update
* Update
* Try revert ci changes
* revert
* Run vp migrate
* Disable typeAware and typeCheck for now
* Better resolve for test
* Use vp dev instead of vite
* Update expect
* Fix NormalizeManifestModuleId
* Try increase timeout
* Update to use vp
* Try new check
* Bring back npx vp
* Migrate CI
* Make next-intl resolvable
* Update
* Update
* Update
558 lines
17 KiB
TypeScript
558 lines
17 KiB
TypeScript
/**
|
|
* next/image component unit tests.
|
|
*
|
|
* Mirrors test cases from Next.js test/unit/next-image-new.test.ts and
|
|
* test/unit/next-image-get-img-props.test.ts, adapted for vinext's
|
|
* Image shim implementation.
|
|
*
|
|
* Tests SSR output, srcSet generation, getImageProps(), fill mode,
|
|
* priority, custom loader, and static image data handling.
|
|
*/
|
|
import { describe, it, expect } from "vite-plus/test";
|
|
import React from "react";
|
|
import ReactDOMServer from "react-dom/server";
|
|
import Image, { getImageProps, type StaticImageData } from "../packages/vinext/src/shims/image.js";
|
|
|
|
/** Helper: expected optimization URL matching what the image shim produces. */
|
|
function optUrl(src: string, w: number, q = 75): string {
|
|
return `/_vinext/image?url=${encodeURIComponent(src)}&w=${w}&q=${q}`;
|
|
}
|
|
/** Same as optUrl but with HTML entity encoding (for SSR output assertions). */
|
|
function optUrlHtml(src: string, w: number, q = 75): string {
|
|
return optUrl(src, w, q).replace(/&/g, "&");
|
|
}
|
|
|
|
// ─── SSR rendering ──────────────────────────────────────────────────────
|
|
|
|
describe("Image SSR rendering", () => {
|
|
it("renders a basic <img> tag with correct attributes", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "a nice image",
|
|
src: "/test.png",
|
|
width: 100,
|
|
height: 100,
|
|
}),
|
|
);
|
|
expect(html).toContain('alt="a nice image"');
|
|
// Local images are routed through the optimization endpoint
|
|
expect(html).toContain(`src="${optUrlHtml("/test.png", 100)}"`);
|
|
expect(html).toContain('width="100"');
|
|
expect(html).toContain('height="100"');
|
|
expect(html).toContain('decoding="async"');
|
|
expect(html).toContain('loading="lazy"');
|
|
expect(html).toContain('data-nimg="1"');
|
|
});
|
|
|
|
it("renders with priority (eager loading + fetchpriority)", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "priority image",
|
|
src: "/hero.png",
|
|
width: 800,
|
|
height: 600,
|
|
priority: true,
|
|
}),
|
|
);
|
|
expect(html).toContain('loading="eager"');
|
|
expect(html).toContain('fetchPriority="high"');
|
|
expect(html).not.toContain('loading="lazy"');
|
|
});
|
|
|
|
it("renders fill mode with absolute positioning", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "fill image",
|
|
src: "/bg.png",
|
|
fill: true,
|
|
}),
|
|
);
|
|
// Fill mode: no width/height attributes
|
|
expect(html).not.toMatch(/width="\d+"/);
|
|
expect(html).not.toMatch(/height="\d+"/);
|
|
// Fill adds position:absolute and 100% dimensions
|
|
expect(html).toContain("position:absolute");
|
|
expect(html).toContain("width:100%");
|
|
expect(html).toContain("height:100%");
|
|
expect(html).toContain('data-nimg="fill"');
|
|
// Fill defaults sizes to 100vw
|
|
expect(html).toContain('sizes="100vw"');
|
|
});
|
|
|
|
it("renders with custom sizes prop", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "sized",
|
|
src: "/img.png",
|
|
width: 500,
|
|
height: 300,
|
|
sizes: "(max-width: 768px) 100vw, 50vw",
|
|
}),
|
|
);
|
|
expect(html).toContain('sizes="(max-width: 768px) 100vw, 50vw"');
|
|
});
|
|
|
|
it("renders with blur placeholder styles", () => {
|
|
const blurDataURL = "data:image/png;base64,abc123";
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "blurry",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
placeholder: "blur",
|
|
blurDataURL,
|
|
}),
|
|
);
|
|
expect(html).toContain(`url(${blurDataURL})`);
|
|
expect(html).toContain("background-size:cover");
|
|
});
|
|
|
|
it("renders with custom loader", () => {
|
|
const loader = ({ src, width, quality }: { src: string; width: number; quality?: number }) =>
|
|
`https://cdn.example.com${src}?w=${width}&q=${quality || 75}`;
|
|
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "cdn image",
|
|
src: "/photo.jpg",
|
|
width: 200,
|
|
height: 150,
|
|
loader,
|
|
}),
|
|
);
|
|
expect(html).toContain('src="https://cdn.example.com/photo.jpg?w=200&q=75"');
|
|
});
|
|
|
|
it("renders StaticImageData (import result)", () => {
|
|
const staticImage: StaticImageData = {
|
|
src: "/_next/static/media/test.abc123.png",
|
|
width: 800,
|
|
height: 600,
|
|
blurDataURL: "data:image/png;base64,xyz",
|
|
};
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "static import",
|
|
src: staticImage,
|
|
placeholder: "blur",
|
|
}),
|
|
);
|
|
expect(html).toContain(`src="${optUrlHtml("/_next/static/media/test.abc123.png", 800)}"`);
|
|
|
|
expect(html).toContain('width="800"');
|
|
expect(html).toContain('height="600"');
|
|
expect(html).toContain("data:image/png;base64,xyz");
|
|
});
|
|
|
|
it("applies className and custom style", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "styled",
|
|
src: "/test.png",
|
|
width: 100,
|
|
height: 100,
|
|
className: "hero-img",
|
|
style: { borderRadius: "8px" },
|
|
}),
|
|
);
|
|
expect(html).toContain('class="hero-img"');
|
|
expect(html).toContain("border-radius:8px");
|
|
});
|
|
});
|
|
|
|
// ─── srcSet generation ──────────────────────────────────────────────────
|
|
|
|
describe("Image srcSet generation", () => {
|
|
it("generates srcSet for local images with width", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "test",
|
|
src: "/photo.png",
|
|
width: 500,
|
|
height: 400,
|
|
}),
|
|
);
|
|
// RESPONSIVE_WIDTHS = [640, 750, 828, 1080, 1200, 1920, 2048, 3840]
|
|
// Filter: widths <= 500 * 2 = 1000 → [640, 750, 828]
|
|
expect(html).toContain("srcSet");
|
|
expect(html).toContain(`${optUrlHtml("/photo.png", 640)} 640w`);
|
|
expect(html).toContain(`${optUrlHtml("/photo.png", 750)} 750w`);
|
|
expect(html).toContain(`${optUrlHtml("/photo.png", 828)} 828w`);
|
|
// Should not include widths > 1000
|
|
expect(html).not.toContain("1080w");
|
|
});
|
|
|
|
it("generates srcSet with all widths for large images", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "test",
|
|
src: "/large.png",
|
|
width: 2000,
|
|
height: 1500,
|
|
}),
|
|
);
|
|
// widths <= 4000: all of them
|
|
expect(html).toContain(`${optUrlHtml("/large.png", 640)} 640w`);
|
|
expect(html).toContain(`${optUrlHtml("/large.png", 3840)} 3840w`);
|
|
});
|
|
|
|
it("generates fallback srcSet for very small images", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "tiny",
|
|
src: "/icon.png",
|
|
width: 16,
|
|
height: 16,
|
|
}),
|
|
);
|
|
// widths <= 32: none of RESPONSIVE_WIDTHS qualify
|
|
// Falls back to single: optimized icon.png at 16w
|
|
expect(html).toContain(`${optUrlHtml("/icon.png", 16)} 16w`);
|
|
});
|
|
|
|
it("does not generate srcSet for fill mode", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "fill",
|
|
src: "/bg.png",
|
|
fill: true,
|
|
}),
|
|
);
|
|
// Fill mode: no srcSet (srcSet is only for local non-fill images with width)
|
|
expect(html).not.toContain("srcSet");
|
|
});
|
|
});
|
|
|
|
// ─── getImageProps ──────────────────────────────────────────────────────
|
|
|
|
describe("getImageProps", () => {
|
|
it("returns correct props for basic image", () => {
|
|
const { props } = getImageProps({
|
|
alt: "a nice desc",
|
|
src: "/test.png",
|
|
width: 100,
|
|
height: 200,
|
|
});
|
|
|
|
expect(props.alt).toBe("a nice desc");
|
|
expect(props.src).toBe(optUrl("/test.png", 100));
|
|
expect(props.width).toBe(100);
|
|
expect(props.height).toBe(200);
|
|
expect(props.loading).toBe("lazy");
|
|
expect(props.decoding).toBe("async");
|
|
expect((props as any)["data-nimg"]).toBe("1");
|
|
});
|
|
|
|
it("returns priority props", () => {
|
|
const { props } = getImageProps({
|
|
alt: "priority",
|
|
src: "/hero.png",
|
|
width: 800,
|
|
height: 600,
|
|
priority: true,
|
|
});
|
|
|
|
expect(props.loading).toBe("eager");
|
|
expect(props.fetchPriority).toBe("high");
|
|
});
|
|
|
|
it("returns fill mode props", () => {
|
|
const { props } = getImageProps({
|
|
alt: "fill",
|
|
src: "/bg.png",
|
|
fill: true,
|
|
});
|
|
|
|
expect(props.width).toBeUndefined();
|
|
expect(props.height).toBeUndefined();
|
|
expect(props.sizes).toBe("100vw");
|
|
expect((props as any)["data-nimg"]).toBe("fill");
|
|
expect((props.style as any)?.position).toBe("absolute");
|
|
expect((props.style as any)?.width).toBe("100%");
|
|
expect((props.style as any)?.height).toBe("100%");
|
|
});
|
|
|
|
it("returns custom loader URL", () => {
|
|
const loader = ({ src, width }: { src: string; width: number }) =>
|
|
`https://cdn.example.com${src}?w=${width}`;
|
|
|
|
const { props } = getImageProps({
|
|
alt: "cdn",
|
|
src: "/photo.jpg",
|
|
width: 300,
|
|
height: 200,
|
|
loader,
|
|
});
|
|
|
|
expect(props.src).toBe("https://cdn.example.com/photo.jpg?w=300");
|
|
});
|
|
|
|
it("returns blur placeholder styles", () => {
|
|
const { props } = getImageProps({
|
|
alt: "blur",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
placeholder: "blur",
|
|
blurDataURL: "data:image/png;base64,test",
|
|
});
|
|
|
|
expect((props.style as any)?.backgroundImage).toBe("url(data:image/png;base64,test)");
|
|
expect((props.style as any)?.backgroundSize).toBe("cover");
|
|
});
|
|
|
|
it("merges user style with default", () => {
|
|
const { props } = getImageProps({
|
|
alt: "styled",
|
|
src: "/test.png",
|
|
width: 100,
|
|
height: 100,
|
|
style: { maxWidth: "100%", height: "auto" },
|
|
});
|
|
|
|
expect((props.style as any)?.maxWidth).toBe("100%");
|
|
expect((props.style as any)?.height).toBe("auto");
|
|
});
|
|
|
|
it("passes through arbitrary props", () => {
|
|
const { props } = getImageProps({
|
|
alt: "test",
|
|
src: "/test.png",
|
|
width: 100,
|
|
height: 100,
|
|
id: "my-image",
|
|
} as any);
|
|
|
|
expect(props.id).toBe("my-image");
|
|
});
|
|
|
|
it("handles StaticImageData", () => {
|
|
const staticImage: StaticImageData = {
|
|
src: "/static/photo.png",
|
|
width: 1920,
|
|
height: 1080,
|
|
};
|
|
|
|
const { props } = getImageProps({
|
|
alt: "static",
|
|
src: staticImage,
|
|
});
|
|
|
|
expect(props.src).toBe(optUrl("/static/photo.png", 1920));
|
|
expect(props.width).toBe(1920);
|
|
expect(props.height).toBe(1080);
|
|
});
|
|
|
|
it("generates srcSet for local images", () => {
|
|
const { props } = getImageProps({
|
|
alt: "local",
|
|
src: "/photo.png",
|
|
width: 800,
|
|
height: 600,
|
|
});
|
|
|
|
expect(props.srcSet).toBeDefined();
|
|
expect(props.srcSet).toContain("/_vinext/image");
|
|
expect(props.srcSet).toContain("photo.png");
|
|
expect(props.srcSet).toContain("w");
|
|
});
|
|
|
|
it("handles loading=eager prop", () => {
|
|
const { props } = getImageProps({
|
|
alt: "eager",
|
|
src: "/test.png",
|
|
width: 100,
|
|
height: 100,
|
|
loading: "eager",
|
|
});
|
|
|
|
expect(props.loading).toBe("eager");
|
|
});
|
|
});
|
|
|
|
// ─── Security: blurDataURL CSS injection ────────────────────────────────
|
|
|
|
describe("blurDataURL CSS injection prevention", () => {
|
|
it("rejects blurDataURL with ) character (CSS url breakout)", () => {
|
|
const { props } = getImageProps({
|
|
alt: "malicious",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
placeholder: "blur",
|
|
blurDataURL: "data:x); color: red; background: url(",
|
|
});
|
|
|
|
// Should NOT have any backgroundImage — the malicious URL is rejected
|
|
expect((props.style as any)?.backgroundImage).toBeUndefined();
|
|
});
|
|
|
|
it("rejects blurDataURL with ; character (CSS property injection)", () => {
|
|
const { props } = getImageProps({
|
|
alt: "malicious",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
placeholder: "blur",
|
|
blurDataURL: "data:image/png;base64,abc); color: red; x: url(",
|
|
});
|
|
|
|
// The ; in data:image/png;base64 is fine, but ) breaks out of url()
|
|
expect((props.style as any)?.backgroundImage).toBeUndefined();
|
|
});
|
|
|
|
it("rejects blurDataURL with { character (CSS rule injection)", () => {
|
|
const { props } = getImageProps({
|
|
alt: "malicious",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
placeholder: "blur",
|
|
blurDataURL: "data:image/svg+xml,<svg>{</svg>",
|
|
});
|
|
|
|
expect((props.style as any)?.backgroundImage).toBeUndefined();
|
|
});
|
|
|
|
it("rejects blurDataURL that does not start with data:image/", () => {
|
|
const { props } = getImageProps({
|
|
alt: "malicious",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
placeholder: "blur",
|
|
blurDataURL: "javascript:alert(1)",
|
|
});
|
|
|
|
expect((props.style as any)?.backgroundImage).toBeUndefined();
|
|
});
|
|
|
|
it("accepts valid base64 blurDataURL", () => {
|
|
const { props } = getImageProps({
|
|
alt: "valid",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
placeholder: "blur",
|
|
blurDataURL:
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
|
});
|
|
|
|
expect((props.style as any)?.backgroundImage).toContain("data:image/png;base64,");
|
|
});
|
|
|
|
it("sanitizes blurDataURL in SSR rendering (Image component)", () => {
|
|
const maliciousURL = "data:x); color: red; background: url(";
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "malicious",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
placeholder: "blur",
|
|
blurDataURL: maliciousURL,
|
|
}),
|
|
);
|
|
// Should NOT contain the malicious CSS injection
|
|
expect(html).not.toContain("color: red");
|
|
expect(html).not.toContain("color:red");
|
|
// Should NOT contain any background-image at all (blur was rejected)
|
|
expect(html).not.toContain("background-image");
|
|
});
|
|
|
|
it("renders valid blurDataURL in SSR", () => {
|
|
const validURL = "data:image/png;base64,abc123";
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "valid blur",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
placeholder: "blur",
|
|
blurDataURL: validURL,
|
|
}),
|
|
);
|
|
expect(html).toContain("background-image");
|
|
expect(html).toContain(validURL);
|
|
});
|
|
});
|
|
|
|
// ─── onLoadingComplete (deprecated but supported) ────────────────────────
|
|
// Next.js deprecated onLoadingComplete in v14 but still supports it.
|
|
// It should be handled internally and NOT leak through to the returned props.
|
|
|
|
describe("onLoadingComplete prop", () => {
|
|
it("getImageProps does not leak onLoadingComplete into returned props", () => {
|
|
const { props } = getImageProps({
|
|
alt: "test",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
onLoadingComplete: () => {},
|
|
});
|
|
// onLoadingComplete must be consumed internally, not passed through
|
|
expect((props as any).onLoadingComplete).toBeUndefined();
|
|
});
|
|
|
|
it("getImageProps does not leak onLoad or onLoadingComplete when both provided", () => {
|
|
const { props } = getImageProps({
|
|
alt: "test",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
onLoad: () => {},
|
|
onLoadingComplete: () => {},
|
|
});
|
|
expect((props as any).onLoadingComplete).toBeUndefined();
|
|
expect((props as any).onLoad).toBeUndefined();
|
|
});
|
|
|
|
it("does not leak onLoadingComplete as a DOM attribute in SSR (local image)", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "test",
|
|
src: "/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
onLoadingComplete: () => {},
|
|
}),
|
|
);
|
|
expect(html).not.toContain("onLoadingComplete");
|
|
expect(html).not.toContain("onloadingcomplete");
|
|
expect(html).toContain('alt="test"');
|
|
});
|
|
|
|
it("does not leak onLoadingComplete as a DOM attribute in SSR (custom loader)", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "cdn",
|
|
src: "/photo.jpg",
|
|
width: 200,
|
|
height: 150,
|
|
loader: ({ src, width }: { src: string; width: number }) =>
|
|
`https://cdn.example.com${src}?w=${width}`,
|
|
onLoadingComplete: () => {},
|
|
}),
|
|
);
|
|
expect(html).not.toContain("onLoadingComplete");
|
|
expect(html).not.toContain("onloadingcomplete");
|
|
expect(html).toContain('alt="cdn"');
|
|
});
|
|
|
|
it("does not leak onLoadingComplete as a DOM attribute in SSR (remote URL)", () => {
|
|
const html = ReactDOMServer.renderToString(
|
|
React.createElement(Image, {
|
|
alt: "remote",
|
|
src: "https://example.com/photo.jpg",
|
|
width: 400,
|
|
height: 300,
|
|
onLoadingComplete: () => {},
|
|
}),
|
|
);
|
|
expect(html).not.toContain("onLoadingComplete");
|
|
expect(html).not.toContain("onloadingcomplete");
|
|
expect(html).toContain('alt="remote"');
|
|
});
|
|
});
|