import url from 'url'; import util from 'util'; import {tmpdir} from 'os'; import {randomBytes} from 'crypto'; import {PassThrough} from 'stream'; import {spawn} from 'child_process'; import {relative, join, resolve} from 'path'; import {promises as fs, constants as fs_constants, createWriteStream} from 'fs'; import fileMgr from '../src/file_mgr.js'; const maybeStat = path => fs.stat(path).catch(() => false); const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); function sed(fn) { return new PassThrough({ write(chunk, _, cb) { this.buf = Buffer.concat([this.buf || Buffer.alloc(0), chunk]); let eol; while (~(eol = this.buf.indexOf(0x0a))) { this.push(fn(this.buf.slice(0, eol + 1))); this.buf = this.buf.slice(eol + 1); } cb(null); }, final(cb) { this.push(this.buf); cb(); }, }); } function tee(stream1, stream2) { let stream = new PassThrough(); stream.pipe(stream1); stream.pipe(stream2); return stream; } let abortCode = Symbol('ExitCode'); async function pRetry(tries, fn) { let result, rawErr, abortSymbol = Symbol('RetryAbort'); for (let [i] of Array.apply(null, {length: tries}).entries()) { try { return await fn(i + 1, rawErr, blob => { if (blob && abortCode in blob) (result = Promise.reject(blob)).catch(() => {}); throw abortSymbol; }); } catch (err) { if (err === abortSymbol) break; (result = Promise.reject((rawErr = err))).catch(() => {}); } } return result; } function short_path(path) { let a = resolve(path); let b = relative(process.cwd(), path); if (!['..', '/'].some(c => b.startsWith(c))) b = `./${b}`; return a.length < b.length ? a : b; } const default_stage = join(tmpdir(), 'freyr-test'); async function run_tests(suite, args, i) { let docker_image; if (~(i = args.indexOf('--docker')) && !(docker_image = args.splice(i, 2)[1])) throw new Error('`--docker` requires an image name'); let stage_name = randomBytes(6).toString('hex'); if (~(i = args.indexOf('--name')) && !(stage_name = args.splice(i, 2)[1])) throw new Error('`--name` requires a stage name'); let stage_path = default_stage; if (~(i = args.indexOf('--stage')) && !(stage_path = args.splice(i, 2)[1])) throw new Error('`--stage` requires a path'); stage_path = resolve(join(stage_path, stage_name)); let force, clean; if ((force = !!~(i = args.indexOf('--force')))) args.splice(i, 1); if ((clean = !!~(i = args.indexOf('--clean')))) args.splice(i, 1); if (await maybeStat(stage_path)) if (!force) throw new Error(`stage path [${stage_path}] already exists`); else if (clean) await fs.rm(stage_path, {recursive: true}); let is_gha = 'GITHUB_ACTIONS' in process.env && process.env['GITHUB_ACTIONS'] === 'true'; let tests = args; if (~(i = args.indexOf('--all'))) args.splice(i, 1), (tests = Object.keys(suite)); let invalidArg; if ((invalidArg = args.find(arg => arg.startsWith('-')))) throw new Error(`Invalid argument: ${invalidArg}`); if (!tests.length) return noService; for (let [i, test] of tests.entries()) { let [service, type] = test.split('.'); if (!(service in suite)) throw new Error(`Invalid service: ${service}`); if (!type) { tests.splice(i + 1, 0, ...Object.keys(suite[service]).map(type => `${test}.${type}`)); continue; } let {uri, filter = [], expect} = suite[service][type]; let test_stage_path = join(stage_path, test); let preargs = ['--no-logo', '--no-header', '--no-bar']; if (is_gha) preargs.push('--no-auth'); let child_args = [...preargs, ...filter.map(f => `--filter=${f}`)]; let unmetExpectations = new Error('One or more expectations failed'); await pRetry(3, async (attempt, lastErr, abort) => { if (attempt > 1 && lastErr !== unmetExpectations) abort(); let logFile = await fileMgr({ path: join(test_stage_path, `${service}-${type}-${attempt}.log`), keep: true, }).open(fs_constants.O_WRONLY | fs_constants.O_TRUNC); logFile.stream = createWriteStream(null, {fd: logFile.handle}); let logline = line => `│ ${line}`; let raw_stdout = tee(logFile.stream, process.stdout); let stdout = sed(logline); stdout.pipe(raw_stdout); let raw_stderr = tee(logFile.stream, process.stderr); let stderr = sed(logline); stderr.pipe(raw_stderr); stdout.log = (...args) => void stdout.write(util.formatWithOptions({colors: true}, ...args, '\n')); stderr.log = (...args) => void stderr.write(util.formatWithOptions({colors: true}, ...args, '\n')); if (attempt > 1) if (is_gha) console.log(`::warning::[${attempt}/3] Download failed, retrying..`); else console.log(`\x1b[33m[${attempt}/3] Download failed, retrying..\x1b[0m`); console.log(`Log File: ${logFile.path}`); let top_bar = `┌──> ${`[${attempt}/3] ${service} ${type} `.padEnd(56, '─')}┐`; if (is_gha) console.log(`::group::${top_bar}`); else raw_stdout.write(`${top_bar}\n`); let child, handler = () => {}; let extra_node_args = process.env['NODE_ARGS'] ? process.env['NODE_ARGS'].split(' ') : []; if (!docker_image) { child = spawn( 'node', [ ...extra_node_args, '--', short_path(join(__dirname, '..', 'cli.js')), ...child_args, '--directory', short_path(test_stage_path), uri, ], {...process.env, SHOW_DEBUG_STACK: 1}, ); } else { let child_id = `${test}.${stage_name}`; let extra_docker_args = process.env['DOCKER_ARGS'] ? process.env['DOCKER_ARGS'].split(' ') : []; child = spawn('docker', [ 'run', ...extra_docker_args, '--rm', '--interactive', '--log-driver=none', '--name', child_id, '--network', 'host', '--volume', `${test_stage_path}:/data`, '--env', 'SHOW_DEBUG_STACK=1', ...(extra_node_args.length ? ['--env', `FREYR_NODE_ARGS=${extra_node_args.join(' ')}`] : []), '--', docker_image, ...child_args, uri, ]); handler = () => spawn('docker', ['kill', child_id]); } let sigint_handler, sigterm_handler, sighup_handler; process .on('SIGINT', (sigint_handler = () => (handler(), close_handler(130)))) .on('SIGTERM', (sigterm_handler = () => (handler(), close_handler(143)))) .on('SIGHUP', (sighup_handler = () => (handler(), close_handler(129)))); stdout.log(`\n$ ${child.spawnargs.join(' ')}\n`); let childErrors = []; child.on('error', err => childErrors.push(err)); let logs = []; for (let [i, o] of [ [child.stdout, stdout], [child.stderr, stderr], ]) i.on('data', data => { let line = data.toString(); let pos; if (~(pos = line.indexOf('\x1b[G'))) line = line.slice(0, pos + 3) + logline(line.slice(pos + 3)); logs.push(line); o.write(line); }); let closed, close_handler; await new Promise((res, rej) => { child.on( 'close', (close_handler = (code, err) => { if (closed) return; closed = true; child.off('close', close_handler); process.off('SIGINT', sigint_handler).off('SIGTERM', sigterm_handler).off('SIGHUP', sighup_handler); if (docker_image && code === 137) abort({[abortCode]: 130}); for (let [signal, signame] of [ [130, 'SIGINT'], [143, 'SIGTERM'], [129, 'SIGHUP'], ]) if (code === signal) process.kill(process.pid, signame); if (code !== 0) err = new Error(`child process exited with code ${code}`); else if (childErrors.length) err = childErrors.shift(); if (!err) res(); else { err.code = code; if (childErrors.length) err[errorCauses] = childErrors; rej(err); } }), ); }); if (is_gha && (i = logs.findIndex(line => line.includes('[•] Embedding Metadata')))) { console.log(`::group::├──> ${`[${attempt}/3] View Download Status `.padEnd(56, '─')}┤`); for (let line of logs .slice(i) .join('') .split('\n') .filter(line => line.trim().length)) console.log(`│ ${line}`); console.log('::endgroup::'); } let ml = expect.reduce((a, v) => Math.max(a, v.length), 0); if (is_gha) console.log(`::group::├──> ${`[${attempt}/3] Verifying... `.padEnd(56, '─')}┤`); else raw_stdout.write(`├──> ${`[${attempt}/3] Verifying... `.padEnd(56, '─')}┤\n`); let as_expected = true; for (let expected of expect) { stdout.write(`• \x1b[33m${expected.padEnd(ml + 2, ' ')}\x1b[0m `); let this_passed; if ((this_passed = logs.some(line => line.match(new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))))) stdout.log('\x1b[32m(matched)\x1b[0m'); else stdout.log('\x1b[31m(failed to match)\x1b[0m'); as_expected &&= this_passed; } if (is_gha) console.log('::endgroup::'); if (is_gha) console.log(`::group::└${'─'.repeat(top_bar.length - 2)}┘\n::endgroup::`); else raw_stdout.write(`└${'─'.repeat(top_bar.length - 2)}┘\n`); if (!as_expected) throw unmetExpectations; }); } } let errorCauses = Symbol('ErrorCauses'); let noService = Symbol('noService'); async function main(args) { let suite, test_suite, i; if (~(i = args.indexOf('--suite')) && !(test_suite = args.splice(i, 2)[1])) throw new Error('`--suite` requires a file path'); suite = JSON.parse(await fs.readFile(test_suite || join(__dirname, 'default.json'))); if (!['-h', '--help'].some(args.includes.bind(args))) { let exitCode; try { if (noService !== (await run_tests(suite, args))) return; } catch (err) { if (abortCode in err) exitCode = err[abortCode]; else { console.error('An error occurred!'); if (errorCauses in err) { let causes = err[errorCauses]; delete err[errorCauses]; console.error('', err); for (let cause of causes) console.error('', cause); } else console.error('', err); exitCode = 1; } } finally { await fileMgr.garbageCollect(); } if (exitCode) process.exit(exitCode); } console.log('freyr-test'); console.log('----------'); console.log('Usage: freyr-test [options] [[.]...]'); console.log(); console.log('Utility for testing the Freyr CLI'); console.log(); console.log('Options:'); console.log(); console.log(` SERVICE ${Object.keys(suite).join(' / ')}`); console.log(` TYPE ${[...new Set(Object.values(suite).flatMap(s => Object.keys(s)))].join(' / ')}`); console.log(); console.log(' --all run all tests'); console.log(' --suite use a specific test suite (json)'); console.log(' --docker run tests in a docker container'); console.log(' --name name for this test run (defaults to a random hex string)'); console.log(` --stage directory to stage this test (default: ${default_stage})`); console.log(' --force allow reusing existing stages'); console.log(' --clean (when --force is used) clean existing stage before reusing it'); console.log(' --help show this help message'); console.log(); console.log('Enviroment Variables:'); console.log(); console.log(' DOCKER_ARGS arguments to pass to `docker run`'); console.log(' NODE_ARGS arguments to pass to `node`'); console.log(); console.log('Example:'); console.log(); console.log(' $ freyr-test --all'); console.log(' runs all tests'); console.log(); console.log(' $ freyr-test spotify'); console.log(' runs all Spotify tests'); console.log(); console.log(' $ freyr-test apple_music.album'); console.log(' tests downloading an Apple Music album'); console.log(); console.log(' $ freyr-test spotify.track deezer.artist'); console.log(' tests downloading a Spotify track and Deezer artist'); console.log(); console.log(' $ freyr-test spotify.track --stage ./stage --name test-run'); console.log(' downloads the Spotify test track in ./stage/test-run/spotify.track with logs'); } function _start() { main(process.argv.slice(2)); } _start();