From de4b11910dface9d54bb206317b6142927c806bd Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 5 Mar 2024 09:49:14 -0700 Subject: [PATCH] Assign unique name to sandbox directory (#171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `withinSandbox` is designed for tests that need to act on the filesystem. It creates a temporary directory and passes it to the function so that it can do whatever it needs to do within the directory in a safer fashion. The name of the sandbox directory is generated from the current time to ensure that each one is unique. However, this causes problems when Jest is running more than one test file, each of which make use of the sandbox. Jest runs test files in parallel, so paired with the naming — and the fact that time can be frozen in tests — it is possible for two tests to create and use the same sandbox simultaneously. `withinSandbox` double-checks that the directory it would have created does not already exist, so when the first test creates the directory it will cause the second test to fail. Also, since `withinSandbox` removes the sandbox directory after it runs its function, this will also cause the second test to fail if it's still running and using that directory. To fix this, this commit changes `withinSandbox` to use a UUID to name the sandbox directory instead of the current time. --- package.json | 4 +++- src/fs.test.ts | 51 ++++++++++++++++++++++++++++++++------------------ src/fs.ts | 4 ++-- yarn.lock | 18 ++++++++++++++++++ 4 files changed, 56 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 0a47eb8fb..99a5fd4ae 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,8 @@ "debug": "^4.3.4", "pony-cause": "^2.1.10", "semver": "^7.5.4", - "superstruct": "^1.0.3" + "superstruct": "^1.0.3", + "uuid": "^9.0.1" }, "devDependencies": { "@lavamoat/allow-scripts": "^2.3.1", @@ -72,6 +73,7 @@ "@types/jest": "^28.1.7", "@types/jest-when": "^3.5.3", "@types/node": "^17.0.23", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^5.43.0", "@typescript-eslint/parser": "^5.43.0", "depcheck": "^1.4.7", diff --git a/src/fs.test.ts b/src/fs.test.ts index bfab32edf..cffb5a885 100644 --- a/src/fs.test.ts +++ b/src/fs.test.ts @@ -3,6 +3,7 @@ import { when } from 'jest-when'; import os from 'os'; import path from 'path'; import util from 'util'; +import * as uuid from 'uuid'; import { createSandbox, @@ -18,6 +19,16 @@ import { const { withinSandbox } = createSandbox('utils'); +// Clone the `uuid` module so that we can spy on its exports +jest.mock('uuid', () => { + return { + // This is how to mock an ES-compatible module in Jest. + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + ...jest.requireActual('uuid'), + }; +}); + describe('fs', () => { describe('readFile', () => { it('reads the contents of the given file as a UTF-8-encoded string', async () => { @@ -674,9 +685,14 @@ describe('fs', () => { }); it('does not create the sandbox directory immediately', async () => { + jest.spyOn(uuid, 'v4').mockReturnValue('AAAA-AAAA-AAAA-AAAA'); createSandbox('utils-fs'); - const sandboxDirectoryPath = path.join(os.tmpdir(), 'utils-fs'); + const sandboxDirectoryPath = path.join( + os.tmpdir(), + 'utils-fs', + 'AAAA-AAAA-AAAA-AAAA', + ); await expect(fs.promises.stat(sandboxDirectoryPath)).rejects.toThrow( 'ENOENT', @@ -686,16 +702,15 @@ describe('fs', () => { describe('withinSandbox', () => { it('creates the sandbox directory and keeps it around before its given function ends', async () => { expect.assertions(1); - - const nowTimestamp = new Date('2023-01-01').getTime(); - jest.setSystemTime(nowTimestamp); + jest.spyOn(uuid, 'v4').mockReturnValue('AAAA-AAAA-AAAA-AAAA'); const { withinSandbox: withinTestSandbox } = createSandbox('utils-fs'); - const sandboxDirectoryPath = path.join( - os.tmpdir(), - `utils-fs--${nowTimestamp}`, - ); await withinTestSandbox(async () => { + const sandboxDirectoryPath = path.join( + os.tmpdir(), + 'utils-fs', + 'AAAA-AAAA-AAAA-AAAA', + ); expect(await fs.promises.stat(sandboxDirectoryPath)).toStrictEqual( expect.anything(), ); @@ -703,32 +718,32 @@ describe('fs', () => { }); it('removes the sandbox directory after its given function ends', async () => { - const nowTimestamp = new Date('2023-01-01').getTime(); - jest.setSystemTime(nowTimestamp); + jest.spyOn(uuid, 'v4').mockReturnValue('AAAA-AAAA-AAAA-AAAA'); const { withinSandbox: withinTestSandbox } = createSandbox('utils-fs'); - const sandboxDirectoryPath = path.join( - os.tmpdir(), - `utils-fs--${nowTimestamp}`, - ); await withinTestSandbox(async () => { // do nothing }); + const sandboxDirectoryPath = path.join( + os.tmpdir(), + 'utils-fs', + 'AAAA-AAAA-AAAA-AAAA', + ); await expect(fs.promises.stat(sandboxDirectoryPath)).rejects.toThrow( 'ENOENT', ); }); it('throws if the sandbox directory already exists', async () => { - const nowTimestamp = new Date('2023-01-01').getTime(); - jest.setSystemTime(nowTimestamp); + jest.spyOn(uuid, 'v4').mockReturnValue('AAAA-AAAA-AAAA-AAAA'); const { withinSandbox: withinTestSandbox } = createSandbox('utils-fs'); + const sandboxDirectoryPath = path.join( os.tmpdir(), - `utils-fs--${nowTimestamp}`, + 'utils-fs', + 'AAAA-AAAA-AAAA-AAAA', ); - try { await fs.promises.mkdir(sandboxDirectoryPath); diff --git a/src/fs.ts b/src/fs.ts index 0f6b4ef3c..6df5b60ec 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import * as uuid from 'uuid'; import { isErrorWithCode, wrapError } from './errors'; import type { Json } from './json'; @@ -240,8 +241,7 @@ export async function forceRemove(entryPath: string): Promise { * ``` */ export function createSandbox(projectName: string): FileSandbox { - const timestamp = new Date().getTime(); - const directoryPath = path.join(os.tmpdir(), `${projectName}--${timestamp}`); + const directoryPath = path.join(os.tmpdir(), projectName, uuid.v4()); return { directoryPath, diff --git a/yarn.lock b/yarn.lock index 2c3c6ca82..93b091378 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1226,6 +1226,7 @@ __metadata: "@types/jest": ^28.1.7 "@types/jest-when": ^3.5.3 "@types/node": ^17.0.23 + "@types/uuid": ^9.0.8 "@typescript-eslint/eslint-plugin": ^5.43.0 "@typescript-eslint/parser": ^5.43.0 debug: ^4.3.4 @@ -1253,6 +1254,7 @@ __metadata: tsup: ^7.2.0 typedoc: ^0.23.15 typescript: ~4.8.4 + uuid: ^9.0.1 languageName: unknown linkType: soft @@ -1694,6 +1696,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^9.0.8": + version: 9.0.8 + resolution: "@types/uuid@npm:9.0.8" + checksum: b8c60b7ba8250356b5088302583d1704a4e1a13558d143c549c408bf8920535602ffc12394ede77f8a8083511b023704bc66d1345792714002bfa261b17c5275 + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.0 resolution: "@types/yargs-parser@npm:21.0.0" @@ -7673,6 +7682,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^9.0.1": + version: 9.0.1 + resolution: "uuid@npm:9.0.1" + bin: + uuid: dist/bin/uuid + checksum: 39931f6da74e307f51c0fb463dc2462807531dc80760a9bff1e35af4316131b4fc3203d16da60ae33f07fdca5b56f3f1dd662da0c99fea9aaeab2004780cc5f4 + languageName: node + linkType: hard + "v8-compile-cache-lib@npm:^3.0.1": version: 3.0.1 resolution: "v8-compile-cache-lib@npm:3.0.1"