diff --git a/packages/core/spec/FakeFS.ts b/packages/core/spec/FakeFS.ts index cf9347b8e19..05bd95d5ce8 100644 --- a/packages/core/spec/FakeFS.ts +++ b/packages/core/spec/FakeFS.ts @@ -1,44 +1,12 @@ -import fs = require('fs'); -import path = require('upath'); -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { createFsFromVolume, Volume } = require('memfs'); // Typings incorrectly assume the presence of "dom" lib +import type * as fs from 'node:fs'; -export interface DirectoryStructure { - [ key: string ]: DirectoryStructure | string; -} +import type { NestedDirectoryJSON} from 'memfs'; +import { createFsFromVolume, Volume } from 'memfs'; export class FakeFS { public static readonly Empty_Directory = {}; - static with(tree: DirectoryStructure, cwd = process.cwd()): typeof fs { - - function flatten( - currentStructure: DirectoryStructure | string, - currentPath = '', - ): { [key: string]: string } { - - if (typeof currentStructure === 'string') { - return ({ - [ currentPath ]: currentStructure, - }); - } - - const entries = Object.keys(currentStructure); - - if (entries.length === 0) { - return ({ - [ currentPath ]: void 0, - }); - } - - return Object.keys(currentStructure).reduce((acc, key) => { - return ({ - ...acc, - ...flatten(currentStructure[key], path.join(currentPath, key)), - }); - }, {}); - } - - return createFsFromVolume(Volume.fromJSON(flatten(tree), cwd)) as typeof fs; + static with(tree: NestedDirectoryJSON, cwd = process.cwd()): typeof fs { + return createFsFromVolume(Volume.fromNestedJSON(tree, cwd)) as unknown as typeof fs; } } diff --git a/packages/core/spec/io/FileSystem.spec.ts b/packages/core/spec/io/FileSystem.spec.ts index 321a11a2d72..35dd072b66c 100644 --- a/packages/core/spec/io/FileSystem.spec.ts +++ b/packages/core/spec/io/FileSystem.spec.ts @@ -78,8 +78,8 @@ describe ('FileSystem', () => { it (`complains when the file can't be written`, () => { const fs = FakeFS.with(FakeFS.Empty_Directory); - (fs as any).writeFile = () => { // memfs doesn't support mocking error conditions or permissions - throw new Error('EACCES, permission denied'); + (fs as any).promises.writeFile = () => { // memfs doesn't support mocking error conditions or permissions + return Promise.reject(new Error('EACCES, permission denied')); }; const out = new FileSystem(new Path('/'), fs); @@ -119,6 +119,78 @@ describe ('FileSystem', () => { }); }); + describe('when reading files', () => { + + describe('synchronously', () => { + + it('returns the contents', async () => { + const + fs = FakeFS.with({ + [processCWD.value]: { + 'file.txt': 'contents' + }, + }), + out = new FileSystem(processCWD, fs); + + const result = out.readFileSync(new Path('file.txt'), { encoding: 'utf8' }); + + expect(result).to.equal('contents'); + }); + }); + + describe('asynchronously', () => { + + it('returns a Promise of the contents', async () => { + const + fs = FakeFS.with({ + [processCWD.value]: { + 'file.txt': 'contents' + }, + }), + out = new FileSystem(processCWD, fs); + + const result = await out.readFile(new Path('file.txt'), { encoding: 'utf8' }); + + expect(result).to.equal('contents'); + }); + }); + }); + + describe('when writing files', () => { + + describe('synchronously', () => { + + it('writes the contents', async () => { + const + fs = FakeFS.with({ + [processCWD.value]: { + }, + }), + out = new FileSystem(processCWD, fs); + + const absolutePathToFile = out.writeFileSync(new Path('file.txt'), 'contents', { encoding: 'utf8' }); + + expect(absolutePathToFile).to.equal(processCWD.resolve(new Path('file.txt'))); + }); + }); + + describe('asynchronously', () => { + + it('writes the contents', async () => { + const + fs = FakeFS.with({ + [processCWD.value]: { + }, + }), + out = new FileSystem(processCWD, fs); + + const absolutePathToFile = await out.writeFile(new Path('file.txt'), 'contents', { encoding: 'utf8' }); + + expect(absolutePathToFile).to.equal(processCWD.resolve(new Path('file.txt'))); + }); + }); + }); + describe ('when removing', () => { describe('individual files', () => { diff --git a/packages/core/spec/io/Version.spec.ts b/packages/core/spec/io/Version.spec.ts index 9a256cd86d1..3c8bae67dab 100644 --- a/packages/core/spec/io/Version.spec.ts +++ b/packages/core/spec/io/Version.spec.ts @@ -1,4 +1,5 @@ import { describe, it } from 'mocha'; +import { given } from 'mocha-testdata'; import { Version } from '../../src/io'; import { expect } from '../expect'; @@ -9,11 +10,65 @@ describe('Version', () => { expect(new Version('1.2.3').isAtLeast(new Version('1.0.0'))).to.equal(true); }); - it('grants access to the major version number', () => { + it('tells the major version number', () => { expect(new Version('1.2.3').major()).to.equal(1); }); it('provides a sensible description', () => { expect(new Version('1.2.3').toString()).to.equal('1.2.3'); }); + + given([ + { range: '1.x', expected: true }, + { range: '0.x || >=1.2', expected: true }, + { range: '^1', expected: true }, + { range: '0.x || 2.x', expected: false }, + ]). + it('checks if the version satisfies a given range', ({ range, expected }) => { + expect(new Version('1.2.3').satisfies(range)).to.equal(expected); + }); + + given([ + { version: '1.2.2', expected: true }, + { version: '1.2.3', expected: false }, + { version: '1.2.4', expected: false }, + ]). + it('tells if it is lower than another version', ({ version, expected }) => { + expect(new Version(version).isLowerThan(new Version('1.2.3'))).to.equal(expected); + }); + + given([ + { version: '1.2.2', expected: true }, + { version: '1.2.3', expected: true }, + { version: '1.2.4', expected: false }, + ]). + it('tells if it is at most another version', ({ version, expected }) => { + expect(new Version(version).isAtMost(new Version('1.2.3'))).to.equal(expected); + }); + + given([ + { version: '1.2.2', expected: false }, + { version: '1.2.3', expected: true }, + { version: '1.2.4', expected: true }, + ]). + it('tells if it is at least another version', ({ version, expected }) => { + expect(new Version(version).isAtLeast(new Version('1.2.3'))).to.equal(expected); + }); + + given([ + { version: '1.2.2', expected: false }, + { version: '1.2.3', expected: false }, + { version: '1.2.4', expected: true }, + ]). + it('tells if it is higher than another version', ({ version, expected }) => { + expect(new Version(version).isHigherThan(new Version('1.2.3'))).to.equal(expected); + }); + + it('tells if it is equal to another version', () => { + expect(new Version('1.2.3').equals(new Version('1.2.3'))).to.equal(true); + }); + + it('can be created from a JSON string', () => { + expect(Version.fromJSON('1.2.3')).to.equal(new Version('1.2.3')); + }); }); diff --git a/packages/core/src/io/FileSystem.ts b/packages/core/src/io/FileSystem.ts index fc45393b708..c648988311d 100644 --- a/packages/core/src/io/FileSystem.ts +++ b/packages/core/src/io/FileSystem.ts @@ -1,9 +1,8 @@ +import * as nodeOS from 'node:os'; + import { createId } from '@paralleldrive/cuid2'; -import type * as nodeFS from 'fs'; -import type { WriteFileOptions } from 'fs'; +import type * as NodeFS from 'fs'; import * as gracefulFS from 'graceful-fs'; -import * as nodeOS from 'os'; -import { promisify } from 'util'; import { Path } from './Path'; @@ -11,7 +10,7 @@ export class FileSystem { constructor( private readonly root: Path, - private readonly fs: typeof nodeFS = gracefulFS, + private readonly fs: typeof NodeFS = gracefulFS, private readonly os: typeof nodeOS = nodeOS, private readonly directoryMode = Number.parseInt('0777', 8) & (~process.umask()), ) { @@ -21,108 +20,93 @@ export class FileSystem { return this.root.resolve(relativeOrAbsolutePath); } - public store(relativeOrAbsolutePathToFile: Path, data: string | NodeJS.ArrayBufferView, encoding?: WriteFileOptions): Promise { - return Promise.resolve() - .then(() => this.ensureDirectoryExistsAt(relativeOrAbsolutePathToFile.directory())) - .then(() => this.write(this.resolve(relativeOrAbsolutePathToFile), data, encoding)); + public async store(relativeOrAbsolutePathToFile: Path, data: string | NodeJS.ArrayBufferView, encoding?: NodeFS.WriteFileOptions): Promise { + await this.ensureDirectoryExistsAt(relativeOrAbsolutePathToFile.directory()); + return this.writeFile(relativeOrAbsolutePathToFile, data, encoding); + } + + public readFile(relativeOrAbsolutePathToFile: Path, options?: { encoding?: null | undefined; flag?: string | undefined; }): Promise + public readFile(relativeOrAbsolutePathToFile: Path, options: { encoding: BufferEncoding; flag?: string | undefined; } | NodeJS.BufferEncoding): Promise + public readFile(relativeOrAbsolutePathToFile: Path, options?: (NodeFS.ObjectEncodingOptions & { flag?: string | undefined; }) | NodeJS.BufferEncoding): Promise { + return this.fs.promises.readFile(this.resolve(relativeOrAbsolutePathToFile).value, options); + } + + public readFileSync(relativeOrAbsolutePathToFile: Path, options?: { encoding?: null | undefined; flag?: string | undefined; }): Buffer + public readFileSync(relativeOrAbsolutePathToFile: Path, options: { encoding: BufferEncoding; flag?: string | undefined; } | NodeJS.BufferEncoding): string + public readFileSync(relativeOrAbsolutePathToFile: Path, options?: (NodeFS.ObjectEncodingOptions & { flag?: string | undefined; }) | NodeJS.BufferEncoding): string | Buffer { + return this.fs.readFileSync(this.resolve(relativeOrAbsolutePathToFile).value, options); } - public createReadStream(relativeOrAbsolutePathToFile: Path): nodeFS.ReadStream { + public async writeFile(relativeOrAbsolutePathToFile: Path, data: string | NodeJS.ArrayBufferView, options?: NodeFS.WriteFileOptions): Promise { + const resolvedPath = this.resolve(relativeOrAbsolutePathToFile); + await this.fs.promises.writeFile(resolvedPath.value, data, options); + + return resolvedPath; + } + + public writeFileSync(relativeOrAbsolutePathToFile: Path, data: string | NodeJS.ArrayBufferView, options?: NodeFS.WriteFileOptions): Path { + const resolvedPath = this.resolve(relativeOrAbsolutePathToFile); + this.fs.writeFileSync(resolvedPath.value, data, options); + + return resolvedPath; + } + + public createReadStream(relativeOrAbsolutePathToFile: Path): NodeFS.ReadStream { return this.fs.createReadStream(this.resolve(relativeOrAbsolutePathToFile).value); } - public createWriteStreamTo(relativeOrAbsolutePathToFile: Path): nodeFS.WriteStream { + public createWriteStreamTo(relativeOrAbsolutePathToFile: Path): NodeFS.WriteStream { return this.fs.createWriteStream(this.resolve(relativeOrAbsolutePathToFile).value); } - public stat(relativeOrAbsolutePathToFile: Path): Promise { - const stat = promisify(this.fs.stat); - - return stat(this.resolve(relativeOrAbsolutePathToFile).value); + public stat(relativeOrAbsolutePathToFile: Path): Promise { + return this.fs.promises.stat(this.resolve(relativeOrAbsolutePathToFile).value); } public exists(relativeOrAbsolutePathToFile: Path): boolean { return this.fs.existsSync(this.resolve(relativeOrAbsolutePathToFile).value); } - public remove(relativeOrAbsolutePathToFileOrDirectory: Path): Promise { - const - stat = promisify(this.fs.stat), - unlink = promisify(this.fs.unlink), - readdir = promisify(this.fs.readdir), - rmdir = promisify(this.fs.rmdir); - - const absolutePath = this.resolve(relativeOrAbsolutePathToFileOrDirectory); - - return stat(absolutePath.value) - .then(result => - result.isFile() - ? unlink(absolutePath.value) - : readdir(absolutePath.value) - .then(entries => - Promise.all(entries.map(entry => - this.remove(absolutePath.join(new Path(entry)))), - ).then(() => rmdir(absolutePath.value)), - ), - ) - .then(() => void 0) - .catch(error => { - if (error?.code === 'ENOENT') { - return void 0; + public async remove(relativeOrAbsolutePathToFileOrDirectory: Path): Promise { + try { + const absolutePath = this.resolve(relativeOrAbsolutePathToFileOrDirectory); + + const stat = await this.stat(relativeOrAbsolutePathToFileOrDirectory); + + if (stat.isFile()) { + await this.fs.promises.unlink(absolutePath.value); + } + else { + const entries = await this.fs.promises.readdir(absolutePath.value); + for (const entry of entries) { + await this.remove(absolutePath.join(new Path(entry))); } - throw error; - }); + + await this.fs.promises.rmdir(absolutePath.value); + } + } + catch (error) { + if (error?.code === 'ENOENT') { + return void 0; + } + throw error; + } } - public ensureDirectoryExistsAt(relativeOrAbsolutePathToDirectory: Path): Promise { + public async ensureDirectoryExistsAt(relativeOrAbsolutePathToDirectory: Path): Promise { const absolutePath = this.resolve(relativeOrAbsolutePathToDirectory); - return absolutePath.split().reduce((promisedParent, child) => { - return promisedParent.then(parent => new Promise((resolve, reject) => { - const current = parent.resolve(new Path(child)); - - this.fs.mkdir(current.value, this.directoryMode, error => { - if (! error || error.code === 'EEXIST') { - return resolve(current); - } - - // To avoid `EISDIR` error on Mac and `EACCES`-->`ENOENT` and `EPERM` on Windows. - if (error.code === 'ENOENT') { // Throw the original parentDir error on `current` `ENOENT` failure. - throw new Error(`EACCES: permission denied, mkdir '${ parent.value }'`); - } - - const caughtError = !! ~['EACCES', 'EPERM', 'EISDIR'].indexOf(error.code); - if (! caughtError || (caughtError && current.equals(relativeOrAbsolutePathToDirectory))) { - throw error; // Throw if it's just the last created dir. - } - - return resolve(current); - }); - })); - }, Promise.resolve(absolutePath.root())); + await this.fs.promises.mkdir(absolutePath.value, { recursive: true, mode: this.directoryMode }); + + return absolutePath; } public rename(source: Path, destination: Path): Promise { - const rename = promisify(this.fs.rename); - - return rename(source.value, destination.value); + return this.fs.promises.rename(source.value, destination.value); } public tempFilePath(prefix = '', suffix = '.tmp'): Path { return Path.from(this.fs.realpathSync(this.os.tmpdir()), `${ prefix }${ createId() }${ suffix }`); } - - private write(path: Path, data: string | NodeJS.ArrayBufferView, encoding?: WriteFileOptions): Promise { - return new Promise((resolve, reject) => { - this.fs.writeFile( - path.value, - data, - encoding, - error => error - ? reject(error) - : resolve(path), - ); - }); - } } diff --git a/packages/core/src/io/Version.ts b/packages/core/src/io/Version.ts new file mode 100644 index 00000000000..bcc4295f187 --- /dev/null +++ b/packages/core/src/io/Version.ts @@ -0,0 +1,61 @@ +import semver from 'semver'; +import { ensure, isDefined, isString, Predicate, TinyType } from 'tiny-types'; + +/** + * A tiny type describing a version number, like `1.2.3` + */ +export class Version extends TinyType { + + static fromJSON(version: string): Version { + return new Version(version); + } + + constructor(private readonly version: string) { + super(); + ensure('version', version, isDefined(), isString(), isValid()); + } + + isLowerThan(other: Version): boolean { + return semver.lt(this.version, other.version, { loose: false }); + } + + isAtMost(other: Version): boolean { + return semver.lte(this.version, other.version, { loose: false }); + } + + /** + * @param other + */ + isAtLeast(other: Version): boolean { + return semver.gte(this.version, other.version, { loose: false }); + } + + isHigherThan(other: Version): boolean { + return semver.gt(this.version, other.version, { loose: false }); + } + + /** + * @returns + * Major version number of a given package version, i.e. `1` in `1.2.3` + */ + major(): number { + return Number(this.version.split('.')[0]); + } + + satisfies(range: string): boolean { + return semver.satisfies(this.version, range, { loose: false }); + } + + toString(): string { + return `${ this.version }`; + } +} + +/** + * @package + */ +function isValid(): Predicate { + return Predicate.to(`be a valid version number`, (version: string) => + !! semver.valid(version), + ); +} diff --git a/packages/core/src/io/index.ts b/packages/core/src/io/index.ts index c0b3ac93623..66bb23deda6 100644 --- a/packages/core/src/io/index.ts +++ b/packages/core/src/io/index.ts @@ -12,3 +12,4 @@ export * from './Path'; export * from './reflection'; export * from './RequirementsHierarchy'; export * from './trimmed'; +export * from './Version'; diff --git a/packages/core/src/io/loader/ModuleLoader.ts b/packages/core/src/io/loader/ModuleLoader.ts index 2b1619f6006..baf014c16f9 100644 --- a/packages/core/src/io/loader/ModuleLoader.ts +++ b/packages/core/src/io/loader/ModuleLoader.ts @@ -2,7 +2,7 @@ const Module = require('module'); // No type definitions available import * as path from 'path'; // eslint-disable-line unicorn/import-style -import { Version } from './Version'; +import { Version } from '../Version'; /** * Dynamically loads Node modules located relative to `cwd`. @@ -12,8 +12,14 @@ export class ModuleLoader { /** * @param {string} cwd * Current working directory, relative to which Node modules should be resolved. + * @param useRequireCache + * Whether to use Node's `require.cache`. Defaults to `true`. + * Set to `false` to force Node to reload required modules on every call. */ - constructor(public readonly cwd: string) { + constructor( + public readonly cwd: string, + public readonly useRequireCache: boolean = true, + ) { } /** @@ -37,7 +43,7 @@ export class ModuleLoader { * NPM module id, for example `cucumber` or `@serenity-js/core` * * @returns - * Path a given Node module + * Path to a given Node.js module */ resolve(moduleId: string): string { const fromFile = path.join(this.cwd, 'noop.js'); @@ -56,13 +62,21 @@ export class ModuleLoader { */ require(moduleId: string): any { try { - return require(this.resolve(moduleId)); + return require(this.cachedIfNeeded(this.resolve(moduleId))); } catch { - return require(moduleId); + return require(this.cachedIfNeeded(moduleId)); } } + private cachedIfNeeded(moduleId: string): string { + if (! this.useRequireCache) { + delete require.cache[moduleId]; + } + + return moduleId; + } + /** * Returns {@apilink Version} of module specified by `moduleId`, based on its `package.json`. * diff --git a/packages/core/src/io/loader/index.ts b/packages/core/src/io/loader/index.ts index 24e3931830c..ef7d83534a5 100644 --- a/packages/core/src/io/loader/index.ts +++ b/packages/core/src/io/loader/index.ts @@ -2,4 +2,3 @@ export * from './ClassDescriptionParser'; export * from './ClassDescriptor'; export * from './ClassLoader'; export * from './ModuleLoader'; -export * from './Version';