diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6e916ce..00711e8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,6 +22,10 @@ jobs:
- name: Install dependencies
run: npm ci
+ - name: Install mcp-server dependencies
+ run: npm ci
+ working-directory: mcp-server
+
- name: Lint
run: npm run lint
diff --git a/mcp-server/src/renderer/__tests__/satori-renderer.chrome.test.js b/mcp-server/src/renderer/__tests__/satori-renderer.chrome.test.js
new file mode 100644
index 0000000..6f1679c
--- /dev/null
+++ b/mcp-server/src/renderer/__tests__/satori-renderer.chrome.test.js
@@ -0,0 +1,134 @@
+// @vitest-environment node
+//
+// Override the project-wide jsdom env: this file imports the real Satori
+// renderer, which transitively pulls in node:fs/promises (emoji-loader).
+// jsdom's import analyser refuses unknown node: protocols.
+
+import { describe, it, expect, beforeAll } from "vitest";
+import { SatoriRenderer } from "../satori-renderer.js";
+
+// End-to-end integration test for Phase 2 (renderer integration).
+//
+// These exercise the real Satori → composeChromeSvg → Resvg pipeline.
+// They are slower than the pure-function tests in chrome/__tests__/ but
+// catch regressions the unit tests can't (missing fonts in Resvg, broken
+// SVG composition, etc.).
+
+const SIMPLE_HTML =
+ '
';
+
+const SIMPLE_ANDROID_HTML =
+ '';
+
+describe("SatoriRenderer chrome composition", () => {
+ let renderer;
+
+ beforeAll(async () => {
+ renderer = new SatoriRenderer();
+ await renderer.init();
+ });
+
+ it("renders an iPhone screen with auto chrome at the device 2x dimensions", async () => {
+ const result = await renderer.render(SIMPLE_HTML, { device: "iphone", chrome: "auto" });
+ expect(result.width).toBe(786); // 393 × 2
+ expect(result.height).toBe(1704); // 852 × 2
+ expect(result.device).toBe("iphone");
+ expect(result.chrome).toEqual([
+ "status-bar-ios",
+ "dynamic-island",
+ "home-indicator",
+ ]);
+ expect(result.chromeStyle).toBe("light");
+ expect(result.safeArea).toEqual({ top: 59, bottom: 34, left: 0, right: 0 });
+ expect(result.chromeRenderError).toBeUndefined();
+ // svgString should now contain chrome group ids
+ expect(result.svgString).toContain("chrome-status-bar-ios");
+ expect(result.svgString).toContain("chrome-dynamic-island");
+ expect(result.svgString).toContain("chrome-home-indicator");
+ // PNG buffer should be non-trivial
+ expect(result.pngBuffer.length).toBeGreaterThan(1000);
+ });
+
+ it("renders an Android screen with auto chrome", async () => {
+ const result = await renderer.render(SIMPLE_ANDROID_HTML, { device: "android", chrome: "auto" });
+ expect(result.width).toBe(824); // 412 × 2
+ expect(result.height).toBe(1830); // 915 × 2
+ expect(result.device).toBe("android");
+ expect(result.chrome).toEqual(["status-bar-android", "android-gesture-pill"]);
+ expect(result.safeArea).toEqual({ top: 36, bottom: 16, left: 0, right: 0 });
+ expect(result.svgString).toContain("chrome-status-bar-android");
+ expect(result.svgString).toContain("chrome-android-gesture-pill");
+ });
+
+ it("respects chrome: false (no chrome composited, zero safeArea)", async () => {
+ const result = await renderer.render(SIMPLE_HTML, { device: "iphone", chrome: false });
+ expect(result.chrome).toEqual([]);
+ expect(result.safeArea).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
+ expect(result.svgString).not.toContain("chrome-status-bar-ios");
+ });
+
+ it("respects an explicit chrome subset", async () => {
+ const result = await renderer.render(SIMPLE_HTML, {
+ device: "iphone",
+ chrome: ["status-bar-ios"],
+ });
+ expect(result.chrome).toEqual(["status-bar-ios"]);
+ expect(result.safeArea).toEqual({ top: 54, bottom: 0, left: 0, right: 0 });
+ expect(result.svgString).toContain("chrome-status-bar-ios");
+ expect(result.svgString).not.toContain("chrome-dynamic-island");
+ expect(result.svgString).not.toContain("chrome-home-indicator");
+ });
+
+ it("chromeStyle: dark uses the dark palette", async () => {
+ const result = await renderer.render(SIMPLE_HTML, {
+ device: "iphone",
+ chrome: ["home-indicator"],
+ chromeStyle: "dark",
+ });
+ expect(result.chromeStyle).toBe("dark");
+ expect(result.svgString).toContain('fill="#ffffff"');
+ });
+
+ it("custom width/height (no device) skips chrome and returns zero safeArea", async () => {
+ const result = await renderer.render(SIMPLE_HTML, { width: 500, height: 500, chrome: "auto" });
+ expect(result.device).toBe(null);
+ expect(result.chrome).toEqual([]);
+ expect(result.safeArea).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
+ expect(result.width).toBe(1000); // 500 × 2
+ });
+
+ it("composeChrome (universal path) wraps a base SVG and emits a PNG", async () => {
+ const baseSvg =
+ '';
+ const result = await renderer.composeChrome({
+ baseSvg,
+ device: "iphone",
+ chrome: "auto",
+ });
+ expect(result.width).toBe(786);
+ expect(result.height).toBe(1704);
+ expect(result.device).toBe("iphone");
+ expect(result.chrome).toEqual([
+ "status-bar-ios",
+ "dynamic-island",
+ "home-indicator",
+ ]);
+ expect(result.chromeRenderError).toBeUndefined();
+ expect(result.pngBuffer.length).toBeGreaterThan(1000);
+ });
+
+ it("composeChrome (universal path) wraps a base PNG dataUri", async () => {
+ // 1×1 transparent PNG
+ const dataUri =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9X0HuBkAAAAASUVORK5CYII=";
+ const result = await renderer.composeChrome({
+ baseImageDataUri: dataUri,
+ device: "android",
+ chrome: "auto",
+ });
+ expect(result.device).toBe("android");
+ expect(result.chrome).toEqual(["status-bar-android", "android-gesture-pill"]);
+ expect(result.svgString).toContain(' {
+ it('returns the iPhone auto-list for "auto"', () => {
+ expect(expandAutoChrome("auto", "iphone")).toEqual([
+ "status-bar-ios",
+ "dynamic-island",
+ "home-indicator",
+ ]);
+ });
+
+ it('returns the Android auto-list for "auto"', () => {
+ expect(expandAutoChrome("auto", "android")).toEqual([
+ "status-bar-android",
+ "android-gesture-pill",
+ ]);
+ });
+
+ it("treats undefined and null as auto", () => {
+ expect(expandAutoChrome(undefined, "iphone")).toEqual(AUTO_CHROME_BY_DEVICE.iphone);
+ expect(expandAutoChrome(null, "iphone")).toEqual(AUTO_CHROME_BY_DEVICE.iphone);
+ });
+
+ it("returns [] for explicit false (chrome disabled)", () => {
+ expect(expandAutoChrome(false, "iphone")).toEqual([]);
+ });
+
+ it("returns [] for empty array (chrome explicitly disabled)", () => {
+ expect(expandAutoChrome([], "iphone")).toEqual([]);
+ });
+
+ it("returns [] for auto on an unsupported device", () => {
+ expect(expandAutoChrome("auto", "windows-phone")).toEqual([]);
+ });
+
+ it("passes through an explicit array unchanged when valid", () => {
+ expect(expandAutoChrome(["status-bar-ios"], "iphone")).toEqual(["status-bar-ios"]);
+ });
+
+ it("rejects unknown chrome ids in an explicit array", () => {
+ expect(() => expandAutoChrome(["bogus"], "iphone")).toThrow(/Unknown chrome element/);
+ });
+
+ it("rejects conflicting chrome elements (device-agnostic check)", () => {
+ // No device → skip appliesTo gating, exercise the conflict rule directly.
+ expect(() =>
+ expandAutoChrome(["status-bar-ios", "status-bar-android"], undefined)
+ ).toThrow(/conflict/);
+ });
+
+ it("rejects an element not applicable to the device before conflict checks", () => {
+ expect(() =>
+ expandAutoChrome(["status-bar-ios", "status-bar-android"], "iphone")
+ ).toThrow(/does not apply/);
+ });
+
+ it("rejects an element not applicable to the device", () => {
+ expect(() => expandAutoChrome(["dynamic-island"], "android")).toThrow(/does not apply/);
+ });
+
+ it("throws on an unrecognised input shape (e.g. string other than 'auto')", () => {
+ expect(() => expandAutoChrome("yes-please", "iphone")).toThrow(/Invalid chrome value/);
+ });
+});
+
+// ── validateChrome ────────────────────────────────────────────────────────────
+
+describe("validateChrome", () => {
+ it("accepts an empty array", () => {
+ expect(() => validateChrome([], "iphone")).not.toThrow();
+ });
+
+ it("rejects non-array input", () => {
+ expect(() => validateChrome("auto", "iphone")).toThrow(/must be an array/);
+ });
+});
+
+// ── computeSafeArea ───────────────────────────────────────────────────────────
+
+describe("computeSafeArea (max-by-edge)", () => {
+ it("returns zero safe-area for empty list", () => {
+ expect(computeSafeArea([], "iphone")).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
+ });
+
+ it("for iPhone auto, top safeArea is dynamic-island's 59 (not status bar's 54)", () => {
+ const sa = computeSafeArea(AUTO_CHROME_BY_DEVICE.iphone, "iphone");
+ expect(sa.top).toBe(59);
+ expect(sa.bottom).toBe(34);
+ expect(sa.left).toBe(0);
+ expect(sa.right).toBe(0);
+ });
+
+ it("for Android auto, contributes top: 36 and bottom: 16", () => {
+ const sa = computeSafeArea(AUTO_CHROME_BY_DEVICE.android, "android");
+ expect(sa.top).toBe(36);
+ expect(sa.bottom).toBe(16);
+ });
+});
+
+// ── composeChromeSvg ──────────────────────────────────────────────────────────
+
+describe("composeChromeSvg", () => {
+ const baseSvg =
+ '';
+
+ it("wraps a Satori base SVG with a content group and chrome group", () => {
+ const { svgString } = composeChromeSvg({
+ baseSvg,
+ device: "iphone",
+ chrome: AUTO_CHROME_BY_DEVICE.iphone,
+ viewport: IPHONE_VIEWPORT,
+ });
+ expect(svgString).toMatch(/^