From ebabf979f26a43e267d6aeab155233da0a5f91ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 26 Sep 2025 13:21:07 +0200 Subject: [PATCH] fix(plugin-lighthouse): prevent cleanup permissions error on windows --- .../src/lib/runner/runner.ts | 5 +- .../plugin-lighthouse/src/lib/runner/utils.ts | 32 ++++++++++ .../src/lib/runner/utils.unit.test.ts | 62 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/plugin-lighthouse/src/lib/runner/runner.ts b/packages/plugin-lighthouse/src/lib/runner/runner.ts index 92d2ed1c9..61518f7d8 100644 --- a/packages/plugin-lighthouse/src/lib/runner/runner.ts +++ b/packages/plugin-lighthouse/src/lib/runner/runner.ts @@ -12,13 +12,14 @@ import { getConfig, normalizeAuditOutputs, toAuditOutputs, + withLocalTmpDir, } from './utils.js'; export function createRunnerFunction( urls: string[], flags: LighthouseCliFlags = DEFAULT_CLI_FLAGS, ): RunnerFunction { - return async (): Promise => { + return withLocalTmpDir(async (): Promise => { const config = await getConfig(flags); const normalizationFlags = enrichFlags(flags); const isSingleUrl = !shouldExpandForUrls(urls.length); @@ -58,7 +59,7 @@ export function createRunnerFunction( ); } return normalizeAuditOutputs(allResults, normalizationFlags); - }; + }); } async function runLighthouseForUrl( diff --git a/packages/plugin-lighthouse/src/lib/runner/utils.ts b/packages/plugin-lighthouse/src/lib/runner/utils.ts index 30ed5f51f..b1f34b711 100644 --- a/packages/plugin-lighthouse/src/lib/runner/utils.ts +++ b/packages/plugin-lighthouse/src/lib/runner/utils.ts @@ -6,13 +6,17 @@ import experimentalConfig from 'lighthouse/core/config/experimental-config.js'; import perfConfig from 'lighthouse/core/config/perf-config.js'; import type Details from 'lighthouse/types/lhr/audit-details'; import type { Result } from 'lighthouse/types/lhr/audit-result'; +import os from 'node:os'; +import path from 'node:path'; import type { AuditOutput, AuditOutputs } from '@code-pushup/models'; import { formatReportScore, importModule, + pluginWorkDir, readJsonFile, ui, } from '@code-pushup/utils'; +import { LIGHTHOUSE_PLUGIN_SLUG } from '../constants.js'; import type { LighthouseOptions } from '../types.js'; import { logUnsupportedDetails, toAuditDetails } from './details/details.js'; import type { LighthouseCliFlags } from './types.js'; @@ -167,3 +171,31 @@ export function enrichFlags( outputPath: urlSpecificOutputPath, }; } + +/** + * Wraps Lighthouse runner with `TEMP` directory override for Windows, to prevent permissions error on cleanup. + * + * `Runtime error encountered: EPERM, Permission denied: \\?\C:\Users\RUNNER~1\AppData\Local\Temp\lighthouse.57724617 '\\?\C:\Users\RUNNER~1\AppData\Local\Temp\lighthouse.57724617'` + * + * @param fn Async function which runs Lighthouse. + * @returns Wrapped function which overrides `TEMP` environment variable, before cleaning up afterwards. + */ +export function withLocalTmpDir(fn: () => Promise): () => Promise { + if (os.platform() !== 'win32') { + return fn; + } + + return async () => { + const originalTmpDir = process.env['TEMP']; + process.env['TEMP'] = path.join( + pluginWorkDir(LIGHTHOUSE_PLUGIN_SLUG), + 'tmp', + ); + + try { + return await fn(); + } finally { + process.env['TEMP'] = originalTmpDir; + } + }; +} diff --git a/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts index 93cdff927..17744d48b 100644 --- a/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts @@ -4,6 +4,7 @@ import log from 'lighthouse-logger'; import type Details from 'lighthouse/types/lhr/audit-details'; import type { Result } from 'lighthouse/types/lhr/audit-result'; import { vol } from 'memfs'; +import os from 'node:os'; import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -22,6 +23,7 @@ import { getConfig, normalizeAuditOutputs, toAuditOutputs, + withLocalTmpDir, } from './utils.js'; // mock bundleRequire inside importEsmModule used for fetching config @@ -502,3 +504,63 @@ describe('enrichFlags', () => { }); }); }); + +describe('withLocalTmpDir', () => { + it('should return unchanged function on Linux', () => { + vi.spyOn(os, 'platform').mockReturnValue('linux'); + const runner = vi.fn().mockResolvedValue('result'); + + expect(withLocalTmpDir(runner)).toBe(runner); + }); + + it('should return unchanged function on MacOS', () => { + vi.spyOn(os, 'platform').mockReturnValue('darwin'); + const runner = vi.fn().mockResolvedValue('result'); + + expect(withLocalTmpDir(runner)).toBe(runner); + }); + + it('should wrap function on Windows', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + const runner = vi.fn().mockResolvedValue('result'); + + const transformed = withLocalTmpDir(runner); + + expect(transformed).not.toBe(runner); + await expect(transformed()).resolves.toBe('result'); + expect(runner).toHaveBeenCalled(); + }); + + it('should override TEMP environment variable before function call', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + const runner = vi + .fn() + .mockImplementation( + async () => `TEMP directory is ${process.env['TEMP']}`, + ); + + await expect(withLocalTmpDir(runner)()).resolves.toBe( + `TEMP directory is ${path.join('node_modules', '.code-pushup', 'lighthouse', 'tmp')}`, + ); + }); + + it('should reset TEMP environment variable after function resolves', async () => { + const originalTmpDir = String.raw`\\?\C:\Users\RUNNER~1\AppData\Local\Temp`; + const runner = vi.fn().mockResolvedValue('result'); + vi.spyOn(os, 'platform').mockReturnValue('win32'); + vi.stubEnv('TEMP', originalTmpDir); + + await withLocalTmpDir(runner)(); + + expect(process.env['TEMP']).toBe(originalTmpDir); + }); + + it('should reset TEMP environment variable after function rejects', async () => { + const runner = vi.fn().mockRejectedValue('error'); + vi.spyOn(os, 'platform').mockReturnValue('win32'); + vi.stubEnv('TEMP', ''); + + await expect(withLocalTmpDir(runner)()).rejects.toBe('error'); + expect(process.env['TEMP']).toBe(''); + }); +});