Files
OpenFrontIO/tests/server/PublicAssetManifest.test.ts
T
Evan 1ebac8e854 Move brand images to proprietary/ and support multi-dir asset pipeline (#3662)
## Description:

* Move proprietary brand images (logos, favicon) from resources/images/
to proprietary/images/ to separate open-source assets from proprietary
ones
* Extend the asset pipeline (PublicAssetManifest, vite.config.ts) to
support multiple source directories (resources/ + proprietary/), so
buildAssetUrl resolves assets from either location transparently
* In dev, serve proprietary/ as a fallback middleware (registered after
Vite's publicDir handler) so resources/ takes precedence when files
exist in both. The idea is we could have placeholder assets placeholders
that can be used by forks, and only the production build uses
proprietary assets.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
2026-04-16 08:42:11 -07:00

281 lines
8.7 KiB
TypeScript

import fs from "fs/promises";
import os from "os";
import path from "path";
import { afterEach, describe, expect, test } from "vitest";
import { normalizeAssetPath } from "../../src/core/AssetUrls";
import {
buildPublicAssetManifest,
clearPublicAssetManifestCache,
createHashedPublicAssetFiles,
} from "../../src/server/PublicAssetManifest";
describe("PublicAssetManifest", () => {
let tempDir: string | null = null;
type TempResources = {
resourcesDir: string;
outDir: string;
};
async function createTempResources(): Promise<TempResources> {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "public-assets-"));
const resourcesDir = path.join(tempDir, "resources");
const outDir = path.join(tempDir, "static");
await fs.mkdir(resourcesDir, { recursive: true });
await fs.writeFile(path.join(resourcesDir, "manifest.json"), "{}\n");
return { resourcesDir, outDir };
}
function getExpectedRelativeEmittedPath(
fromAssetHref: string,
targetAssetHref: string,
): string {
const fromDir = path.posix.dirname(normalizeAssetPath(fromAssetHref));
const targetPath = normalizeAssetPath(targetAssetHref);
return path.posix.relative(fromDir, targetPath);
}
async function writeBitmapFontFixture(
resourcesDir: string,
xmlRelativePath: string,
pageFilePath: string,
pageContent: string = "png-v1",
): Promise<void> {
const xmlPath = path.join(resourcesDir, xmlRelativePath);
const pagePath = path.join(path.dirname(xmlPath), pageFilePath);
const xmlPageFilePath = pageFilePath.split(path.sep).join(path.posix.sep);
await fs.mkdir(path.dirname(pagePath), { recursive: true });
await fs.writeFile(
xmlPath,
[
'<?xml version="1.0"?>',
"<font>",
` <pages><page id="0" file="${xmlPageFilePath}"/></pages>`,
"</font>",
"",
].join("\n"),
);
await fs.writeFile(pagePath, pageContent);
}
async function emitHashedAsset(
outDir: string,
assetHref: string,
): Promise<string> {
return fs.readFile(
path.join(outDir, normalizeAssetPath(assetHref)),
"utf8",
);
}
async function writeWebManifestFixture(
resourcesDir: string,
icons: Array<{ src?: string }>,
): Promise<void> {
await fs.writeFile(
path.join(resourcesDir, "manifest.json"),
JSON.stringify(
{
name: "OpenFront",
icons,
},
null,
2,
),
);
}
afterEach(async () => {
clearPublicAssetManifestCache();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
test("hashes manifest.json from its rewritten content", async () => {
const { resourcesDir, outDir } = await createTempResources();
await fs.mkdir(path.join(resourcesDir, "icons"), { recursive: true });
await writeWebManifestFixture(resourcesDir, [
{ src: "icons/app-icon.png" },
]);
await fs.writeFile(
path.join(resourcesDir, "icons", "app-icon.png"),
"icon-v1",
"utf8",
);
const firstManifest = buildPublicAssetManifest([resourcesDir]);
const firstManifestHref = firstManifest["manifest.json"];
const firstIconHref = firstManifest["icons/app-icon.png"];
createHashedPublicAssetFiles([resourcesDir], outDir, firstManifest);
const firstOutput = await fs.readFile(
path.join(outDir, firstManifestHref.slice(1)),
"utf8",
);
await fs.writeFile(
path.join(resourcesDir, "icons", "app-icon.png"),
"icon-v2",
"utf8",
);
clearPublicAssetManifestCache();
const secondManifest = buildPublicAssetManifest([resourcesDir]);
const secondManifestHref = secondManifest["manifest.json"];
const secondIconHref = secondManifest["icons/app-icon.png"];
expect(firstIconHref).not.toBe(secondIconHref);
expect(firstManifestHref).not.toBe(secondManifestHref);
expect(firstOutput).toContain(firstIconHref);
expect(firstOutput).not.toContain(secondIconHref);
});
test("rewrites root-relative web manifest icon paths to hashed URLs", async () => {
const { resourcesDir, outDir } = await createTempResources();
await fs.mkdir(path.join(resourcesDir, "icons"), { recursive: true });
await writeWebManifestFixture(resourcesDir, [
{ src: "/icons/app-icon.png" },
]);
await fs.writeFile(
path.join(resourcesDir, "icons", "app-icon.png"),
"icon-v1",
"utf8",
);
const assetManifest = buildPublicAssetManifest([resourcesDir]);
createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
const emittedManifest = await emitHashedAsset(
outDir,
assetManifest["manifest.json"],
);
expect(emittedManifest).toContain(assetManifest["icons/app-icon.png"]);
expect(emittedManifest).not.toContain('"/icons/app-icon.png"');
});
test("fails when web manifest references a missing local icon", async () => {
const { resourcesDir } = await createTempResources();
await writeWebManifestFixture(resourcesDir, [{ src: "icons/missing.png" }]);
expect(() => buildPublicAssetManifest([resourcesDir])).toThrow(
/manifest\.json references icons\/missing\.png/i,
);
});
test("leaves external and data web manifest icon refs unchanged", async () => {
const { resourcesDir, outDir } = await createTempResources();
await writeWebManifestFixture(resourcesDir, [
{ src: "https://cdn.example.com/app-icon.png" },
{ src: "data:image/png;base64,AAA" },
]);
const assetManifest = buildPublicAssetManifest([resourcesDir]);
createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
const emittedManifest = await emitHashedAsset(
outDir,
assetManifest["manifest.json"],
);
expect(emittedManifest).toContain("https://cdn.example.com/app-icon.png");
expect(emittedManifest).toContain("data:image/png;base64,AAA");
});
test("rewrites BMFont XML page filenames to hashed relative paths", async () => {
const { resourcesDir, outDir } = await createTempResources();
await writeBitmapFontFixture(
resourcesDir,
path.join("fonts", "test.xml"),
"test.png",
);
const assetManifest = buildPublicAssetManifest([resourcesDir]);
createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
const xmlHref = assetManifest["fonts/test.xml"];
const pngHref = assetManifest["fonts/test.png"];
const emittedXml = await emitHashedAsset(outDir, xmlHref);
expect(emittedXml).toContain(
getExpectedRelativeEmittedPath(xmlHref, pngHref),
);
expect(emittedXml).not.toContain('file="test.png"');
});
test("BMFont XML hash changes when a referenced page image changes", async () => {
const { resourcesDir } = await createTempResources();
await writeBitmapFontFixture(
resourcesDir,
path.join("fonts", "test.xml"),
"test.png",
);
const firstManifest = buildPublicAssetManifest([resourcesDir]);
await fs.writeFile(path.join(resourcesDir, "fonts", "test.png"), "png-v2");
clearPublicAssetManifestCache();
const secondManifest = buildPublicAssetManifest([resourcesDir]);
expect(firstManifest["fonts/test.png"]).not.toBe(
secondManifest["fonts/test.png"],
);
expect(firstManifest["fonts/test.xml"]).not.toBe(
secondManifest["fonts/test.xml"],
);
});
test("fails when BMFont XML references a missing page image", async () => {
const { resourcesDir } = await createTempResources();
await fs.mkdir(path.join(resourcesDir, "fonts"), { recursive: true });
await fs.writeFile(
path.join(resourcesDir, "fonts", "broken.xml"),
[
'<?xml version="1.0"?>',
"<font>",
' <pages><page id="0" file="missing.png"/></pages>',
"</font>",
"",
].join("\n"),
);
expect(() => buildPublicAssetManifest([resourcesDir])).toThrow(
/missing from the asset manifest/i,
);
});
test("rewrites nested BMFont page references to the correct relative hashed path", async () => {
const { resourcesDir, outDir } = await createTempResources();
await writeBitmapFontFixture(
resourcesDir,
path.join("fonts", "nested", "atlas.xml"),
path.join("pages", "p0.png"),
"nested-png",
);
const assetManifest = buildPublicAssetManifest([resourcesDir]);
createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest);
const xmlHref = assetManifest["fonts/nested/atlas.xml"];
const pngHref = assetManifest["fonts/nested/pages/p0.png"];
const emittedXml = await emitHashedAsset(outDir, xmlHref);
expect(emittedXml).toContain(
getExpectedRelativeEmittedPath(xmlHref, pngHref),
);
expect(emittedXml).not.toContain('file="pages/p0.png"');
});
});