chore: revamp ci testing (#264)

This commit is contained in:
Miraculous Owonubi 2022-07-12 14:55:25 +01:00 committed by GitHub
parent 42cd913453
commit 9cd5ae9552
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 497 additions and 163 deletions

View File

@ -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
}

View File

@ -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:

View File

@ -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. <https://github.com/miraclx/freyr-js/pull/216>
- Upgraded to ES6 Modules. <https://github.com/miraclx/freyr-js/pull/202>
- Introduced the pushing of docker images for each PR. <https://github.com/miraclx/freyr-js/pull/218>, <https://github.com/miraclx/freyr-js/pull/228>
- Introduced a test runner, with local reproducible builds. <https://github.com/miraclx/freyr-js/pull/264>
- Introduced CI checks for formatting.
- Updated dependencies.
- Removed some unused dependencies. <https://github.com/miraclx/freyr-js/pull/217>, <https://github.com/miraclx/freyr-js/pull/245>

View File

@ -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.

72
TEST.md Normal file
View File

@ -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] [<SERVICE>[.<TYPE>]...]
Utility for testing the Freyr CLI
Options:
SERVICE spotify / apple_music / deezer
TYPE track / album / artist / playlist
--all run all tests
--suite <SUITE> use a specific test suite (json)
--docker <IMAGE> 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
```

View File

@ -10,6 +10,9 @@
"engines": {
"node": ">=12"
},
"scripts": {
"test": "node test/index.js"
},
"files": [
"src",
"conf.json",

View File

@ -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 = () => {

122
test/default.json Normal file
View File

@ -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]"
]
}
}
}

267
test/index.js Normal file
View File

@ -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] [<SERVICE>[.<TYPE>]...]');
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 <SUITE> use a specific test suite (json)');
console.log(' --docker <IMAGE> 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();