From 97f7cf1390de80494c6d666a41fa899a78d2b9db Mon Sep 17 00:00:00 2001 From: Justin Stayton Date: Fri, 23 Aug 2024 08:08:53 -0400 Subject: [PATCH] Add tests --- .github/workflows/ci.yml | 27 ++ .gitignore | 3 + .prettierignore | 3 + README.md | 19 ++ package.json | 3 +- src/main.js | 85 +++--- src/main.test.js | 246 ++++++++++++++++++ test/templates/fail_build_remove/.keep | 0 .../fail_config_json/suri.config.json | 1 + test/templates/fail_config_json_read/.keep | 0 test/templates/fail_links_json/src/links.json | 1 + test/templates/fail_links_json_read/src/.keep | 0 test/templates/fail_public_read/public/.keep | 0 .../templates/fail_public_read/src/links.json | 3 + test/templates/pass/public/robots.txt | 2 + test/templates/pass/public/test/test.txt | 0 test/templates/pass/src/links.json | 5 + test/templates/pass_js/src/links.json | 3 + test/templates/pass_js/suri.config.json | 3 + test/templates/pass_no_public/src/links.json | 3 + test/utilities.js | 18 ++ 21 files changed, 380 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .prettierignore create mode 100644 src/main.test.js create mode 100644 test/templates/fail_build_remove/.keep create mode 100644 test/templates/fail_config_json/suri.config.json create mode 100644 test/templates/fail_config_json_read/.keep create mode 100644 test/templates/fail_links_json/src/links.json create mode 100644 test/templates/fail_links_json_read/src/.keep create mode 100644 test/templates/fail_public_read/public/.keep create mode 100644 test/templates/fail_public_read/src/links.json create mode 100644 test/templates/pass/public/robots.txt create mode 100644 test/templates/pass/public/test/test.txt create mode 100644 test/templates/pass/src/links.json create mode 100644 test/templates/pass_js/src/links.json create mode 100644 test/templates/pass_js/suri.config.json create mode 100644 test/templates/pass_no_public/src/links.json create mode 100644 test/utilities.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ac928c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + workflow_dispatch: + +jobs: + lint-test: + name: Lint & test + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ['18.x', '20.x', '22.x'] + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Node.js v${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - name: Install npm dependencies + run: npm install + - name: Run lint + run: npm run lint + - name: Run tests + run: npm test diff --git a/.gitignore b/.gitignore index 3055d78..38887bf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ # JSDoc /docs + +# Test builds +/test/templates/*/build diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..abb33ff --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +# Test files with invalid syntax +/test/templates/fail_config_json/suri.config.json +/test/templates/fail_links_json/src/links.json diff --git a/README.md b/README.md index 83323c3..155e769 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,25 @@ Install dependencies with npm: npm install ``` +### Tests + +The built-in Node.js [test runner](https://nodejs.org/docs/latest/api/test.html) +and [assertions module](https://nodejs.org/docs/latest/api/assert.html) is used +for testing. + +To run the tests: + +```bash +npm test +``` + +During development, it's recommended to run the tests automatically on file +change: + +```bash +npm test -- --watch +``` + ### Docs [JSDoc](https://jsdoc.app/) is used to document the code. diff --git a/package.json b/package.json index 7fa4f47..6502ea7 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "lint:format:fix": "prettier --write .", "lint:quality": "eslint .", "lint:quality:fix": "eslint --fix .", - "release": "release-it --only-version" + "release": "release-it --only-version", + "test": "node --test ./src" }, "devDependencies": { "eslint": "^8.56.0", diff --git a/src/main.js b/src/main.js index b9de607..9a985b3 100644 --- a/src/main.js +++ b/src/main.js @@ -12,22 +12,6 @@ import SuriError from './error.js' */ const SURI_DIR_PATH = join(dirname(fileURLToPath(import.meta.url)), '..') -/** - * The path to the current working directory of the Node.js process. - * - * @private - * @constant {string} - */ -const CWD_PATH = cwd() - -/** - * The path to the build directory in the current working directory. - * - * @private - * @constant {string} - */ -const BUILD_DIR_PATH = join(CWD_PATH, 'build') - /** * Tagged template function for composing HTML. * @@ -64,18 +48,18 @@ function isErrorFileNotExists(error) { * Load the config from `suri.config.json`, if it exists, merged with defaults. * * @private + * @param {Object} params + * @param {string} params.path The path to the `suri.config.json` file to load. * @throws {SuriError} If `suri.config.json` fails to be read or parsed. * @returns {Object} The parsed and merged config. */ -async function loadConfig() { - const configFilePath = join(CWD_PATH, 'suri.config.json') - - console.log(`Config file: ${configFilePath}`) +async function loadConfig({ path }) { + console.log(`Config file: ${path}`) let config try { - config = await readFile(configFilePath) + config = await readFile(path) } catch (error) { if (!isErrorFileNotExists(error)) { throw new SuriError('Failed to load config file', error) @@ -102,18 +86,18 @@ async function loadConfig() { * Load the links from `links.json`. * * @private + * @param {Object} params + * @param {string} params.path The path to the `links.json` file to load. * @throws {SuriError} If `links.json` fails to be loaded or parsed. * @returns {Object} The parsed links. */ -async function loadLinks() { - const linkFilePath = join(CWD_PATH, 'src', 'links.json') - - console.log(`Links file: ${linkFilePath}`) +async function loadLinks({ path }) { + console.log(`Links file: ${path}`) let links try { - links = await readFile(linkFilePath) + links = await readFile(path) } catch (error) { throw new SuriError('Failed to load links file', error) } @@ -157,11 +141,12 @@ function buildLinkPage({ redirectURL, config }) { * @param {string} params.linkPath The shortlink path to redirect from. * @param {string} params.redirectURL The target URL to redirect to. * @param {Object} params.config The parsed and merged config. + * @param {string} params.buildDirPath The path to the build directory. * @throws {SuriError} If the directory/file fails to be created. * @returns {true} If the link was created. */ -async function createLink({ linkPath, redirectURL, config }) { - const linkDirPath = join(BUILD_DIR_PATH, linkPath) +async function createLink({ linkPath, redirectURL, config, buildDirPath }) { + const linkDirPath = join(buildDirPath, linkPath) console.log(`Creating link: ${linkPath}`) @@ -187,23 +172,23 @@ async function createLink({ linkPath, redirectURL, config }) { * Copy the public directories/files to the build directory. * * The directory in this repository of "default" files is copied first, followed - * by the directory in the current working directory, if it exists. + * by the directory in the source directory, if it exists. * * @private + * @param {Object} params + * @param {string} params.path The path to the public directory to copy. + * @param {string} params.buildDirPath The path to the build directory. * @throws {SuriError} If a directory/file fails to be copied. * @returns {true} If the directories/files were copied. */ -async function copyPublic() { - const publicDirPaths = [ - join(SURI_DIR_PATH, 'public'), - join(CWD_PATH, 'public'), - ] +async function copyPublic({ path, buildDirPath }) { + const publicDirPaths = [join(SURI_DIR_PATH, 'public'), path] for (const publicDirPath of publicDirPaths) { console.log(`Copying public directory: ${publicDirPath}`) try { - await cp(publicDirPath, BUILD_DIR_PATH, { + await cp(publicDirPath, buildDirPath, { preserveTimestamps: true, recursive: true, }) @@ -223,12 +208,14 @@ async function copyPublic() { * Remove the build directory and all of its child directories/files. * * @private + * @param {Object} params + * @param {string} params.path The path to the build directory to remove. * @throws {SuriError} If the directory fails to be removed. * @returns {undefined} If the directory was removed. */ -async function removeBuild() { +async function removeBuild({ path }) { try { - return await rm(BUILD_DIR_PATH, { recursive: true, force: true }) + return await rm(path, { recursive: true, force: true }) } catch (error) { throw new SuriError('Failed to remove build directory', error) } @@ -238,27 +225,37 @@ async function removeBuild() { * Build the static site from a `links.json` file. * * @memberof module:suri + * @param {Object} [params] + * @param {string} [params.path] The path to the directory to build from. Defaults to the current working directory of the Node.js process. * @throws {SuriError} If the build fails. * @returns {true} If the build succeeds. */ -async function main() { +async function main({ path = cwd() } = {}) { try { - await removeBuild() + await removeBuild({ path: join(path, 'build') }) - const config = await loadConfig() - const links = await loadLinks() + const config = await loadConfig({ path: join(path, 'suri.config.json') }) + const links = await loadLinks({ path: join(path, 'src', 'links.json') }) for (const [linkPath, redirectURL] of Object.entries(links)) { - await createLink({ linkPath, redirectURL, config }) + await createLink({ + linkPath, + redirectURL, + config, + buildDirPath: join(path, 'build'), + }) } - await copyPublic() + await copyPublic({ + path: join(path, 'public'), + buildDirPath: join(path, 'build'), + }) console.log('Done!') return true } catch (error) { - await removeBuild() + await removeBuild({ path: join(path, 'build') }) throw error } diff --git a/src/main.test.js b/src/main.test.js new file mode 100644 index 0000000..f0ed085 --- /dev/null +++ b/src/main.test.js @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict' +import { access, chmod, mkdir, rm, writeFile } from 'node:fs/promises' +import { describe, it } from 'node:test' +import main from './main.js' +import { joinTemplates, readTemplatesFile } from '../test/utilities.js' + +describe('main', () => { + describe('removes build directory', () => { + it('removes existing build directory at start', async () => { + const testFilePath = joinTemplates('pass', 'build', 'test.txt') + + await mkdir(joinTemplates('pass', 'build'), { recursive: true }) + await writeFile(testFilePath, 'test') + + await main({ path: joinTemplates('pass') }) + + await assert.rejects( + async () => { + await access(testFilePath) + }, + { + name: 'Error', + code: 'ENOENT', + }, + ) + }) + + it('removes build directory when an error occurs', async () => { + await assert.rejects(async () => { + await main({ path: joinTemplates('fail_config_json') }) + }) + + await assert.rejects( + async () => { + await access(joinTemplates('fail_config_json', 'build')) + }, + { + name: 'Error', + code: 'ENOENT', + }, + ) + }) + + it('throws `SuriError` when build directory fails to be removed', async () => { + const buildDirPath = joinTemplates('fail_build_remove', 'build') + + await mkdir(buildDirPath, { recursive: true }) + await writeFile( + joinTemplates('fail_build_remove', 'build', 'fail.txt'), + 'fail', + ) + await chmod(buildDirPath, 0o000) + + await assert.rejects( + async () => { + await main({ path: joinTemplates('fail_build_remove') }) + }, + { + name: 'SuriError', + message: 'Failed to remove build directory', + }, + ) + + await chmod(buildDirPath, 0o777) + }) + }) + + describe('loads config', () => { + it('throws `SuriError` when `suri.config.json` fails to be read', async () => { + const configFilePath = joinTemplates( + 'fail_config_json_read', + 'suri.config.json', + ) + + await writeFile(configFilePath, 'fail', { mode: 0o000 }) + + await assert.rejects( + async () => { + await main({ path: joinTemplates('fail_config_json_read') }) + }, + { + name: 'SuriError', + message: 'Failed to load config file', + }, + ) + + await rm(configFilePath) + }) + + it('throws `SuriError` when `suri.config.json` fails to be parsed as JSON', async () => { + await assert.rejects( + async () => { + await main({ path: joinTemplates('fail_config_json') }) + }, + { + name: 'SuriError', + message: 'Failed to parse config as JSON', + }, + ) + }) + }) + + describe('creates links', () => { + it('creates the `/` link in the root directory', async () => { + await main({ path: joinTemplates('pass') }) + + assert.equal( + await readTemplatesFile('pass', 'build', 'index.html'), + '\n \n \n ', + ) + }) + + it('creates a link consisting of letters in a directory of the same name', async () => { + await main({ path: joinTemplates('pass') }) + + assert.equal( + await readTemplatesFile('pass', 'build', 'gh', 'index.html'), + '\n \n \n ', + ) + }) + + it('creates a link consisting of numbers in a directory of the same name', async () => { + await main({ path: joinTemplates('pass') }) + + assert.equal( + await readTemplatesFile('pass', 'build', '1', 'index.html'), + '\n \n \n ', + ) + }) + + it('creates links with JavaScript redirect if `js` config enabled', async () => { + await main({ path: joinTemplates('pass_js') }) + + assert.equal( + await readTemplatesFile('pass_js', 'build', 'index.html'), + "\n \n \n \n \n ", + ) + }) + + it('throws `SuriError` when `links.json` fails to be read', async () => { + const linksFilePath = joinTemplates( + 'fail_links_json_read', + 'src', + 'suri.config.json', + ) + + await writeFile(linksFilePath, 'fail', { mode: 0o000 }) + + await assert.rejects( + async () => { + await main({ path: joinTemplates('fail_links_json_read') }) + }, + { + name: 'SuriError', + message: 'Failed to load links file', + }, + ) + + await rm(linksFilePath) + }) + + it('throws `SuriError` when `links.json` fails to be parsed as JSON', async () => { + await assert.rejects( + async () => { + await main({ path: joinTemplates('fail_links_json') }) + }, + { + name: 'SuriError', + message: 'Failed to parse links as JSON', + }, + ) + }) + + it.skip('throws `SuriError` when a link directory fails to be created', async () => { + // TODO: Figure out how to test this. + }) + + it.skip('throws `SuriError` when a link file fails to be created', async () => { + // TODO: Figure out how to test this. + }) + }) + + describe('copies public files', () => { + it('copies public files from this repository as "defaults"', async () => { + await main({ path: joinTemplates('pass') }) + + assert.equal( + await access(joinTemplates('pass', 'build', 'favicon.ico')), + undefined, + ) + }) + + it('copies public files from this repository even if no source directory exists', async () => { + await main({ path: joinTemplates('pass_no_public') }) + + assert.equal( + await readTemplatesFile('pass_no_public', 'build', 'robots.txt'), + 'User-agent: *\nDisallow: /\n', + ) + }) + + it('copies public files from the source directory, overwriting "defaults"', async () => { + await main({ path: joinTemplates('pass') }) + + assert.equal( + await readTemplatesFile('pass', 'build', 'robots.txt'), + 'User-agent: Google\nDisallow: /\n', + ) + }) + + it('copies public files recursively from the source directory', async () => { + await main({ path: joinTemplates('pass') }) + + assert.equal( + await access(joinTemplates('pass', 'build', 'test', 'test.txt')), + undefined, + ) + }) + + it('throws `SuriError` when a public directory/file fails to be copied', async () => { + const publicFilePath = joinTemplates( + 'fail_public_read', + 'public', + 'test.txt', + ) + + await writeFile(publicFilePath, 'fail', { mode: 0o000 }) + + await assert.rejects( + async () => { + await main({ path: joinTemplates('fail_public_read') }) + }, + { + name: 'SuriError', + message: 'Failed to copy public directory', + }, + ) + + await rm(publicFilePath) + }) + }) + + it('returns `true` when the build succeeds', async () => { + assert.equal(await main({ path: joinTemplates('pass') }), true) + }) +}) diff --git a/test/templates/fail_build_remove/.keep b/test/templates/fail_build_remove/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/templates/fail_config_json/suri.config.json b/test/templates/fail_config_json/suri.config.json new file mode 100644 index 0000000..f45d4b6 --- /dev/null +++ b/test/templates/fail_config_json/suri.config.json @@ -0,0 +1 @@ +fail \ No newline at end of file diff --git a/test/templates/fail_config_json_read/.keep b/test/templates/fail_config_json_read/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/templates/fail_links_json/src/links.json b/test/templates/fail_links_json/src/links.json new file mode 100644 index 0000000..f45d4b6 --- /dev/null +++ b/test/templates/fail_links_json/src/links.json @@ -0,0 +1 @@ +fail \ No newline at end of file diff --git a/test/templates/fail_links_json_read/src/.keep b/test/templates/fail_links_json_read/src/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/templates/fail_public_read/public/.keep b/test/templates/fail_public_read/public/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/templates/fail_public_read/src/links.json b/test/templates/fail_public_read/src/links.json new file mode 100644 index 0000000..245360d --- /dev/null +++ b/test/templates/fail_public_read/src/links.json @@ -0,0 +1,3 @@ +{ + "/": "https://www.youtube.com/watch?v=CsHiG-43Fzg" +} diff --git a/test/templates/pass/public/robots.txt b/test/templates/pass/public/robots.txt new file mode 100644 index 0000000..f5ace81 --- /dev/null +++ b/test/templates/pass/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: Google +Disallow: / diff --git a/test/templates/pass/public/test/test.txt b/test/templates/pass/public/test/test.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/templates/pass/src/links.json b/test/templates/pass/src/links.json new file mode 100644 index 0000000..fcb8fc9 --- /dev/null +++ b/test/templates/pass/src/links.json @@ -0,0 +1,5 @@ +{ + "/": "https://www.youtube.com/watch?v=CsHiG-43Fzg", + "1": "https://fee.org/articles/the-use-of-knowledge-in-society/", + "gh": "https://github.com/surishortlink/suri" +} diff --git a/test/templates/pass_js/src/links.json b/test/templates/pass_js/src/links.json new file mode 100644 index 0000000..245360d --- /dev/null +++ b/test/templates/pass_js/src/links.json @@ -0,0 +1,3 @@ +{ + "/": "https://www.youtube.com/watch?v=CsHiG-43Fzg" +} diff --git a/test/templates/pass_js/suri.config.json b/test/templates/pass_js/suri.config.json new file mode 100644 index 0000000..b4c5836 --- /dev/null +++ b/test/templates/pass_js/suri.config.json @@ -0,0 +1,3 @@ +{ + "js": true +} diff --git a/test/templates/pass_no_public/src/links.json b/test/templates/pass_no_public/src/links.json new file mode 100644 index 0000000..245360d --- /dev/null +++ b/test/templates/pass_no_public/src/links.json @@ -0,0 +1,3 @@ +{ + "/": "https://www.youtube.com/watch?v=CsHiG-43Fzg" +} diff --git a/test/utilities.js b/test/utilities.js new file mode 100644 index 0000000..5ff374c --- /dev/null +++ b/test/utilities.js @@ -0,0 +1,18 @@ +import { readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const TEMPLATES_DIR_PATH = join( + dirname(fileURLToPath(import.meta.url)), + 'templates', +) + +function joinTemplates(...paths) { + return join(TEMPLATES_DIR_PATH, ...paths) +} + +async function readTemplatesFile(...paths) { + return await readFile(joinTemplates(...paths), { encoding: 'utf8' }) +} + +export { joinTemplates, readTemplatesFile }