mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-17 00:01:14 +00:00
Merge branch 'main' into team-names
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildAssetUrl } from "../src/core/AssetUrls";
|
||||
|
||||
describe("AssetUrls", () => {
|
||||
test("returns hashed URLs for direct asset matches", () => {
|
||||
expect(
|
||||
buildAssetUrl("images/Favicon.svg", {
|
||||
"images/Favicon.svg": "/_assets/images/Favicon.hash.svg",
|
||||
}),
|
||||
).toBe("/_assets/images/Favicon.hash.svg");
|
||||
});
|
||||
|
||||
test("maps directory prefixes into the hashed asset namespace", () => {
|
||||
const manifest = {
|
||||
"maps/britanniaclassic/manifest.json":
|
||||
"/_assets/maps/britanniaclassic/manifest.hash.json",
|
||||
"maps/britanniaclassic/map.bin":
|
||||
"/_assets/maps/britanniaclassic/map.hash.bin",
|
||||
};
|
||||
|
||||
expect(buildAssetUrl("maps", manifest)).toBe("/_assets/maps");
|
||||
expect(buildAssetUrl("maps/britanniaclassic", manifest)).toBe(
|
||||
"/_assets/maps/britanniaclassic",
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to the unversioned path when manifest has no match", () => {
|
||||
expect(buildAssetUrl("images/unknown.svg", {})).toBe("/images/unknown.svg");
|
||||
});
|
||||
|
||||
test("rejects dot segments in asset paths", () => {
|
||||
expect(() => buildAssetUrl("../api/instance", {})).toThrow(
|
||||
"Invalid asset path segment: ..",
|
||||
);
|
||||
expect(() => buildAssetUrl("images/%2e%2e/secret.svg", {})).toThrow(
|
||||
"Invalid asset path segment: %2e%2e",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects empty asset paths", () => {
|
||||
expect(() => buildAssetUrl("", {})).toThrow("Asset path must not be empty");
|
||||
expect(() => buildAssetUrl("///", {})).toThrow(
|
||||
"Asset path must not be empty",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -333,6 +333,54 @@ describe("Attack race condition with alliance requests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transport ship alliance rejection", () => {
|
||||
beforeEach(async () => {
|
||||
game = await setup("ocean_and_land", {
|
||||
infiniteGold: true,
|
||||
instantBuild: true,
|
||||
infiniteTroops: true,
|
||||
});
|
||||
|
||||
const playerAInfo = new PlayerInfo(
|
||||
"playerA",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"playerA_id",
|
||||
);
|
||||
// close to the water to send boats
|
||||
playerA = addPlayerToGame(playerAInfo, game, game.ref(7, 0));
|
||||
|
||||
const playerBInfo = new PlayerInfo(
|
||||
"playerB",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"playerB_id",
|
||||
);
|
||||
playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15));
|
||||
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
});
|
||||
|
||||
test("Should cancel alliance requests if the recipient sends a transport ship", async () => {
|
||||
// Player A sends alliance request to Player B
|
||||
const allianceRequest = playerA.createAllianceRequest(playerB);
|
||||
expect(allianceRequest).not.toBeNull();
|
||||
expect(playerB.incomingAllianceRequests()).toHaveLength(1);
|
||||
|
||||
// Player B sends a transport ship toward Player A's territory
|
||||
game.addExecution(new TransportShipExecution(playerB, game.ref(7, 0), 0));
|
||||
|
||||
// Execute a tick to process the transport ship launch
|
||||
game.executeNextTick();
|
||||
|
||||
// Alliance request should be rejected since player B sent a naval invasion
|
||||
expect(playerA.outgoingAllianceRequests()).toHaveLength(0);
|
||||
expect(playerB.incomingAllianceRequests()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Attack immunity", () => {
|
||||
beforeEach(async () => {
|
||||
game = await setup("ocean_and_land", {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { cosmeticRelationship } from "../src/client/Cosmetics";
|
||||
import { UserMeResponse } from "../src/core/ApiSchemas";
|
||||
|
||||
const product = { productId: "prod_123", priceId: "price_123", price: "$4.99" };
|
||||
|
||||
function makeUserMe(flares: string[]): UserMeResponse {
|
||||
return {
|
||||
player: { flares },
|
||||
} as unknown as UserMeResponse;
|
||||
}
|
||||
|
||||
describe("cosmeticRelationship", () => {
|
||||
it("returns owned when user has wildcard flare", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
makeUserMe(["flag:*"]),
|
||||
),
|
||||
).toBe("owned");
|
||||
});
|
||||
|
||||
it("returns owned when user has the specific flare", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
makeUserMe(["flag:cool"]),
|
||||
),
|
||||
).toBe("owned");
|
||||
});
|
||||
|
||||
it("returns blocked when no product and user does not own it", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product: null,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
makeUserMe([]),
|
||||
),
|
||||
).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns blocked when affiliate codes do not match", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product,
|
||||
affiliateCode: "storeA",
|
||||
itemAffiliateCode: "storeB",
|
||||
},
|
||||
makeUserMe([]),
|
||||
),
|
||||
).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns purchasable when product exists and affiliate matches", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
makeUserMe([]),
|
||||
),
|
||||
).toBe("purchasable");
|
||||
});
|
||||
|
||||
it("returns purchasable when affiliate codes match", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "pattern:*",
|
||||
requiredFlare: "pattern:stripes:red",
|
||||
product,
|
||||
affiliateCode: "storeA",
|
||||
itemAffiliateCode: "storeA",
|
||||
},
|
||||
makeUserMe([]),
|
||||
),
|
||||
).toBe("purchasable");
|
||||
});
|
||||
|
||||
it("returns blocked when user is not logged in and no product", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product: null,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns purchasable when user is not logged in but product exists", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toBe("purchasable");
|
||||
});
|
||||
|
||||
it("returns owned when user has wildcard flare for patterns", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "pattern:*",
|
||||
requiredFlare: "pattern:stripes:red",
|
||||
product,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
makeUserMe(["pattern:*"]),
|
||||
),
|
||||
).toBe("owned");
|
||||
});
|
||||
});
|
||||
+79
-1
@@ -18,7 +18,7 @@ const bannedWords = [
|
||||
const matcher = createMatcher(bannedWords);
|
||||
|
||||
// Create a minimal PrivilegeCheckerImpl for testing censor
|
||||
const mockCosmetics = { patterns: {}, colorPalettes: {} };
|
||||
const mockCosmetics = { patterns: {}, colorPalettes: {}, flags: {} };
|
||||
const mockDecoder = () => new Uint8Array();
|
||||
const checker = new PrivilegeCheckerImpl(
|
||||
mockCosmetics,
|
||||
@@ -27,6 +27,24 @@ const checker = new PrivilegeCheckerImpl(
|
||||
);
|
||||
const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []);
|
||||
|
||||
const flagCosmetics = {
|
||||
patterns: {},
|
||||
colorPalettes: {},
|
||||
flags: {
|
||||
cool_flag: {
|
||||
name: "cool_flag",
|
||||
url: "https://example.com/cool.png",
|
||||
affiliateCode: null,
|
||||
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const flagChecker = new PrivilegeCheckerImpl(
|
||||
flagCosmetics,
|
||||
mockDecoder,
|
||||
bannedWords,
|
||||
);
|
||||
|
||||
describe("UsernameCensor", () => {
|
||||
describe("isProfane (via matcher.hasMatch)", () => {
|
||||
test("detects exact banned words", () => {
|
||||
@@ -154,3 +172,63 @@ describe("UsernameCensor", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Flag validation in isAllowed", () => {
|
||||
test("allows valid country flag and resolves to SVG path", () => {
|
||||
const result = flagChecker.isAllowed([], { flag: "country:us" });
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.flag).toBe("/flags/us.svg");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid country code", () => {
|
||||
const result = flagChecker.isAllowed([], { flag: "country:zzzz" });
|
||||
expect(result.type).toBe("forbidden");
|
||||
});
|
||||
|
||||
test("rejects flag with no prefix", () => {
|
||||
const result = flagChecker.isAllowed([], { flag: "us" });
|
||||
expect(result.type).toBe("forbidden");
|
||||
});
|
||||
|
||||
test("allows cosmetic flag when user has wildcard flare", () => {
|
||||
const result = flagChecker.isAllowed(["flag:*"], {
|
||||
flag: "flag:cool_flag",
|
||||
});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.flag).toBe("https://example.com/cool.png");
|
||||
}
|
||||
});
|
||||
|
||||
test("allows cosmetic flag when user has specific flare", () => {
|
||||
const result = flagChecker.isAllowed(["flag:cool_flag"], {
|
||||
flag: "flag:cool_flag",
|
||||
});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.flag).toBe("https://example.com/cool.png");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects cosmetic flag when user lacks flare", () => {
|
||||
const result = flagChecker.isAllowed([], { flag: "flag:cool_flag" });
|
||||
expect(result.type).toBe("forbidden");
|
||||
});
|
||||
|
||||
test("rejects cosmetic flag that does not exist", () => {
|
||||
const result = flagChecker.isAllowed(["flag:*"], {
|
||||
flag: "flag:nonexistent",
|
||||
});
|
||||
expect(result.type).toBe("forbidden");
|
||||
});
|
||||
|
||||
test("allows no flag", () => {
|
||||
const result = flagChecker.isAllowed([], {});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.flag).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { GameEnv } from "../../../src/core/configuration/Config";
|
||||
import {
|
||||
clearCachedRuntimeClientServerConfig,
|
||||
GameLogicEnv,
|
||||
getBuildTimeGameLogicEnv,
|
||||
getGameLogicConfig,
|
||||
getRuntimeClientServerConfig,
|
||||
getServerConfigForGameLogicEnv,
|
||||
} from "../../../src/core/configuration/ConfigLoader";
|
||||
|
||||
describe("ConfigLoader", () => {
|
||||
const originalGameEnv = process.env.GAME_ENV;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.BOOTSTRAP_CONFIG = undefined;
|
||||
process.env.GAME_ENV = originalGameEnv;
|
||||
clearCachedRuntimeClientServerConfig();
|
||||
});
|
||||
|
||||
test("uses runtime bootstrap config without fetching /api/env", async () => {
|
||||
window.BOOTSTRAP_CONFIG = { gameEnv: "staging" };
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
const config = await getRuntimeClientServerConfig();
|
||||
|
||||
expect(config.env()).toBe(GameEnv.Preprod);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("maps staging builds to the default game logic config", async () => {
|
||||
process.env.GAME_ENV = "staging";
|
||||
|
||||
expect(getBuildTimeGameLogicEnv()).toBe(GameLogicEnv.Default);
|
||||
expect(getServerConfigForGameLogicEnv(GameLogicEnv.Default).env()).toBe(
|
||||
GameEnv.Prod,
|
||||
);
|
||||
|
||||
const config = await getGameLogicConfig({} as any, null);
|
||||
|
||||
expect(config.serverConfig().env()).toBe(GameEnv.Prod);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ describe("TradeShipExecution", () => {
|
||||
let pirate: Player;
|
||||
let srcPort: Unit;
|
||||
let piratePort: Unit;
|
||||
let piratePort2: Unit;
|
||||
let tradeShip: Unit;
|
||||
let dstPort: Unit;
|
||||
let tradeShipExecution: TradeShipExecution;
|
||||
@@ -48,27 +49,41 @@ describe("TradeShipExecution", () => {
|
||||
id: vi.fn(() => 3),
|
||||
addGold: vi.fn(),
|
||||
displayName: vi.fn(() => "Destination"),
|
||||
units: vi.fn(() => [piratePort]),
|
||||
unitCount: vi.fn(() => 1),
|
||||
units: vi.fn(() => [piratePort, piratePort2]),
|
||||
unitCount: vi.fn(() => 2),
|
||||
canTrade: vi.fn(() => true),
|
||||
} as any;
|
||||
|
||||
piratePort = {
|
||||
tile: vi.fn(() => 40011),
|
||||
tile: vi.fn(() => 56),
|
||||
owner: vi.fn(() => pirate),
|
||||
isActive: vi.fn(() => true),
|
||||
isUnderConstruction: vi.fn(() => false),
|
||||
isMarkedForDeletion: vi.fn(() => false),
|
||||
} as any;
|
||||
|
||||
piratePort2 = {
|
||||
tile: vi.fn(() => 75),
|
||||
owner: vi.fn(() => pirate),
|
||||
isActive: vi.fn(() => true),
|
||||
isUnderConstruction: vi.fn(() => false),
|
||||
isMarkedForDeletion: vi.fn(() => false),
|
||||
} as any;
|
||||
|
||||
srcPort = {
|
||||
tile: vi.fn(() => 20011),
|
||||
tile: vi.fn(() => 10),
|
||||
owner: vi.fn(() => origOwner),
|
||||
isActive: vi.fn(() => true),
|
||||
isUnderConstruction: vi.fn(() => false),
|
||||
isMarkedForDeletion: vi.fn(() => false),
|
||||
} as any;
|
||||
|
||||
dstPort = {
|
||||
tile: vi.fn(() => 30015), // 15x15
|
||||
tile: vi.fn(() => 100),
|
||||
owner: vi.fn(() => dstOwner),
|
||||
isActive: vi.fn(() => true),
|
||||
isUnderConstruction: vi.fn(() => false),
|
||||
isMarkedForDeletion: vi.fn(() => false),
|
||||
} as any;
|
||||
|
||||
tradeShip = {
|
||||
@@ -80,13 +95,13 @@ describe("TradeShipExecution", () => {
|
||||
setSafeFromPirates: vi.fn(),
|
||||
touch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
tile: vi.fn(() => 2001),
|
||||
tile: vi.fn(() => 32),
|
||||
} as any;
|
||||
|
||||
tradeShipExecution = new TradeShipExecution(origOwner, srcPort, dstPort);
|
||||
tradeShipExecution.init(game, 0);
|
||||
tradeShipExecution["pathFinder"] = {
|
||||
next: vi.fn(() => ({ status: PathStatus.NEXT, node: 2001 })),
|
||||
next: vi.fn(() => ({ status: PathStatus.NEXT, node: 32 })),
|
||||
findPath: vi.fn((from: number) => [from]),
|
||||
} as any;
|
||||
tradeShipExecution["tradeShip"] = tradeShip;
|
||||
@@ -118,7 +133,7 @@ describe("TradeShipExecution", () => {
|
||||
|
||||
it("should complete trade and award gold", () => {
|
||||
tradeShipExecution["pathFinder"] = {
|
||||
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 2001 })),
|
||||
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 32 })),
|
||||
findPath: vi.fn((from: number) => [from]),
|
||||
} as any;
|
||||
tradeShipExecution.tick(1);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { FetchGameMapLoader } from "../../../src/core/game/FetchGameMapLoader";
|
||||
import { GameMapType } from "../../../src/core/game/Game";
|
||||
|
||||
describe("FetchGameMapLoader", () => {
|
||||
test("resolves each map file through the provided path resolver", async () => {
|
||||
const fetchMock = vi.fn(async (url: string) => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
json: async () => ({ url }),
|
||||
statusText: "OK",
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const loader = new FetchGameMapLoader(
|
||||
(path) => `/_assets/maps/${path}.hashed`,
|
||||
);
|
||||
const mapData = loader.getMapData(GameMapType.BritanniaClassic);
|
||||
|
||||
expect(mapData.webpPath).toBe(
|
||||
"/_assets/maps/britanniaclassic/thumbnail.webp.hashed",
|
||||
);
|
||||
|
||||
await mapData.manifest();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/_assets/maps/britanniaclassic/manifest.json.hashed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { setNoStoreHeaders } from "../../src/server/NoStoreHeaders";
|
||||
|
||||
describe("NoStoreHeaders", () => {
|
||||
test("sets explicit no-store headers", () => {
|
||||
const headers = new Map<string, string>();
|
||||
const response = {
|
||||
setHeader(name: string, value: string) {
|
||||
headers.set(name, value);
|
||||
},
|
||||
} as any;
|
||||
|
||||
setNoStoreHeaders(response);
|
||||
|
||||
expect(headers.get("Cache-Control")).toBe(
|
||||
"no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
);
|
||||
expect(headers.get("Pragma")).toBe("no-cache");
|
||||
expect(headers.get("Expires")).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
buildPublicAssetManifest,
|
||||
clearPublicAssetManifestCache,
|
||||
createHashedPublicAssetFiles,
|
||||
} from "../../src/server/PublicAssetManifest";
|
||||
|
||||
describe("PublicAssetManifest", () => {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
clearPublicAssetManifestCache();
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("hashes manifest.json from its rewritten content", async () => {
|
||||
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(path.join(resourcesDir, "icons"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(resourcesDir, "manifest.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "OpenFront",
|
||||
icons: [{ src: "icons/app-icon.png" }],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
clearAppShellContentCache,
|
||||
getAppShellContent,
|
||||
setAppShellCacheHeaders,
|
||||
} from "../../src/server/RenderHtml";
|
||||
|
||||
describe("RenderHtml", () => {
|
||||
const originalGitCommit = process.env.GIT_COMMIT;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.GIT_COMMIT = originalGitCommit;
|
||||
clearAppShellContentCache();
|
||||
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("reuses cached app shell content", async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "render-html-"));
|
||||
const htmlPath = path.join(tempDir, "index.html");
|
||||
await fs.writeFile(
|
||||
htmlPath,
|
||||
"<script>window.GIT_COMMIT = <%- gitCommit %>;</script>",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.GIT_COMMIT = "first";
|
||||
const first = await getAppShellContent(htmlPath);
|
||||
|
||||
process.env.GIT_COMMIT = "second";
|
||||
const second = await getAppShellContent(htmlPath);
|
||||
|
||||
expect(first).toContain('"first"');
|
||||
expect(second).toBe(first);
|
||||
expect(second).not.toContain('"second"');
|
||||
});
|
||||
|
||||
test("sets shared-cache headers for the app shell", () => {
|
||||
const headers = new Map<string, string>();
|
||||
const response = {
|
||||
setHeader(name: string, value: string) {
|
||||
headers.set(name, value);
|
||||
},
|
||||
} as any;
|
||||
|
||||
setAppShellCacheHeaders(response);
|
||||
|
||||
expect(headers.get("Cache-Control")).toBe(
|
||||
"public, max-age=0, s-maxage=300, stale-while-revalidate=86400",
|
||||
);
|
||||
expect(headers.get("Content-Type")).toBe("text/html");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getStaticAssetCacheControl } from "../../src/server/StaticAssetCache";
|
||||
|
||||
describe("StaticAssetCache", () => {
|
||||
test("marks Vite asset namespace as immutable", () => {
|
||||
expect(getStaticAssetCacheControl("/assets/index-abc123.js")).toBe(
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
});
|
||||
|
||||
test("marks custom hashed asset namespace as immutable", () => {
|
||||
expect(
|
||||
getStaticAssetCacheControl("/_assets/maps/world/manifest.hash.json"),
|
||||
).toBe("public, max-age=31536000, immutable");
|
||||
});
|
||||
|
||||
test("does not mark other paths as immutable", () => {
|
||||
expect(getStaticAssetCacheControl("/manifest.json")).toBeUndefined();
|
||||
expect(getStaticAssetCacheControl("/api/health")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user