diff --git a/packages/angular/build/src/utils/index-file/inline-fonts.ts b/packages/angular/build/src/utils/index-file/inline-fonts.ts index 52181200c885..387eb479cd60 100644 --- a/packages/angular/build/src/utils/index-file/inline-fonts.ts +++ b/packages/angular/build/src/utils/index-file/inline-fonts.ts @@ -7,7 +7,7 @@ */ import { createHash } from 'node:crypto'; -import { readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { type Agent, get as httpsGet } from 'node:https'; import { join } from 'node:path'; import { NormalizedCachedOptions } from '../normalize-cache'; @@ -181,7 +181,7 @@ export class InlineFontsProcessor { if (data.length > CONTENT_HASH_LENGTH) { const storedHash = data.slice(0, CONTENT_HASH_LENGTH); const content = data.slice(CONTENT_HASH_LENGTH); - const contentHash = createHash(CONTENT_HASH_ALGORITHM).update(content).digest('base64'); + const contentHash = createHash(CONTENT_HASH_ALGORITHM).update(content).digest('hex'); if (storedHash === contentHash) { // Return valid content return content; @@ -253,8 +253,9 @@ export class InlineFontsProcessor { ); }); - if (cacheFile) { + if (cacheFile && this.cachePath) { try { + await mkdir(this.cachePath, { recursive: true }); const dataHash = createHash(CONTENT_HASH_ALGORITHM).update(data).digest('hex'); await writeFile(cacheFile, dataHash + data); } catch {} diff --git a/packages/angular/build/src/utils/index-file/inline-fonts_spec.ts b/packages/angular/build/src/utils/index-file/inline-fonts_spec.ts index 46511f853ecf..b2dea06b570a 100644 --- a/packages/angular/build/src/utils/index-file/inline-fonts_spec.ts +++ b/packages/angular/build/src/utils/index-file/inline-fonts_spec.ts @@ -6,9 +6,101 @@ * found in the LICENSE file at https://angular.dev/license */ +import assert from 'node:assert'; +import { createHash } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import type { ClientRequest, IncomingMessage } from 'node:http'; +import https from 'node:https'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; import { InlineFontsProcessor } from './inline-fonts'; describe('InlineFontsProcessor', () => { + describe('disk cache', () => { + const url = 'https://fonts.googleapis.com/css2?family=Roboto'; + const content = '@font-face { font-family: Roboto; src: url(roboto.woff2); }'; + let tempRoot: string; + let cache: { enabled: boolean; path: string; basePath: string }; + let cacheDirectory: string; + let cacheFile: string; + let getSpy: jasmine.Spy; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-font-cache-test-')); + cache = { enabled: true, path: join(tempRoot, 'cache'), basePath: tempRoot }; + cacheDirectory = join(cache.path, 'angular-build-fonts'); + cacheFile = join(cacheDirectory, createHash('sha256').update(url).digest('hex')); + + getSpy = spyOn(https, 'get').and.callFake((...args: unknown[]) => { + const callback = args.at(-1); + assert(typeof callback === 'function'); + const response = Readable.from([content]) as IncomingMessage; + response.statusCode = 200; + callback(response); + + return new EventEmitter() as ClientRequest; + }); + }); + + afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('should create the cache directory and save the stylesheet', async () => { + const processor = new InlineFontsProcessor({ cache }); + + expect(await processor.processURL(url)).toBe(content); + expect(await readdir(cacheDirectory)).toHaveSize(1); + expect(await readFile(cacheFile, 'utf8')).toBe( + createHash('sha256').update(content).digest('hex') + content, + ); + }); + + it('should reuse the stylesheet across processor instances without another request', async () => { + await mkdir(cacheDirectory, { recursive: true }); + expect(await new InlineFontsProcessor({ cache }).processURL(url)).toBe(content); + getSpy.calls.reset(); + getSpy.and.throwError('Unexpected font request'); + + expect(await new InlineFontsProcessor({ cache }).processURL(url)).toBe(content); + expect(getSpy).not.toHaveBeenCalled(); + expect(existsSync(cacheFile)).toBeTrue(); + }); + + it('should replace corrupted cache content with a downloaded stylesheet', async () => { + await mkdir(cacheDirectory, { recursive: true }); + await writeFile(cacheFile, '0'.repeat(64) + content); + + expect(await new InlineFontsProcessor({ cache }).processURL(url)).toBe(content); + expect(getSpy).toHaveBeenCalledTimes(1); + expect(await readFile(cacheFile, 'utf8')).toBe( + createHash('sha256').update(content).digest('hex') + content, + ); + }); + + it('should not cache stylesheets when the cache is disabled', async () => { + const processor = new InlineFontsProcessor({ cache: { ...cache, enabled: false } }); + + expect(await processor.processURL(url)).toBe(content); + expect(await processor.processURL(url)).toBe(content); + expect(getSpy).toHaveBeenCalledTimes(2); + expect(existsSync(cache.path)).toBeFalse(); + }); + + it('should still return the stylesheet when the cache cannot be written', async () => { + await writeFile(cache.path, 'not a directory'); + + expect(await new InlineFontsProcessor({ cache }).processURL(url)).toBe(content); + expect(getSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('Google fonts', () => { const content = `