diff --git a/.github/workflows/ci-prep.sh b/.github/workflows/ci-prep.sh deleted file mode 100755 index 54d563e..0000000 --- a/.github/workflows/ci-prep.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash - -RG_SRC="$(which rg)" -rg() { - printf 'rg: pattern: %s' "/$*/" >/dev/stderr - if $RG_SRC --fixed-strings --passthru "$@"; then - echo " (matched)" >/dev/stderr - else - echo " (failed to match)" >/dev/stderr - return 1 - fi -} - -freyr() { - echo "::group::[$attempts/3] Downloading..." - script -qfc "freyr --no-bar $*" /dev/null | tee .freyr_log - echo "::endgroup::" - i=$($RG_SRC -n '.' .freyr_log | $RG_SRC --fixed-strings '[•] Embedding Metadata' | cut -d':' -f1) - if [[ $i ]]; then - echo "::group::[$attempts/3] View Download Status" - tail +"$i" .freyr_log - echo "::endgroup::" - fi -} - -exec_retry() { - cmd="$(cat)" && attempts=1 - until eval "$cmd"; do - echo "::endgroup::" - if ((attempts < 3)); then - echo "::warning::[$attempts/3] Download failed, retrying.." - : $((attempts += 1)) - else - echo "::error::[$attempts/3] Download failed." - return 1 - fi - done - echo "::endgroup::" - echo "::group::View Files" - STAGE=$(realpath --relative-to=../.. .) && cd ../.. - tree -sh "$STAGE" - echo "::endgroup::" -} - -validate() { - echo "::group::[$attempts/3] Verifying..." - res=$(<.freyr_log) - for arg in "[•] Collation Complete" "$@"; do - res=$(echo "$res" | rg "$arg") || return 1 - done >/dev/null -} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1dd94a5..34e961b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,7 +50,7 @@ jobs: - name: Install Dependencies run: | sudo apt-get update - sudo apt-get install -y ffmpeg ripgrep atomicparsley + sudo apt-get install -y ffmpeg atomicparsley - uses: actions/cache@v3 with: @@ -67,51 +67,23 @@ jobs: - name: Spotify - Download Track run: | - . .github/workflows/ci-prep.sh && cd ./CI/spotify/track - - exec_retry << EOF - freyr https://open.spotify.com/track/5FNS5Vj69AhRGJWjhrAd01 - validate \ - "[•] Total tracks: [01]" \ - "✓ Passed: [01]" \ - "✕ Failed: [00]" - EOF + cd ./CI/spotify/track + npm run test -- spotify.track - name: Spotify - Download Album run: | - . .github/workflows/ci-prep.sh && cd ./CI/spotify/album - - exec_retry << EOF - freyr https://open.spotify.com/album/2D23kwwoy2JpZVuJwzE42B - validate \ - "[•] Total tracks: [04]" \ - "✓ Passed: [04]" \ - "✕ Failed: [00]" - EOF + cd ./CI/spotify/album + npm run test -- spotify.album - name: Spotify - Download Artist run: | - . .github/workflows/ci-prep.sh && cd ./CI/spotify/artist - - exec_retry << EOF - freyr https://open.spotify.com/artist/4adSXA1GDOxNG7Zw89YHyz \ - -l album=\"the rainbow cassette\" \ - -l album=\"make believe\" - validate \ - "✓ Passed: [10]" \ - "✕ Failed: [00]" - EOF + cd ./CI/spotify/artist + npm run test -- spotify.artist - name: Spotify - Download Playlist run: | - . .github/workflows/ci-prep.sh && cd ./CI/spotify/playlist - - exec_retry << EOF - freyr https://open.spotify.com/playlist/5KcCGEx7fFqXYNiJkSQ5KT - validate \ - "✓ Passed: [05]" \ - "✕ Failed: [00]" - EOF + cd ./CI/spotify/playlist + npm run test -- spotify.playlist apple_music: runs-on: ubuntu-latest @@ -123,7 +95,7 @@ jobs: - name: Install Dependencies run: | sudo apt-get update - sudo apt-get install -y ffmpeg ripgrep atomicparsley + sudo apt-get install -y ffmpeg atomicparsley - uses: actions/cache@v3 with: @@ -140,51 +112,23 @@ jobs: - name: Apple Music - Download Track run: | - . .github/workflows/ci-prep.sh && cd ./CI/apple_music/track - - exec_retry << EOF - freyr https://music.apple.com/us/album/elio-irl/1547735824?i=1547736100 - validate \ - "[•] Total tracks: [01]" \ - "✓ Passed: [01]" \ - "✕ Failed: [00]" - EOF + cd ./CI/apple_music/track + npm run test -- apple_music.track - name: Apple Music - Download Album run: | - . .github/workflows/ci-prep.sh && cd ./CI/apple_music/album - - exec_retry << EOF - freyr https://music.apple.com/us/album/im-sorry-im-not-sorry-ep/1491795443 - validate \ - "[•] Total tracks: [04]" \ - "✓ Passed: [04]" \ - "✕ Failed: [00]" - EOF + cd ./CI/apple_music/album + npm run test -- apple_music.album - name: Apple Music - Download Artist run: | - . .github/workflows/ci-prep.sh && cd ./CI/apple_music/artist - - exec_retry << EOF - freyr https://music.apple.com/us/artist/mazie/1508029053 \ - -l album=\"the rainbow cassette\" \ - -l album=\"make believe\" - validate \ - "✓ Passed: [10]" \ - "✕ Failed: [00]" - EOF + cd ./CI/apple_music/artist + npm run test -- apple_music.artist - name: Apple Music - Download Playlist run: | - . .github/workflows/ci-prep.sh && cd ./CI/apple_music/playlist - - exec_retry << EOF - freyr https://music.apple.com/us/playlist/songs-from-up-next-bazzi/pl.56c4bdf909954beca0cf69379a48144f - validate \ - "✓ Passed: [06]" \ - "✕ Failed: [00]" - EOF + cd ./CI/apple_music/playlist + npm run test -- apple_music.playlist deezer: runs-on: ubuntu-latest @@ -196,7 +140,7 @@ jobs: - name: Install Dependencies run: | sudo apt-get update - sudo apt-get install -y ffmpeg ripgrep atomicparsley + sudo apt-get install -y ffmpeg atomicparsley - uses: actions/cache@v3 with: @@ -213,51 +157,23 @@ jobs: - name: Deezer - Download Track run: | - . .github/workflows/ci-prep.sh && cd ./CI/deezer/track - - exec_retry << EOF - freyr https://www.deezer.com/en/track/1189202982 - validate \ - "[•] Total tracks: [01]" \ - "✓ Passed: [01]" \ - "✕ Failed: [00]" - EOF + cd ./CI/deezer/track + npm run test -- deezer.track - name: Deezer - Download Album run: | - . .github/workflows/ci-prep.sh && cd ./CI/deezer/album - - exec_retry << EOF - freyr https://www.deezer.com/en/album/123330212 - validate \ - "[•] Total tracks: [04]" \ - "✓ Passed: [04]" \ - "✕ Failed: [00]" - EOF + cd ./CI/deezer/album + npm run test -- deezer.album - name: Deezer - Download Artist run: | - . .github/workflows/ci-prep.sh && cd ./CI/deezer/artist - - exec_retry << EOF - freyr https://www.deezer.com/en/artist/14808825 \ - -l album=\"the rainbow cassette\" \ - -l album=\"make believe\" - validate \ - "✓ Passed: [10]" \ - "✕ Failed: [00]" - EOF + cd ./CI/deezer/artist + npm run test -- deezer.artist - name: Deezer - Download Playlist run: | - . .github/workflows/ci-prep.sh && cd ./CI/deezer/playlist - - exec_retry << EOF - freyr https://www.deezer.com/en/playlist/10004168842 - validate \ - "✓ Passed: [05]" \ - "✕ Failed: [00]" - EOF + cd ./CI/deezer/playlist + npm run test -- deezer.playlist docker-build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea68c8..ff4c703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix long standing issue with freyr seeming frozen on exit. - Upgraded to ES6 Modules. - Introduced the pushing of docker images for each PR. , +- Introduced a test runner, with local reproducible builds. - Introduced CI checks for formatting. - Updated dependencies. - Removed some unused dependencies. , diff --git a/README.md b/README.md index ab2ac0a..062c851 100644 --- a/README.md +++ b/README.md @@ -1081,6 +1081,10 @@ cd freyr yarn link ``` +### Testing + +Freyr comes bundled with a lightweight test suite. See [TEST.md](https://github.com/miraclx/freyr-js/blob/master/TEST.md) for instructions on how to run it. + ### Docker Development With docker, you can drop into a sandbox that has all the dependencies you need. Without needing to mess around with your host system or install any weird dependencies. diff --git a/TEST.md b/TEST.md new file mode 100644 index 0000000..a7e6dba --- /dev/null +++ b/TEST.md @@ -0,0 +1,72 @@ + +# freyr testing + +freyr is bundled with its own flexibly customizable test runner. + +- To run all tests + + ```console + npm run test -- --all + ``` + +- To run just Spotify tests + + ```console + npm run test -- spotify + ``` + +- To run just Apple Music artist tests + + ```console + npm run test -- apple_music.artist + ``` + +- You can use a custom test suite (see the [default suite](https://github.com/miraclx/freyr-js/blob/master/test/default.json) for an example) + + ```console + npm run test -- --all --suite ./special_cases.json + ``` + +- And optionally, you can run the tests inside a freyr docker container + + ```console + npm run test -- deezer --docker freyr-dev:latest + ``` + +## `npm run test -- --help` + +```console +freyr-test +---------- +Usage: freyr-test [options] [[.]...] + +Utility for testing the Freyr CLI + +Options: + + SERVICE spotify / apple_music / deezer + TYPE track / album / artist / playlist + + --all run all tests + --suite use a specific test suite (json) + --docker run tests in a docker container + --help show this help message + +Enviroment Variables: + + DOCKER_ARGS arguments to pass to `docker run` + +Example: + + $ freyr-test --all + runs all tests + + $ freyr-test spotify + runs all Spotify tests + + $ freyr-test apple_music.album + tests downloading an Apple Music album + + $ freyr-test spotify.track deezer.artist + tests downloading a Spotify track and Deezer artist +``` diff --git a/package.json b/package.json index 2fee89c..7d112f9 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "engines": { "node": ">=12" }, + "scripts": { + "test": "node test/index.js" + }, "files": [ "src", "conf.json", diff --git a/src/file_mgr.js b/src/file_mgr.js index ed14dd7..d2a0811 100644 --- a/src/file_mgr.js +++ b/src/file_mgr.js @@ -35,7 +35,7 @@ export default async function genFile(opts) { const dir = join(opts.tmpdir, opts.dirname || '.'); await mkdirp(dir); const name = join(dir, opts.filename); - const fd = await open(name, fs.constants.O_CREAT); + const fd = await open(name, fs.constants.O_CREAT | opts.mode); hookupListeners(); let closed = false; const garbageHandler = () => { diff --git a/test/default.json b/test/default.json new file mode 100644 index 0000000..92a1afe --- /dev/null +++ b/test/default.json @@ -0,0 +1,122 @@ +{ + "spotify": { + "track": { + "uri": "https://open.spotify.com/track/5FNS5Vj69AhRGJWjhrAd01", + "expect": [ + "[•] Collation Complete", + "[•] Total tracks: [01]", + "✓ Passed: [01]", + "✕ Failed: [00]" + ] + }, + "album": { + "uri": "https://open.spotify.com/album/2D23kwwoy2JpZVuJwzE42B", + "expect": [ + "[•] Collation Complete", + "[•] Total tracks: [04]", + "✓ Passed: [04]", + "✕ Failed: [00]" + ] + }, + "artist": { + "uri": "https://open.spotify.com/artist/4adSXA1GDOxNG7Zw89YHyz", + "filter": [ + "album=\"the rainbow cassette\"", + "album=\"make believe\"" + ], + "expect": [ + "[•] Collation Complete", + "✓ Passed: [10]", + "✕ Failed: [00]" + ] + }, + "playlist": { + "uri": "https://open.spotify.com/playlist/5KcCGEx7fFqXYNiJkSQ5KT", + "expect": [ + "[•] Collation Complete", + "✓ Passed: [05]", + "✕ Failed: [00]" + ] + } + }, + "apple_music": { + "track": { + "uri": "https://music.apple.com/us/album/elio-irl/1547735824?i=1547736100", + "expect": [ + "[•] Collation Complete", + "[•] Total tracks: [01]", + "✓ Passed: [01]", + "✕ Failed: [00]" + ] + }, + "album": { + "uri": "https://music.apple.com/us/album/im-sorry-im-not-sorry-ep/1491795443", + "expect": [ + "[•] Collation Complete", + "[•] Total tracks: [04]", + "✓ Passed: [04]", + "✕ Failed: [00]" + ] + }, + "artist": { + "uri": "https://music.apple.com/us/artist/mazie/1508029053", + "filter": [ + "album=\"the rainbow cassette\"", + "album=\"make believe\"" + ], + "expect": [ + "[•] Collation Complete", + "✓ Passed: [10]", + "✕ Failed: [00]" + ] + }, + "playlist": { + "uri": "https://music.apple.com/us/playlist/songs-from-up-next-bazzi/pl.56c4bdf909954beca0cf69379a48144f", + "expect": [ + "[•] Collation Complete", + "✓ Passed: [06]", + "✕ Failed: [00]" + ] + } + }, + "deezer": { + "track": { + "uri": "https://www.deezer.com/en/track/1189202982", + "expect": [ + "[•] Collation Complete", + "[•] Total tracks: [01]", + "✓ Passed: [01]", + "✕ Failed: [00]" + ] + }, + "album": { + "uri": "https://www.deezer.com/en/album/123330212", + "expect": [ + "[•] Collation Complete", + "[•] Total tracks: [04]", + "✓ Passed: [04]", + "✕ Failed: [00]" + ] + }, + "artist": { + "uri": "https://www.deezer.com/en/artist/14808825", + "filter": [ + "album=\"the rainbow cassette\"", + "album=\"make believe\"" + ], + "expect": [ + "[•] Collation Complete", + "✓ Passed: [10]", + "✕ Failed: [00]" + ] + }, + "playlist": { + "uri": "https://www.deezer.com/en/playlist/10004168842", + "expect": [ + "[•] Collation Complete", + "✓ Passed: [05]", + "✕ Failed: [00]" + ] + } + } +} diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..21c7aa7 --- /dev/null +++ b/test/index.js @@ -0,0 +1,267 @@ +import fs from 'fs'; +import url from 'url'; +import path from 'path'; +import util from 'util'; +import {randomUUID} from 'crypto'; +import {PassThrough} from 'stream'; +import {spawn} from 'child_process'; + +import fileMgr from '../src/file_mgr.js'; + +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; +} + +async function pRetry(tries, fn) { + let result, + rawErr, + abortSymbol = Symbol('RetryAbort'); + for (let [i] of Array.apply(null, {length: tries}).entries()) { + try { + result = await fn(i + 1, rawErr, () => { + throw abortSymbol; + }); + } catch (err) { + if (err === abortSymbol) break; + (result = Promise.reject((rawErr = err))).catch(() => {}); + } + } + return result; +} + +async function run_tests(stage, args) { + let docker_image, i; + if ((docker_image = !!~(i = args.indexOf('--docker'))) && !(docker_image = args.splice(i, 2)[1])) + throw new Error('`--docker` requires an image name'); + + let is_gha = 'GITHUB_ACTIONS' in process.env && process.env['GITHUB_ACTIONS'] === 'true'; + if (~(i = args.indexOf('--all'))) args = Object.keys(stage); + let invalidArg; + if ((invalidArg = args.find(arg => arg.startsWith('-')))) throw new Error(`Invalid argument: ${invalidArg}`); + + for (let [i, arg] of args.entries()) { + let [service, type] = arg.split('.'); + if (!(service in stage)) throw new Error(`Invalid service: ${service}`); + if (!type) { + args.splice(i + 1, 0, ...Object.keys(stage[service]).map(type => `${arg}.${type}`)); + continue; + } + + let {uri, filter = [], expect} = stage[service][type]; + + let child_args = ['--no-logo', '--no-header', '--no-bar', uri, ...filter.map(f => `--filter=${f}`)]; + + let unmetExpectations = new Error('One or more expectations failed'); + + let child_id = randomUUID(); + + await pRetry(3, async (attempt, lastErr, abort) => { + if (attempt > 1 && lastErr !== unmetExpectations) abort(); + + let logFile = await fileMgr({ + filename: `${service}-${type}-${attempt}.log`, + dirname: path.join('freyr-test', child_id), + keep: true, + mode: fs.constants.W_OK, + }); + + logFile.stream = fs.createWriteStream(null, {fd: logFile.fd}); + + 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.name}`); + + 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; + + if (!docker_image) { + child = spawn('node', [path.join(__dirname, '..', 'cli.js'), ...child_args]); + } else { + let extra_docker_args = process.env['DOCKER_ARGS'] ? process.env['DOCKER_ARGS'].split(' ') : []; + child = spawn('docker', [ + 'run', + ...extra_docker_args, + '--rm', + '-i', + '--log-driver=none', + '--name', + child_id, + docker_image, + ...child_args, + ]); + process.on('SIGINT', (handler = () => (spawn('docker', ['kill', child_id]), process.off('SIGINT', handler)))); + } + + 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); + }); + + await new Promise((res, rej) => { + child.on('close', (code, err) => { + if (docker_image) { + process.off('SIGINT', handler); + if (code === 137) process.exit(130); + } + 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'); + +function main() { + let args = process.argv.slice(2); + + let stage, test_suite, i; + + if ((test_suite = !!~(i = args.indexOf('--suite'))) && !(test_suite = args.splice(i, 2)[1])) + throw new Error('`--suite` requires a file path'); + + try { + stage = JSON.parse(fs.readFileSync(test_suite || path.join(__dirname, 'default.json'))); + } catch (e) { + (i = ''), (stage = JSON.parse(fs.readFileSync(path.join(__dirname, 'default.json')))); + console.error("\x1b[33mCouldn't read test suite file\x1b[0m\n", e.message, '\n'); + } + + if (!args.length || i === '' || args.includes('--help') || args.includes('-h')) { + 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(stage).join(' / ')}`); + console.log(` TYPE ${[...new Set(Object.values(stage).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(' --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(); + 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'); + return; + } + + run_tests(stage, args).catch(err => { + 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); + process.exit(1); + }); +} + +main();