Skip to content

Commit

Permalink
Assign unique name to sandbox directory (#171)
Browse files Browse the repository at this point in the history
`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.
  • Loading branch information
mcmire authored Mar 5, 2024
1 parent ec0237c commit de4b119
Show file tree
Hide file tree
Showing 4 changed files with 56 additions and 21 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
51 changes: 33 additions & 18 deletions src/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand All @@ -686,49 +702,48 @@ 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(),
);
});
});

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);

Expand Down
4 changes: 2 additions & 2 deletions src/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -240,8 +241,7 @@ export async function forceRemove(entryPath: string): Promise<void> {
* ```
*/
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,
Expand Down
18 changes: 18 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit de4b119

Please sign in to comment.