Files
paseo/packages/server/src/utils/project-icon.test.ts
T
Mohamed Boudra 52e4060d76 fix(server): prefer portable project icons
Project icon discovery could select an ICO that native clients cannot decode even when a renderable SVG or PNG was available. Keep ICO as the final fallback and recognize common sized icon names.
2026-08-13 19:34:37 +02:00

498 lines
15 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, realpathSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import {
findProjectIcon,
getProjectIcon,
ICON_PATTERNS,
PRIORITY_DIRS,
IGNORED_DIRS,
MONOREPO_PACKAGE_DIRS,
} from "./project-icon.js";
function createTempDir(): string {
return realpathSync(mkdtempSync(join(tmpdir(), "project-icon-test-")));
}
describe("findProjectIcon", () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe("ICON_PATTERNS", () => {
it("includes common favicon patterns", () => {
expect(ICON_PATTERNS).toContain("favicon.ico");
expect(ICON_PATTERNS).toContain("favicon.png");
expect(ICON_PATTERNS).toContain("favicon.svg");
});
it("includes app icon patterns", () => {
expect(ICON_PATTERNS).toContain("icon.png");
expect(ICON_PATTERNS).toContain("icon.svg");
expect(ICON_PATTERNS).toContain("app-icon.png");
});
it("includes logo patterns", () => {
expect(ICON_PATTERNS).toContain("logo.png");
expect(ICON_PATTERNS).toContain("logo.svg");
});
});
describe("PRIORITY_DIRS", () => {
it("includes common asset directories", () => {
expect(PRIORITY_DIRS).toContain("public");
expect(PRIORITY_DIRS).toContain("static");
expect(PRIORITY_DIRS).toContain("assets");
});
it("includes Phoenix static assets directory", () => {
expect(PRIORITY_DIRS).toContain("priv/static");
});
});
describe("IGNORED_DIRS", () => {
it("includes common ignored directories", () => {
expect(IGNORED_DIRS).toContain(".git");
expect(IGNORED_DIRS).toContain("node_modules");
expect(IGNORED_DIRS).toContain("dist");
expect(IGNORED_DIRS).toContain("build");
});
});
describe("MONOREPO_PACKAGE_DIRS", () => {
it("includes common monorepo package directories", () => {
expect(MONOREPO_PACKAGE_DIRS).toContain("packages");
expect(MONOREPO_PACKAGE_DIRS).toContain("apps");
});
});
it("returns null when no icon is found", async () => {
const result = await findProjectIcon(tempDir);
expect(result).toBeNull();
});
it("finds favicon.ico in root directory", async () => {
writeFileSync(join(tempDir, "favicon.ico"), "icon content");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "favicon.ico"));
});
it("finds favicon.png in root directory", async () => {
writeFileSync(join(tempDir, "favicon.png"), "icon content");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "favicon.png"));
});
it("finds icon in public directory (priority dir)", async () => {
mkdirSync(join(tempDir, "public"));
writeFileSync(join(tempDir, "public", "favicon.ico"), "icon content");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "public", "favicon.ico"));
});
it("finds icon in static directory (priority dir)", async () => {
mkdirSync(join(tempDir, "static"));
writeFileSync(join(tempDir, "static", "favicon.svg"), "icon content");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "static", "favicon.svg"));
});
it("finds icon in Phoenix priv/static directory", async () => {
mkdirSync(join(tempDir, "priv", "static"), { recursive: true });
writeFileSync(join(tempDir, "priv", "static", "favicon.ico"), "icon content");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "priv", "static", "favicon.ico"));
});
it("finds icon in assets directory (priority dir)", async () => {
mkdirSync(join(tempDir, "assets"));
writeFileSync(join(tempDir, "assets", "logo.png"), "icon content");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "assets", "logo.png"));
});
it("prioritizes favicon over logo", async () => {
writeFileSync(join(tempDir, "favicon.png"), "favicon");
writeFileSync(join(tempDir, "logo.png"), "logo");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "favicon.png"));
});
it("prioritizes priority dirs over root", async () => {
writeFileSync(join(tempDir, "logo.png"), "root logo");
mkdirSync(join(tempDir, "public"));
writeFileSync(join(tempDir, "public", "favicon.ico"), "public favicon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "public", "favicon.ico"));
});
it("ignores files in .git directory", async () => {
mkdirSync(join(tempDir, ".git"));
writeFileSync(join(tempDir, ".git", "favicon.ico"), "git icon");
const result = await findProjectIcon(tempDir);
expect(result).toBeNull();
});
it("ignores files in node_modules directory", async () => {
mkdirSync(join(tempDir, "node_modules"));
writeFileSync(join(tempDir, "node_modules", "favicon.ico"), "node icon");
const result = await findProjectIcon(tempDir);
expect(result).toBeNull();
});
it("ignores files in dist directory", async () => {
mkdirSync(join(tempDir, "dist"));
writeFileSync(join(tempDir, "dist", "favicon.ico"), "dist icon");
const result = await findProjectIcon(tempDir);
expect(result).toBeNull();
});
it("finds icon in nested priority directory", async () => {
mkdirSync(join(tempDir, "public", "images"), { recursive: true });
writeFileSync(join(tempDir, "public", "images", "favicon.png"), "nested icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "public", "images", "favicon.png"));
});
it("finds apple-touch-icon.png", async () => {
writeFileSync(join(tempDir, "apple-touch-icon.png"), "apple icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "apple-touch-icon.png"));
});
it("prefers an Apple touch PNG over ICO", async () => {
writeFileSync(join(tempDir, "favicon.ico"), "ico");
writeFileSync(join(tempDir, "apple-touch-icon.png"), "apple icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "apple-touch-icon.png"));
});
it("finds icon-*.png patterns", async () => {
writeFileSync(join(tempDir, "icon-192.png"), "192 icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "icon-192.png"));
});
it.each([
"favicon-32x32.png",
"apple-touch-icon-180x180.png",
"android-chrome-192x192.png",
"safari-pinned-tab.svg",
"mstile-150x150.png",
])("finds common sized icon %s", async (fileName) => {
writeFileSync(join(tempDir, fileName), "icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, fileName));
});
it("handles non-existent directory gracefully", async () => {
const result = await findProjectIcon(join(tempDir, "nonexistent"));
expect(result).toBeNull();
});
it("prefers SVG over PNG over ICO for the same name", async () => {
writeFileSync(join(tempDir, "favicon.ico"), "ico");
writeFileSync(join(tempDir, "favicon.png"), "png");
writeFileSync(join(tempDir, "favicon.svg"), "svg");
// SVG and PNG render on every client; native <Image> cannot decode ICO.
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "favicon.svg"));
});
it("falls back to ICO when no SVG or PNG exists", async () => {
writeFileSync(join(tempDir, "favicon.ico"), "ico");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "favicon.ico"));
});
describe("monorepo package directories", () => {
it("finds icon in packages/*/public directory", async () => {
mkdirSync(join(tempDir, "packages", "app", "public"), { recursive: true });
writeFileSync(join(tempDir, "packages", "app", "public", "favicon.ico"), "icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "packages", "app", "public", "favicon.ico"));
});
it("finds icon in apps/*/public directory", async () => {
mkdirSync(join(tempDir, "apps", "web", "public"), { recursive: true });
writeFileSync(join(tempDir, "apps", "web", "public", "favicon.png"), "icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "apps", "web", "public", "favicon.png"));
});
it("finds icon in packages/* root", async () => {
mkdirSync(join(tempDir, "packages", "ui"), { recursive: true });
writeFileSync(join(tempDir, "packages", "ui", "logo.svg"), "icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "packages", "ui", "logo.svg"));
});
it("finds icon in Phoenix app priv/static directory inside monorepo", async () => {
mkdirSync(join(tempDir, "apps", "api", "priv", "static"), { recursive: true });
writeFileSync(join(tempDir, "apps", "api", "priv", "static", "favicon.ico"), "icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "apps", "api", "priv", "static", "favicon.ico"));
});
it("prioritizes root priority dirs over monorepo dirs", async () => {
mkdirSync(join(tempDir, "public"), { recursive: true });
mkdirSync(join(tempDir, "packages", "app", "public"), { recursive: true });
writeFileSync(join(tempDir, "public", "favicon.ico"), "root icon");
writeFileSync(join(tempDir, "packages", "app", "public", "favicon.ico"), "package icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "public", "favicon.ico"));
});
it("prioritizes monorepo dirs over root dir (non-priority)", async () => {
mkdirSync(join(tempDir, "packages", "app", "public"), { recursive: true });
writeFileSync(join(tempDir, "logo.png"), "root icon");
writeFileSync(join(tempDir, "packages", "app", "public", "favicon.ico"), "package icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "packages", "app", "public", "favicon.ico"));
});
});
});
describe("getProjectIcon", () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
// Valid 1x1 PNG (square)
const squarePng = Buffer.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a, // PNG signature
0x00,
0x00,
0x00,
0x0d, // IHDR chunk length
0x49,
0x48,
0x44,
0x52, // IHDR
0x00,
0x00,
0x00,
0x01, // width: 1
0x00,
0x00,
0x00,
0x01, // height: 1
0x08,
0x02, // bit depth, color type
0x00,
0x00,
0x00, // compression, filter, interlace
0x90,
0x77,
0x53,
0xde, // CRC
0x00,
0x00,
0x00,
0x0c, // IDAT chunk length
0x49,
0x44,
0x41,
0x54, // IDAT
0x08,
0xd7,
0x63,
0xf8,
0xff,
0xff,
0xff,
0x00,
0x05,
0xfe,
0x02,
0xfe, // data
0xa3,
0x6c,
0x47,
0x9f, // CRC
0x00,
0x00,
0x00,
0x00, // IEND chunk length
0x49,
0x45,
0x4e,
0x44, // IEND
0xae,
0x42,
0x60,
0x82, // CRC
]);
// Valid 2x1 PNG (non-square)
const nonSquarePng = Buffer.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a, // PNG signature
0x00,
0x00,
0x00,
0x0d, // IHDR chunk length
0x49,
0x48,
0x44,
0x52, // IHDR
0x00,
0x00,
0x00,
0x02, // width: 2
0x00,
0x00,
0x00,
0x01, // height: 1
0x08,
0x02, // bit depth, color type
0x00,
0x00,
0x00, // compression, filter, interlace
0x00,
0x00,
0x00,
0x00, // CRC (not validated)
]);
it("returns icon data for square PNG", async () => {
writeFileSync(join(tempDir, "favicon.png"), squarePng);
const result = await getProjectIcon(tempDir);
expect(result).not.toBeNull();
expect(result?.mimeType).toBe("image/png");
expect(result?.data).toBe(squarePng.toString("base64"));
});
it("returns null for non-square PNG", async () => {
writeFileSync(join(tempDir, "favicon.png"), nonSquarePng);
const result = await getProjectIcon(tempDir);
expect(result).toBeNull();
});
it("returns icon data for ICO files (assumed square)", async () => {
writeFileSync(join(tempDir, "favicon.ico"), "ico content");
const result = await getProjectIcon(tempDir);
expect(result).not.toBeNull();
expect(result?.mimeType).toBe("image/x-icon");
});
it("reports PNG for a .ico file that actually contains PNG data", async () => {
// Renamed PNGs are a common real-world favicon.ico; the extension lies.
writeFileSync(join(tempDir, "favicon.ico"), squarePng);
const result = await getProjectIcon(tempDir);
expect(result?.mimeType).toBe("image/png");
expect(result?.data).toBe(squarePng.toString("base64"));
});
it("extracts the PNG frame from an ICO container", async () => {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0); // reserved
header.writeUInt16LE(1, 2); // type: icon
header.writeUInt16LE(1, 4); // one frame
const entry = Buffer.alloc(16);
entry[0] = 1; // width
entry[1] = 1; // height
entry.writeUInt32LE(squarePng.length, 8); // data size
entry.writeUInt32LE(22, 12); // data offset (6 header + 16 entry)
writeFileSync(join(tempDir, "favicon.ico"), Buffer.concat([header, entry, squarePng]));
const result = await getProjectIcon(tempDir);
expect(result?.mimeType).toBe("image/png");
expect(result?.data).toBe(squarePng.toString("base64"));
});
it("keeps ICO containers without a PNG frame as x-icon", async () => {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(1, 4);
const bmpFrame = Buffer.alloc(32, 0x42);
const entry = Buffer.alloc(16);
entry[0] = 1;
entry[1] = 1;
entry.writeUInt32LE(bmpFrame.length, 8);
entry.writeUInt32LE(22, 12);
const ico = Buffer.concat([header, entry, bmpFrame]);
writeFileSync(join(tempDir, "favicon.ico"), ico);
const result = await getProjectIcon(tempDir);
expect(result?.mimeType).toBe("image/x-icon");
expect(result?.data).toBe(ico.toString("base64"));
});
it("returns icon data for SVG files (assumed square)", async () => {
writeFileSync(join(tempDir, "favicon.svg"), "<svg></svg>");
const result = await getProjectIcon(tempDir);
expect(result).not.toBeNull();
expect(result?.mimeType).toBe("image/svg+xml");
});
it("returns null for files over 32KB", async () => {
const largeContent = Buffer.alloc(33 * 1024, 0);
writeFileSync(join(tempDir, "favicon.ico"), largeContent);
const result = await getProjectIcon(tempDir);
expect(result).toBeNull();
});
it("returns null when no icon is found", async () => {
const result = await getProjectIcon(tempDir);
expect(result).toBeNull();
});
});