diff --git a/.changeset/silly-flies-care.md b/.changeset/silly-flies-care.md new file mode 100644 index 00000000000..dc88c0fc85d --- /dev/null +++ b/.changeset/silly-flies-care.md @@ -0,0 +1,6 @@ +--- +"@pnpm/plugin-commands-script-runners": minor +"pnpm": minor +--- + +Support script selector with RegExp such as `pnpm run /build:.*/` and execute the matched scripts with the RegExp [#5871](https://github.com/pnpm/pnpm/pull/5871). diff --git a/exec/plugin-commands-script-runners/src/regexpCommand.ts b/exec/plugin-commands-script-runners/src/regexpCommand.ts new file mode 100644 index 00000000000..1519d6cddc5 --- /dev/null +++ b/exec/plugin-commands-script-runners/src/regexpCommand.ts @@ -0,0 +1,23 @@ +import { PnpmError } from '@pnpm/error' + +export function tryBuildRegExpFromCommand (command: string): RegExp | null { + // https://github.com/stdlib-js/regexp-regexp/blob/6428051ac9ef7c9d03468b19bdbb1dc6fc2a5509/lib/regexp.js + const regExpDetectRegExpScriptCommand = /^\/((?:\\\/|[^/])+)\/([dgimuys]*)$/ + const match = command.match(regExpDetectRegExpScriptCommand) + + // if the passed script selector is not in the format of RegExp literal like /build:.*/, return null and handle it as a string script command + if (!match) { + return null + } + + // if the passed RegExp script selector includes flag, report the error because RegExp flag is not useful for script selector and pnpm does not support this. + if (match[2]) { + throw new PnpmError('UNSUPPORTED_SCRIPT_COMMAND_FORMAT', 'RegExp flags are not supported in script command selector') + } + + try { + return new RegExp(match[1]) + } catch { + return null + } +} diff --git a/exec/plugin-commands-script-runners/src/run.ts b/exec/plugin-commands-script-runners/src/run.ts index c6e8e6e399a..b7741bd68e8 100644 --- a/exec/plugin-commands-script-runners/src/run.ts +++ b/exec/plugin-commands-script-runners/src/run.ts @@ -1,4 +1,5 @@ import path from 'path' +import pLimit from 'p-limit' import { docsUrl, readProjectManifestOnly, @@ -13,11 +14,11 @@ import { makeNodeRequireOption, RunLifecycleHookOptions, } from '@pnpm/lifecycle' -import { ProjectManifest } from '@pnpm/types' +import { PackageScripts, ProjectManifest } from '@pnpm/types' import pick from 'ramda/src/pick' import realpathMissing from 'realpath-missing' import renderHelp from 'render-help' -import { runRecursive, RecursiveRunOpts } from './runRecursive' +import { runRecursive, RecursiveRunOpts, getSpecifiedScripts as getSpecifiedScriptWithoutStartCommand } from './runRecursive' import { existsInDir } from './existsInDir' import { handler as exec } from './exec' @@ -43,6 +44,11 @@ export const RESUME_FROM_OPTION_HELP = { name: '--resume-from', } +export const SEQUENTIAL_OPTION_HELP = { + description: 'Run the specified scripts one by one', + name: '--sequential', +} + export const shorthands = { parallel: [ '--workspace-concurrency=Infinity', @@ -50,6 +56,9 @@ export const shorthands = { '--stream', '--recursive', ], + sequential: [ + '--workspace-concurrency=1', + ], } export function rcOptionsTypes () { @@ -112,6 +121,7 @@ For options that may be used with `-r`, see "pnpm help recursive"', PARALLEL_OPTION_HELP, RESUME_FROM_OPTION_HELP, ...UNIVERSAL_OPTIONS, + SEQUENTIAL_OPTION_HELP, ], }, FILTERING, @@ -159,7 +169,10 @@ export async function handler ( : undefined return printProjectCommands(manifest, rootManifest ?? undefined) } - if (scriptName !== 'start' && !manifest.scripts?.[scriptName]) { + + const specifiedScripts = getSpecifiedScripts(manifest.scripts ?? {}, scriptName) + + if (specifiedScripts.length < 1) { if (opts.ifPresent) return if (opts.fallbackCommandUsed) { if (opts.argv == null) throw new Error('Could not fallback because opts.argv.original was not passed to the script runner') @@ -170,9 +183,9 @@ export async function handler ( } if (opts.workspaceDir) { const { manifest: rootManifest } = await tryReadProjectManifest(opts.workspaceDir, opts) - if (rootManifest?.scripts?.[scriptName]) { + if (getSpecifiedScripts(rootManifest?.scripts ?? {}, scriptName).length > 0 && specifiedScripts.length < 1) { throw new PnpmError('NO_SCRIPT', `Missing script: ${scriptName}`, { - hint: `But ${scriptName} is present in the root of the workspace, + hint: `But script matched with ${scriptName} is present in the root of the workspace, so you may run "pnpm -w run ${scriptName}"`, }) } @@ -203,21 +216,11 @@ so you may run "pnpm -w run ${scriptName}"`, } } try { - if ( - opts.enablePrePostScripts && - manifest.scripts?.[`pre${scriptName}`] && - !manifest.scripts[scriptName].includes(`pre${scriptName}`) - ) { - await runLifecycleHook(`pre${scriptName}`, manifest, lifecycleOpts) - } - await runLifecycleHook(scriptName, manifest, { ...lifecycleOpts, args: passedThruArgs }) - if ( - opts.enablePrePostScripts && - manifest.scripts?.[`post${scriptName}`] && - !manifest.scripts[scriptName].includes(`post${scriptName}`) - ) { - await runLifecycleHook(`post${scriptName}`, manifest, lifecycleOpts) - } + const limitRun = pLimit(opts.workspaceConcurrency ?? 4) + + const _runScript = runScript.bind(null, { manifest, lifecycleOpts, runScriptOptions: { enablePrePostScripts: opts.enablePrePostScripts ?? false }, passedThruArgs }) + + await Promise.all(specifiedScripts.map(script => limitRun(() => _runScript(script)))) } catch (err: any) { // eslint-disable-line if (opts.bail !== false) { throw err @@ -300,6 +303,48 @@ ${renderCommands(rootScripts)}` return output } +export interface RunScriptOptions { + enablePrePostScripts: boolean +} + +export const runScript: (opts: { + manifest: ProjectManifest + lifecycleOpts: RunLifecycleHookOptions + runScriptOptions: RunScriptOptions + passedThruArgs: string[] +}, scriptName: string) => Promise = async function (opts, scriptName) { + if ( + opts.runScriptOptions.enablePrePostScripts && + opts.manifest.scripts?.[`pre${scriptName}`] && + !opts.manifest.scripts[scriptName].includes(`pre${scriptName}`) + ) { + await runLifecycleHook(`pre${scriptName}`, opts.manifest, opts.lifecycleOpts) + } + await runLifecycleHook(scriptName, opts.manifest, { ...opts.lifecycleOpts, args: opts.passedThruArgs }) + if ( + opts.runScriptOptions.enablePrePostScripts && + opts.manifest.scripts?.[`post${scriptName}`] && + !opts.manifest.scripts[scriptName].includes(`post${scriptName}`) + ) { + await runLifecycleHook(`post${scriptName}`, opts.manifest, opts.lifecycleOpts) + } +} + function renderCommands (commands: string[][]) { return commands.map(([scriptName, script]) => ` ${scriptName}\n ${script}`).join('\n') } + +function getSpecifiedScripts (scripts: PackageScripts, scriptName: string) { + const specifiedSelector = getSpecifiedScriptWithoutStartCommand(scripts, scriptName) + + if (specifiedSelector.length > 0) { + return specifiedSelector + } + + // if a user passes start command as scriptName, `node server.js` will be executed as a fallback, so return start command even if start command is not defined in package.json + if (scriptName === 'start') { + return [scriptName] + } + + return [] +} diff --git a/exec/plugin-commands-script-runners/src/runRecursive.ts b/exec/plugin-commands-script-runners/src/runRecursive.ts index d295c7f88e6..8b52af21ba1 100644 --- a/exec/plugin-commands-script-runners/src/runRecursive.ts +++ b/exec/plugin-commands-script-runners/src/runRecursive.ts @@ -3,7 +3,6 @@ import { RecursiveSummary, throwOnCommandFail } from '@pnpm/cli-utils' import { Config } from '@pnpm/config' import { PnpmError } from '@pnpm/error' import { - runLifecycleHook, makeNodeRequireOption, RunLifecycleHookOptions, } from '@pnpm/lifecycle' @@ -13,6 +12,9 @@ import pLimit from 'p-limit' import realpathMissing from 'realpath-missing' import { existsInDir } from './existsInDir' import { getResumedPackageChunks } from './exec' +import { runScript } from './run' +import { tryBuildRegExpFromCommand } from './regexpCommand' +import { PackageScripts } from '@pnpm/types' export type RecursiveRunOpts = Pick opts.selectedProjectsGraph[prefix]) - .filter((pkg) => !pkg.package.manifest.scripts?.[scriptName]) + .filter((pkg) => getSpecifiedScripts(pkg.package.manifest.scripts ?? {}, scriptName).length < 1) .map((pkg) => pkg.package.manifest.name ?? pkg.package.dir) if (missingScriptPackages.length) { throw new PnpmError('RECURSIVE_RUN_NO_SCRIPT', `Missing script "${scriptName}" in packages: ${missingScriptPackages.join(', ')}`) @@ -81,7 +83,14 @@ export async function runRecursive ( } for (const chunk of packageChunks) { - await Promise.all(chunk.map(async (prefix: string) => + const selectedScripts = chunk.map(prefix => { + const pkg = opts.selectedProjectsGraph[prefix] + const specifiedScripts = getSpecifiedScripts(pkg.package.manifest.scripts ?? {}, scriptName) + + return specifiedScripts.map(script => ({ prefix, scriptName: script })) + }).flat() + + await Promise.all(selectedScripts.map(async ({ prefix, scriptName }) => limitRun(async () => { const pkg = opts.selectedProjectsGraph[prefix] if ( @@ -113,21 +122,9 @@ export async function runRecursive ( ...makeNodeRequireOption(pnpPath), } } - if ( - opts.enablePrePostScripts && - pkg.package.manifest.scripts?.[`pre${scriptName}`] && - !pkg.package.manifest.scripts[scriptName].includes(`pre${scriptName}`) - ) { - await runLifecycleHook(`pre${scriptName}`, pkg.package.manifest, lifecycleOpts) - } - await runLifecycleHook(scriptName, pkg.package.manifest, { ...lifecycleOpts, args: passedThruArgs }) - if ( - opts.enablePrePostScripts && - pkg.package.manifest.scripts?.[`post${scriptName}`] && - !pkg.package.manifest.scripts[scriptName].includes(`post${scriptName}`) - ) { - await runLifecycleHook(`post${scriptName}`, pkg.package.manifest, lifecycleOpts) - } + + const _runScript = runScript.bind(null, { manifest: pkg.package.manifest, lifecycleOpts, runScriptOptions: { enablePrePostScripts: opts.enablePrePostScripts ?? false }, passedThruArgs }) + await _runScript(scriptName) result.passes++ } catch (err: any) { // eslint-disable-line logger.info(err) @@ -164,3 +161,20 @@ export async function runRecursive ( throwOnCommandFail('pnpm recursive run', result) } + +export function getSpecifiedScripts (scripts: PackageScripts, scriptName: string) { + // if scripts in package.json has script which is equal to scriptName a user passes, return it. + if (scripts[scriptName]) { + return [scriptName] + } + + const scriptSelector = tryBuildRegExpFromCommand(scriptName) + + // if scriptName which a user passes is RegExp (like /build:.*/), multiple scripts to execute will be selected with RegExp + if (scriptSelector) { + const scriptKeys = Object.keys(scripts) + return scriptKeys.filter(script => script.match(scriptSelector)) + } + + return [] +} diff --git a/exec/plugin-commands-script-runners/test/index.ts b/exec/plugin-commands-script-runners/test/index.ts index 416b3dba053..26a5f6528ef 100644 --- a/exec/plugin-commands-script-runners/test/index.ts +++ b/exec/plugin-commands-script-runners/test/index.ts @@ -411,7 +411,7 @@ test('if a script is not found but is present in the root, print an info message } expect(err).toBeTruthy() - expect(err.hint).toMatch(/But build is present in the root/) + expect(err.hint).toMatch(/But script matched with build is present in the root/) }) test('scripts work with PnP', async () => { @@ -465,3 +465,123 @@ test('pnpm run with custom shell', async () => { expect((await import(path.resolve('shell-input.json'))).default).toStrictEqual(['-c', 'foo bar']) }) + +test('pnpm run with RegExp script selector should work', async () => { + prepare({ + scripts: { + 'build:a': 'node -e "require(\'fs\').writeFileSync(\'./output-build-a.txt\', \'a\', \'utf8\')"', + 'build:b': 'node -e "require(\'fs\').writeFileSync(\'./output-build-b.txt\', \'b\', \'utf8\')"', + 'build:c': 'node -e "require(\'fs\').writeFileSync(\'./output-build-c.txt\', \'c\', \'utf8\')"', + build: 'node -e "require(\'fs\').writeFileSync(\'./output-build-a.txt\', \'should not run\', \'utf8\')"', + 'lint:a': 'node -e "require(\'fs\').writeFileSync(\'./output-lint-a.txt\', \'a\', \'utf8\')"', + 'lint:b': 'node -e "require(\'fs\').writeFileSync(\'./output-lint-b.txt\', \'b\', \'utf8\')"', + 'lint:c': 'node -e "require(\'fs\').writeFileSync(\'./output-lint-c.txt\', \'c\', \'utf8\')"', + lint: 'node -e "require(\'fs\').writeFileSync(\'./output-lint-a.txt\', \'should not run\', \'utf8\')"', + }, + }) + + await run.handler({ + dir: process.cwd(), + extraBinPaths: [], + extraEnv: {}, + rawConfig: {}, + }, ['/^(lint|build):.*/']) + + expect(await fs.readFile('output-build-a.txt', { encoding: 'utf-8' })).toEqual('a') + expect(await fs.readFile('output-build-b.txt', { encoding: 'utf-8' })).toEqual('b') + expect(await fs.readFile('output-build-c.txt', { encoding: 'utf-8' })).toEqual('c') + + expect(await fs.readFile('output-lint-a.txt', { encoding: 'utf-8' })).toEqual('a') + expect(await fs.readFile('output-lint-b.txt', { encoding: 'utf-8' })).toEqual('b') + expect(await fs.readFile('output-lint-c.txt', { encoding: 'utf-8' })).toEqual('c') +}) + +test('pnpm run with RegExp script selector should work also for pre/post script', async () => { + prepare({ + scripts: { + 'build:a': 'node -e "require(\'fs\').writeFileSync(\'./output-a.txt\', \'a\', \'utf8\')"', + 'prebuild:a': 'node -e "require(\'fs\').writeFileSync(\'./output-pre-a.txt\', \'pre-a\', \'utf8\')"', + }, + }) + + await run.handler({ + dir: process.cwd(), + extraBinPaths: [], + extraEnv: {}, + rawConfig: {}, + enablePrePostScripts: true, + }, ['/build:.*/']) + + expect(await fs.readFile('output-a.txt', { encoding: 'utf-8' })).toEqual('a') + expect(await fs.readFile('output-pre-a.txt', { encoding: 'utf-8' })).toEqual('pre-a') +}) + +test('pnpm run with RegExp script selector should work parallel as a default behavior (parallel execution limits number is four)', async () => { + prepare({ + scripts: { + 'build:a': 'node -e "let i = 20;setInterval(() => {if (!--i) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-a.json\');},50)"', + 'build:b': 'node -e "let i = 40;setInterval(() => {if (!--i) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-b.json\');},25)"', + }, + }) + + await execa('pnpm', ['add', 'json-append@1']) + + await run.handler({ + dir: process.cwd(), + extraBinPaths: [], + extraEnv: {}, + rawConfig: {}, + }, ['/build:.*/']) + + const { default: outputsA } = await import(path.resolve('output-a.json')) + const { default: outputsB } = await import(path.resolve('output-b.json')) + + expect(Math.max(outputsA[0], outputsB[0]) < Math.min(outputsA[outputsA.length - 1], outputsB[outputsB.length - 1])).toBeTruthy() +}) + +test('pnpm run with RegExp script selector should work sequentially with --workspace-concurrency=1', async () => { + prepare({ + scripts: { + 'build:a': 'node -e "let i = 2;setInterval(() => {if (!i--) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-a.json\');},16)"', + 'build:b': 'node -e "let i = 2;setInterval(() => {if (!i--) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-b.json\');},16)"', + }, + }) + + await execa('pnpm', ['add', 'json-append@1']) + + await run.handler({ + dir: process.cwd(), + extraBinPaths: [], + extraEnv: {}, + rawConfig: {}, + workspaceConcurrency: 1, + }, ['/build:.*/']) + + const { default: outputsA } = await import(path.resolve('output-a.json')) + const { default: outputsB } = await import(path.resolve('output-b.json')) + + expect(outputsA[0] < outputsB[0] && outputsA[1] < outputsB[1]).toBeTruthy() +}) + +test('pnpm run with RegExp script selector with flag should throw error', async () => { + prepare({ + scripts: { + 'build:a': 'node -e "let i = 2;setInterval(() => {if (!i--) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-a.json\');},16)"', + 'build:b': 'node -e "let i = 2;setInterval(() => {if (!i--) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-b.json\');},16)"', + }, + }) + + let err!: Error + try { + await run.handler({ + dir: process.cwd(), + extraBinPaths: [], + extraEnv: {}, + rawConfig: {}, + workspaceConcurrency: 1, + }, ['/build:.*/i']) + } catch (_err: any) { // eslint-disable-line + err = _err + } + expect(err.message).toBe('RegExp flags are not supported in script command selector') +}) diff --git a/exec/plugin-commands-script-runners/test/runRecursive.ts b/exec/plugin-commands-script-runners/test/runRecursive.ts index 2ccc80d2921..a06d8f3955a 100644 --- a/exec/plugin-commands-script-runners/test/runRecursive.ts +++ b/exec/plugin-commands-script-runners/test/runRecursive.ts @@ -1,3 +1,4 @@ +import { promises as fs } from 'fs' import path from 'path' import { preparePackages } from '@pnpm/prepare' import { run } from '@pnpm/plugin-commands-script-runners' @@ -890,3 +891,89 @@ test('`pnpm -r --resume-from run` should executed from given package', async () expect(output1).toContain('project-2') expect(output1).toContain('project-3') }) + +test('pnpm run with RegExp script selector should work on recursive', async () => { + preparePackages([ + { + name: 'project-1', + version: '1.0.0', + scripts: { + 'build:a': 'node -e "require(\'fs\').writeFileSync(\'../output-build-1-a.txt\', \'1-a\', \'utf8\')"', + 'build:b': 'node -e "require(\'fs\').writeFileSync(\'../output-build-1-b.txt\', \'1-b\', \'utf8\')"', + 'build:c': 'node -e "require(\'fs\').writeFileSync(\'../output-build-1-c.txt\', \'1-c\', \'utf8\')"', + build: 'node -e "require(\'fs\').writeFileSync(\'../output-build-1-a.txt\', \'should not run\', \'utf8\')"', + 'lint:a': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-1-a.txt\', \'1-a\', \'utf8\')"', + 'lint:b': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-1-b.txt\', \'1-b\', \'utf8\')"', + 'lint:c': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-1-c.txt\', \'1-c\', \'utf8\')"', + lint: 'node -e "require(\'fs\').writeFileSync(\'../output-lint-1-a.txt\', \'should not run\', \'utf8\')"', + }, + }, + { + name: 'project-2', + version: '1.0.0', + scripts: { + 'build:a': 'node -e "require(\'fs\').writeFileSync(\'../output-build-2-a.txt\', \'2-a\', \'utf8\')"', + 'build:b': 'node -e "require(\'fs\').writeFileSync(\'../output-build-2-b.txt\', \'2-b\', \'utf8\')"', + 'build:c': 'node -e "require(\'fs\').writeFileSync(\'../output-build-2-c.txt\', \'2-c\', \'utf8\')"', + build: 'node -e "require(\'fs\').writeFileSync(\'../output-build-2-a.txt\', \'should not run\', \'utf8\')"', + 'lint:a': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-2-a.txt\', \'2-a\', \'utf8\')"', + 'lint:b': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-2-b.txt\', \'2-b\', \'utf8\')"', + 'lint:c': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-2-c.txt\', \'2-c\', \'utf8\')"', + lint: 'node -e "require(\'fs\').writeFileSync(\'../output-lint-2-a.txt\', \'should not run\', \'utf8\')"', + }, + }, + { + name: 'project-3', + version: '1.0.0', + scripts: { + 'build:a': 'node -e "require(\'fs\').writeFileSync(\'../output-build-3-a.txt\', \'3-a\', \'utf8\')"', + 'build:b': 'node -e "require(\'fs\').writeFileSync(\'../output-build-3-b.txt\', \'3-b\', \'utf8\')"', + 'build:c': 'node -e "require(\'fs\').writeFileSync(\'../output-build-3-c.txt\', \'3-c\', \'utf8\')"', + build: 'node -e "require(\'fs\').writeFileSync(\'../output-build-3-a.txt\', \'should not run\', \'utf8\')"', + 'lint:a': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-3-a.txt\', \'3-a\', \'utf8\')"', + 'lint:b': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-3-b.txt\', \'3-b\', \'utf8\')"', + 'lint:c': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-3-c.txt\', \'3-c\', \'utf8\')"', + lint: 'node -e "require(\'fs\').writeFileSync(\'../output-lint-3-a.txt\', \'should not run\', \'utf8\')"', + }, + }, + ]) + + await execa(pnpmBin, [ + 'install', + '-r', + '--registry', + REGISTRY_URL, + '--store-dir', + path.resolve(DEFAULT_OPTS.storeDir), + ]) + await run.handler({ + ...DEFAULT_OPTS, + ...await readProjects(process.cwd(), [{ namePattern: '*' }]), + dir: process.cwd(), + recursive: true, + rootProjectManifest: { + name: 'test-workspaces', + private: true, + }, + workspaceDir: process.cwd(), + }, ['/^(lint|build):.*/']) + expect(await fs.readFile('output-build-1-a.txt', { encoding: 'utf-8' })).toEqual('1-a') + expect(await fs.readFile('output-build-1-b.txt', { encoding: 'utf-8' })).toEqual('1-b') + expect(await fs.readFile('output-build-1-c.txt', { encoding: 'utf-8' })).toEqual('1-c') + expect(await fs.readFile('output-build-2-a.txt', { encoding: 'utf-8' })).toEqual('2-a') + expect(await fs.readFile('output-build-2-b.txt', { encoding: 'utf-8' })).toEqual('2-b') + expect(await fs.readFile('output-build-2-c.txt', { encoding: 'utf-8' })).toEqual('2-c') + expect(await fs.readFile('output-build-3-a.txt', { encoding: 'utf-8' })).toEqual('3-a') + expect(await fs.readFile('output-build-3-b.txt', { encoding: 'utf-8' })).toEqual('3-b') + expect(await fs.readFile('output-build-3-c.txt', { encoding: 'utf-8' })).toEqual('3-c') + + expect(await fs.readFile('output-lint-1-a.txt', { encoding: 'utf-8' })).toEqual('1-a') + expect(await fs.readFile('output-lint-1-b.txt', { encoding: 'utf-8' })).toEqual('1-b') + expect(await fs.readFile('output-lint-1-c.txt', { encoding: 'utf-8' })).toEqual('1-c') + expect(await fs.readFile('output-lint-2-a.txt', { encoding: 'utf-8' })).toEqual('2-a') + expect(await fs.readFile('output-lint-2-b.txt', { encoding: 'utf-8' })).toEqual('2-b') + expect(await fs.readFile('output-lint-2-c.txt', { encoding: 'utf-8' })).toEqual('2-c') + expect(await fs.readFile('output-lint-3-a.txt', { encoding: 'utf-8' })).toEqual('3-a') + expect(await fs.readFile('output-lint-3-b.txt', { encoding: 'utf-8' })).toEqual('3-b') + expect(await fs.readFile('output-lint-3-c.txt', { encoding: 'utf-8' })).toEqual('3-c') +})