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 }