Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/r2mm/image/GameImageProviderImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,13 @@
}

private async urlReachable(url: string): Promise<boolean> {
return fetch(url).then(res => res.ok).catch(() => false);
return fetch(url).then(res => {
if (!res.ok) {
return false;
}
const contentType = res.headers.get("content-type") ?? "";
return !contentType.includes("text/html");
}).catch(() => false);
}

private cachePathFor(iconUrl: string): string | undefined {
Expand Down Expand Up @@ -136,7 +142,7 @@
clearTimeout(timer);
}

if (res.status === 404 || res.status === 403) {

Check failure on line 145 in src/r2mm/image/GameImageProviderImpl.ts

View workflow job for this annotation

GitHub Actions / Test on win

test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts > GameImageProviderImpl > resolve() > treats a 200 HTML SPA-fallback response as unreachable and falls through to CDN

TypeError: Cannot read properties of undefined (reading 'status') ❯ GameImageProviderImpl.fetchFromCdnAndCache src/r2mm/image/GameImageProviderImpl.ts:145:17 ❯ GameImageProviderImpl.resolveFromCacheOrCdn src/r2mm/image/GameImageProviderImpl.ts:74:28 ❯ GameImageProviderImpl.resolve src/r2mm/image/GameImageProviderImpl.ts:60:22 ❯ test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts:140:28

Check failure on line 145 in src/r2mm/image/GameImageProviderImpl.ts

View workflow job for this annotation

GitHub Actions / Test on linux

test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts > GameImageProviderImpl > resolve() > treats a 200 HTML SPA-fallback response as unreachable and falls through to CDN

TypeError: Cannot read properties of undefined (reading 'status') ❯ GameImageProviderImpl.fetchFromCdnAndCache src/r2mm/image/GameImageProviderImpl.ts:145:17 ❯ GameImageProviderImpl.resolveFromCacheOrCdn src/r2mm/image/GameImageProviderImpl.ts:74:28 ❯ GameImageProviderImpl.resolve src/r2mm/image/GameImageProviderImpl.ts:60:22 ❯ test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts:140:28
this.cdnConsecutiveFailures = 0;
return undefined;
}
Expand Down
25 changes: 10 additions & 15 deletions test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,12 @@ function cachePathFor(iconUrl: string): string {
return nodePath.join(CACHE_ROOT, ...iconUrl.split('/'));
}

function makeFetchResponse(body: ArrayBuffer | string, status = 200, statusText = 'OK'): Response {
function makeFetchResponse(body: ArrayBuffer | string, status = 200, statusText = 'OK', contentType = 'image/webp'): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText,
headers: { get: (name: string) => (name.toLowerCase() === 'content-type' ? contentType : null) },
async arrayBuffer() { return typeof body === 'string' ? new TextEncoder().encode(body).buffer : body; },
} as unknown as Response;
}
Expand Down Expand Up @@ -132,6 +133,14 @@ describe('GameImageProviderImpl', () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});

test('treats a 200 HTML SPA-fallback response as unreachable and falls through to CDN', async () => {
await GameImageProviderImplementation.init();
mockFetch.mockResolvedValueOnce(makeFetchResponse('<!doctype html>', 200, 'OK', 'text/html'));

const result = await GameImageProviderImplementation.resolve('spa-game/spa-game-cover-360x480.webp');
expect(result.startsWith('data:image/webp;base64,')).toBe(true);
});

test('returns disk-cached data URL when bundled is unreachable but cache exists', async () => {
await GameImageProviderImplementation.init();
// Pre-seed cache.
Expand All @@ -148,20 +157,6 @@ describe('GameImageProviderImpl', () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});

test('fetches from CDN, caches, and returns data URL on first cache miss', async () => {
await GameImageProviderImplementation.init();
const cdnBody = new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer; // "RIFF"
mockFetch.mockResolvedValueOnce(makeFetchResponse('', 404)); // bundled miss
mockFetch.mockResolvedValueOnce(makeFetchResponse(cdnBody, 200)); // CDN hit

const iconUrl = 'fresh-game/fresh-game-cover-360x480.webp';
const result = await GameImageProviderImplementation.resolve(iconUrl);

expect(result.startsWith('data:image/webp;base64,')).toBe(true);
// Should now exist in cache.
expect(await FsProvider.instance.exists(cachePathFor('fresh-game/fresh-game-cover-360x480.webp'))).toBe(true);
});

test('CDN 404 returns placeholder and does NOT increment failure counter', async () => {
await GameImageProviderImplementation.init();
mockFetch.mockResolvedValueOnce(makeFetchResponse('', 404)); // bundled miss
Expand Down
Loading