diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 9b1c8b1..0000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -/dist diff --git a/.eslintrc.cjs b/.eslintrc.cjs index e21a328..6dabfef 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -4,13 +4,15 @@ module.exports = { 'eslint:recommended', 'oclif', 'oclif-typescript', - 'plugin:prettier/recommended', // needs to be last + 'plugin:prettier/recommended' // needs to be last ], plugins: ['@typescript-eslint'], /* First exclude everything, then re-include /src, then exclude tests */ ignorePatterns: ['/*', '!/src', '*.test.ts'], rules: { - 'no-unused-vars': ['error', { vars: 'all', args: 'none' }], + // The base rule reports TS enums and types as unused + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error', { vars: 'all', args: 'none' }], '@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': 'allow-with-description' }], '@typescript-eslint/unbound-method': 'off', '@typescript-eslint/no-floating-promises': 'off', diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 97359eb..f3f6411 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: lts/* registry-url: https://registry.npmjs.org - run: npm ci - run: npm publish --access public diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..f2c333c --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,29 @@ +name: Unit tests + +on: + push: + branches: [main, master, next] + pull_request: + workflow_dispatch: + +jobs: + run-tests: + name: Run unit tests + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check out repo + uses: actions/checkout@v6 + + - name: Set up node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - run: npm ci + - run: npm run build + + # Runs mocha, then lint via posttest + - name: Run tests + run: npm test diff --git a/.github/workflows/version-update.yml b/.github/workflows/version-update.yml index 42fdbac..681341d 100644 --- a/.github/workflows/version-update.yml +++ b/.github/workflows/version-update.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: lts/* - run: echo "Updating ${{ inputs.segment }} version" - run: npm ci - name: Update version diff --git a/.gitignore b/.gitignore index 91e9fad..116b34f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,7 @@ *-debug.log *-error.log /.idea -/.nyc_output /dist -/lib -/package-lock.json /tmp /yarn.lock node_modules diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..54a6be7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Commands section is generated by `oclif readme` +README.md diff --git a/bin/dev.cmd b/bin/dev.cmd deleted file mode 100644 index 8ae2b12..0000000 --- a/bin/dev.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -node "%~dp0\dev" %* diff --git a/bin/dev.js b/bin/dev.js deleted file mode 100755 index c8f0220..0000000 --- a/bin/dev.js +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env ts-node -// eslint-disable-next-line node/shebang -(async () => { - const oclif = await import('@oclif/core') - await oclif.execute({development: true, dir: import.meta.url}) -})() diff --git a/bin/run.js b/bin/run.js index 05438fe..65a42c2 100755 --- a/bin/run.js +++ b/bin/run.js @@ -1,6 +1,6 @@ #!/usr/bin/env node (async () => { - const oclif = await import('@oclif/core') - await oclif.execute({dir: import.meta.url}) -})() + const oclif = await import('@oclif/core'); + await oclif.execute({ dir: import.meta.url }); +})(); diff --git a/package-lock.json b/package-lock.json index 497fe3b..41fcbb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,13 +30,10 @@ "swup": "bin/run.js" }, "devDependencies": { - "@oclif/test": "^2.5.6", "@types/chai": "^4.3.6", - "@types/crawler": "^1.2.3", "@types/lodash-es": "^4.17.9", "@types/mocha": "^10.0.1", "@types/node": "^20.7.0", - "@types/shelljs": "^0.8.12", "chai": "^4.0.0", "eslint": "^8.50.0", "eslint-config-oclif": "^5.0.0", @@ -50,7 +47,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2617,58 +2614,6 @@ "node": ">=14.0.0" } }, - "node_modules/@oclif/test": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@oclif/test/-/test-2.5.6.tgz", - "integrity": "sha512-AcusFApdU6/akXaofhBDrY4IM9uYzlOD9bYCCM0NwUXOv1m6320hSp2DT/wkj9H1gsvKbJXZHqgtXsNGZTWLFg==", - "dev": true, - "dependencies": { - "@oclif/core": "^2.15.0", - "fancy-test": "^2.0.42" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@oclif/test/node_modules/@oclif/core": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/@oclif/core/-/core-2.15.0.tgz", - "integrity": "sha512-fNEMG5DzJHhYmI3MgpByTvltBOMyFcnRIUMxbiz2ai8rhaYgaTHMG3Q38HcosfIvtw9nCjxpcQtC8MN8QtVCcA==", - "dev": true, - "dependencies": { - "@types/cli-progress": "^3.11.0", - "ansi-escapes": "^4.3.2", - "ansi-styles": "^4.3.0", - "cardinal": "^2.1.1", - "chalk": "^4.1.2", - "clean-stack": "^3.0.1", - "cli-progress": "^3.12.0", - "debug": "^4.3.4", - "ejs": "^3.1.8", - "get-package-type": "^0.1.0", - "globby": "^11.1.0", - "hyperlinker": "^1.0.0", - "indent-string": "^4.0.0", - "is-wsl": "^2.2.0", - "js-yaml": "^3.14.1", - "natural-orderby": "^2.0.3", - "object-treeify": "^1.1.33", - "password-prompt": "^1.1.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "supports-color": "^8.1.1", - "supports-hyperlinks": "^2.2.0", - "ts-node": "^10.9.1", - "tslib": "^2.5.0", - "widest-line": "^3.1.0", - "wordwrap": "^1.0.0", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@octokit/auth-token": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz", @@ -3432,15 +3377,6 @@ "integrity": "sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==", "dev": true }, - "node_modules/@types/cheerio": { - "version": "0.22.32", - "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.32.tgz", - "integrity": "sha512-4RrpCp5ufWTLb6/1RCOjazRhUM6DTD79l763det29n8kLmPB7XeN46cxlUf2GsSF+0g6CbWT5nYl8C/Gs15bdg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/cli-progress": { "version": "3.11.3", "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.3.tgz", @@ -3449,15 +3385,6 @@ "@types/node": "*" } }, - "node_modules/@types/crawler": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@types/crawler/-/crawler-1.2.3.tgz", - "integrity": "sha512-2hvxnm5sa5tYKGB+t7zeNQAZ4pw1LMb0eVqjEpNUSTgIr7u993M92JVofO/QIVCK3lu+YkzYr0ZTcF2haHu4Jg==", - "dev": true, - "dependencies": { - "@types/cheerio": "*" - } - }, "node_modules/@types/estree": { "version": "0.0.39", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", @@ -3469,16 +3396,6 @@ "integrity": "sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==", "dev": true }, - "node_modules/@types/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", - "dev": true, - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -3521,12 +3438,6 @@ "@types/lodash": "*" } }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", - "dev": true - }, "node_modules/@types/mocha": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.2.tgz", @@ -3572,31 +3483,6 @@ "integrity": "sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw==", "dev": true }, - "node_modules/@types/shelljs": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@types/shelljs/-/shelljs-0.8.13.tgz", - "integrity": "sha512-++uMLOQSLlse1kCfEOwhgmHuaABZwinkylmUKCpvcEGZUov3TtM+gJZloSkW/W+9pEAEg/VBOwiSR05oqJsa5A==", - "dev": true, - "dependencies": { - "@types/glob": "~7.2.0", - "@types/node": "*" - } - }, - "node_modules/@types/sinon": { - "version": "10.0.17", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.17.tgz", - "integrity": "sha512-+6ILpcixQ0Ma3dHMTLv4rSycbDXkDljgKL+E0nI2RUxxhYTFyPSjt6RVMxh7jUshvyVcBvicb0Ktj+lAJcjgeA==", - "dev": true, - "dependencies": { - "@types/sinonjs__fake-timers": "*" - } - }, - "node_modules/@types/sinonjs__fake-timers": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.3.tgz", - "integrity": "sha512-4g+2YyWe0Ve+LBh+WUm1697PD0Kdi6coG1eU0YjQbwx61AZ8XbEpL1zIT6WjuUKrCMCROpEaYQPDjBnDouBVAQ==", - "dev": true - }, "node_modules/@types/vinyl": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@types/vinyl/-/vinyl-2.0.8.tgz", @@ -7233,25 +7119,6 @@ "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", "dev": true }, - "node_modules/fancy-test": { - "version": "2.0.42", - "resolved": "https://registry.npmjs.org/fancy-test/-/fancy-test-2.0.42.tgz", - "integrity": "sha512-TX8YTALYAmExny+f+G24MFxWry3Pk09+9uykwRjfwjibRxJ9ZjJzrnHYVBZK46XQdyli7d+rQc5U/KK7V6uLsw==", - "dev": true, - "dependencies": { - "@types/chai": "*", - "@types/lodash": "*", - "@types/node": "*", - "@types/sinon": "*", - "lodash": "^4.17.13", - "mock-stdin": "^1.0.0", - "nock": "^13.3.3", - "stdout-stderr": "^0.1.9" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9109,12 +8976,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -10227,12 +10088,6 @@ "node": ">=10" } }, - "node_modules/mock-stdin": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mock-stdin/-/mock-stdin-1.0.0.tgz", - "integrity": "sha512-tukRdb9Beu27t6dN+XztSRHq9J0B/CoAOySGzHfn8UTfmqipA5yNT/sDUEyYdAV3Hpka6Wx6kOMxuObdOex60Q==", - "dev": true - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -10301,21 +10156,6 @@ "node": ">= 0.6" } }, - "node_modules/nock": { - "version": "13.3.3", - "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.3.tgz", - "integrity": "sha512-z+KUlILy9SK/RjpeXDiDUEAq4T94ADPHE3qaRkf66mpEhzc/ytOMm3Bwdrbq6k1tMWkbdujiKim3G2tfQARuJw==", - "dev": true, - "dependencies": { - "debug": "^4.1.0", - "json-stringify-safe": "^5.0.1", - "lodash": "^4.17.21", - "propagate": "^2.0.0" - }, - "engines": { - "node": ">= 10.13" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -12324,15 +12164,6 @@ "node": ">=0.12" } }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -14068,19 +13899,6 @@ "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility" }, - "node_modules/stdout-stderr": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/stdout-stderr/-/stdout-stderr-0.1.13.tgz", - "integrity": "sha512-Xnt9/HHHYfjZ7NeQLvuQDyL1LnbsbddgMFKCuaQKwGCdJm8LnstZIXop+uOY36UR1UXXoHXfMbC1KlVdVd2JLA==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/package.json b/package.json index 225ad1a..ef14dd3 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,6 @@ "files": [ "/bin", "/dist", - "/package-lock.json", - "/npm-shrinkwrap.json", "/oclif.manifest.json" ], "dependencies": { @@ -41,13 +39,10 @@ "typescript": "^5.2.2" }, "devDependencies": { - "@oclif/test": "^2.5.6", "@types/chai": "^4.3.6", - "@types/crawler": "^1.2.3", "@types/lodash-es": "^4.17.9", "@types/mocha": "^10.0.1", "@types/node": "^20.7.0", - "@types/shelljs": "^0.8.12", "chai": "^4.0.0", "eslint": "^8.50.0", "eslint-config-oclif": "^5.0.0", @@ -78,6 +73,7 @@ "scripts": { "build": "shx rm -rf dist && tsc -b", "lint": "eslint . --ext .ts", + "format": "./bin/run.js package:format", "postpack": "shx rm -f oclif.manifest.json", "posttest": "npm run lint", "prepack": "npm run build && oclif manifest && oclif readme", @@ -85,7 +81,7 @@ "version": "npm run build && oclif readme && git add README.md" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" }, "bugs": "https://github.com/swup/cli/issues", "keywords": [ diff --git a/src/assets/cli-screenshot-validate.png b/src/assets/cli-screenshot-validate.png deleted file mode 100644 index 2525ad0..0000000 Binary files a/src/assets/cli-screenshot-validate.png and /dev/null differ diff --git a/src/browser.ts b/src/browser.ts index c0d550c..5c5876f 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1,4 +1,4 @@ -import { URL } from 'url'; +import { URL } from 'node:url'; import { chromium } from 'playwright'; import type { Browser, Page } from 'playwright'; @@ -22,7 +22,7 @@ export async function createBrowser(): Promise<{ teardown: () => Promise; }> { const browser = await chromium.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] }); - const teardown = async () => await browser.close(); + const teardown = async () => browser.close(); return { browser, teardown }; } @@ -33,31 +33,20 @@ export async function visitPage(browser: Browser, url: string) { } export async function elementExists(page: Page, selector: string): Promise { - return await page.evaluate((selector) => !!document.querySelector(selector), selector); + return page.evaluate((selector) => Boolean(document.querySelector(selector)), selector); } export async function countElementsOnPage(page: Page, selector: string): Promise { - return await page.evaluate((selector) => document.querySelectorAll(selector).length, selector); + return page.evaluate((selector) => document.querySelectorAll(selector).length, selector); } -export async function getNumberOfContainers(page: Page, selectors: string[]): Promise { - return await countElementsOnPage(page, selectors.join(', ')); -} - -export async function validateNumberOfContainers( - page: Page, - url: string, - expected: number, - containers: string[] -) { - const received = await getNumberOfContainers(page, containers); - if (received !== expected) { - return { - text: 'Incorrect number of containers', - expected, - received, - page: url - }; +export async function validateContainers(page: Page, containers: string[]) { + for (const selector of containers) { + // eslint-disable-next-line no-await-in-loop + const count = await countElementsOnPage(page, selector); + if (count !== 1) { + throw new Error(`Expected exactly one container ${selector}, found ${count}`); + } } } @@ -66,7 +55,7 @@ export async function getStyleProperty( selector: string, property: string ): Promise { - return await page.evaluate( + return page.evaluate( ({ selector, property }) => { const element = document.querySelector(selector); return element ? window.getComputedStyle(element).getPropertyValue(property) : ''; @@ -80,14 +69,11 @@ async function getStyleProperties( selector: string, properties: string[] ): Promise { - const styles: Styles = {}; - for (const prop of properties) { - const style = await getStyleProperty(page, selector, prop); - if (style) { - styles[prop] = style; - } - } - return styles; + const values = await Promise.all( + properties.map((prop) => getStyleProperty(page, selector, prop)) + ); + const entries = properties.map((prop, i) => [prop, values[i]]); + return Object.fromEntries(entries.filter(([, value]) => value)); } async function addChangingClass(page: Page) { @@ -102,16 +88,27 @@ async function addAnimatingClass(page: Page) { await page.evaluate(() => document.documentElement.classList.add('is-animating')); } +/** + * Get the animation duration of an element in milliseconds. + * Computed styles always report durations in seconds (e.g. `0.3s`). + * @param page The page to evaluate + * @param selector The animated element + * @returns The longer of transition and animation duration + */ export async function getAnimationDuration(page: Page, selector: string): Promise { - const transition = parseFloat(await getStyleProperty(page, selector, 'transition-duration')); - const animation = parseFloat(await getStyleProperty(page, selector, 'animation-duration')); + const transition = Number.parseFloat( + await getStyleProperty(page, selector, 'transition-duration') + ); + const animation = Number.parseFloat( + await getStyleProperty(page, selector, 'animation-duration') + ); const duration = Math.max(transition, animation); - return duration; + return duration * 1000; } export async function validateAnimationDuration(page: Page, selector: string) { await addChangingClass(page); - const duration = getAnimationDuration(page, selector); + const duration = await getAnimationDuration(page, selector); await removeChangingClass(page); if (!duration) { throw new Error(`Missing animation duration on element: ${selector}`); @@ -132,14 +129,14 @@ export async function validateAnimationStyles(page: Page, selector: string, styl const after = await getStyleProperties(page, selector, styles); await removeChangingClass(page); - if (!Object.keys(before).length && !Object.keys(after).length) { + if (Object.keys(before).length === 0 && Object.keys(after).length === 0) { throw new Error(`Styles not found on element: ${selector} (${styles.join(', ')})`); } - for (const prop of Object.keys(before)) { - if (before[prop] === after[prop]) { - throw new Error(`Styles not changed on element: ${selector} (${prop})`); - } + // At least one style must change: a fade doesn't touch transform and vice versa + const changed = styles.some((prop) => before[prop] !== after[prop]); + if (!changed) { + throw new Error(`Styles not changed on element: ${selector} (${styles.join(', ')})`); } } @@ -147,36 +144,41 @@ export function crawlSiteForUrls(url: string): Promise { const base = new URL(url); const urls: string[] = []; - const options: Crawler.CreateCrawlerOptions = { - maxConnections: 10, - skipDuplicates: true - }; - return new Promise((resolve) => { const crawler = new Crawler({ - ...options, - callback: (error, { request, statusCode, headers, $ }, done) => { + maxConnections: 10, + skipDuplicates: true, + callback(error, { url: href, statusCode, headers, $ }, release) { + const done = release as () => void; if (error) { console.error(error); return done(); } + if (statusCode < 200 || statusCode >= 400) { console.warn( - chalk`{yellow !} Received status {yellow ${statusCode}} {dim on} ${getLocalUrl(request.uri.href)}` + chalk`{yellow !} Received status {yellow ${statusCode}} {dim on} ${getLocalUrl( + href + )}` ); return done(); } + if (!isHtmlContentType(headers)) { return done(); } - urls.push(request.uri.href); - $('a[href]:not([download])').each((i, el) => { + + urls.push(href); + $('a[href]:not([download])').each((_: number, el: unknown) => { const href = removeHash(String($(el).attr('href')).trim()); const url = new URL(href, base); - if (isValidUrl(url)) { - if (!urls.includes(url.href) && isLocalUrl(url, base) && !isAssetUrl(url)) { - crawler.queue(url.href); - } + if ( + isValidUrl(url) && + !urls.includes(url.href) && + isLocalUrl(url, base) && + !isAssetUrl(url) + ) { + crawler.queue(url.href); } }); setTimeout(done, 5); diff --git a/src/commands/create.ts b/src/commands/create.ts index 10c56e1..f906697 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -1,5 +1,5 @@ -import * as fs from 'fs/promises'; -import { join } from 'path'; +import * as fs from 'node:fs/promises'; +import { join } from 'node:path'; import { Args, Command, Flags } from '@oclif/core'; import chalk from 'chalk'; @@ -33,6 +33,7 @@ export default class Create extends Command { static summary = 'Create a swup plugin or theme'; static description = 'Generate a new swup plugin or theme from an official, best-practice template'; + static examples = [ `<%= config.bin %> <%= command.id %> SwupExamplePlugin`, `<%= config.bin %> <%= command.id %> SwupExampleTheme` @@ -69,7 +70,7 @@ export default class Create extends Command { } = await this.parse(Create); let type = Type[flags.type as keyof typeof Type]; - if (type !== Type.theme && args.name.match(/theme$/i)) { + if (type !== Type.theme && /theme$/i.test(args.name)) { type = Type.theme; this.log( chalk`Provided name {magenta ${args.name}} ends with {magenta Theme}. Creating a theme instead.` @@ -125,11 +126,14 @@ export default class Create extends Command { `Unable to create directory ${ctx.dir}. A file of the same name already exists.` ); } + if (!empty) { throw new Error(`Directory ${ctx.dir} already exists and has files in it.`); } + return; } + try { await fs.mkdir(ctx.path); } catch (error) { @@ -161,8 +165,8 @@ export default class Create extends Command { const pluginPath = join(ctx.path, 'src/index.js'); try { let data = await fs.readFile(pluginPath, 'utf8'); - data = data.replace(/PluginName/g, ctx.names.pascal); - data = data.replace(/ThemeName/g, ctx.names.pascal); + data = data.replaceAll('PluginName', ctx.names.pascal); + data = data.replaceAll('ThemeName', ctx.names.pascal); await fs.writeFile(pluginPath, data); } catch (error) { throw new Error(`Error updating plugin file: ${error}`); @@ -198,22 +202,25 @@ export default class Create extends Command { const readmePath = join(ctx.path, 'readme.md'); try { let data = await fs.readFile(readmePath, 'utf8'); - data = data.replace( - / *\[comment\]: CLI-remove-start[\s\S]*\[comment\]: CLI-remove-end */g, + data = data.replaceAll( + / *\[comment]: CLI-remove-start[\S\s]*\[comment]: CLI-remove-end */g, '' ); - data = data.replace( - /swup-\[(plugin-name|theme-name)\]-(plugin|theme)/g, + data = data.replaceAll( + /swup-\[(plugin-name|theme-name)]-(plugin|theme)/g, ctx.names.kebab ); - data = data.replace( - /Swup \[(Plugin Name|Theme Name)\] (Plugin|Theme)/g, + data = data.replaceAll( + /Swup \[(Plugin Name|Theme Name)] (Plugin|Theme)/g, ctx.names.title ); - data = data.replace(/ \[(Plugin Name|Theme Name)\]/g, ctx.names.title); - data = data.replace(/Swup\[(PluginName|ThemeName)\](Plugin|Theme)/g, ctx.names.pascal); - data = data.replace(/\[(PluginName|ThemeName)\]/g, ctx.names.pascal); - data = data.replace(/(SwupNamePlugin|SwupNameTheme)/g, ctx.names.pascal); + data = data.replaceAll(/ \[(Plugin Name|Theme Name)]/g, ctx.names.title); + data = data.replaceAll( + /Swup\[(PluginName|ThemeName)](Plugin|Theme)/g, + ctx.names.pascal + ); + data = data.replaceAll(/\[(PluginName|ThemeName)]/g, ctx.names.pascal); + data = data.replaceAll(/(SwupNamePlugin|SwupNameTheme)/g, ctx.names.pascal); data = `${data.trim()}\n`; await fs.writeFile(readmePath, data); } catch (error) { diff --git a/src/commands/package/build.ts b/src/commands/package/build.ts index 2da298c..6ba8fd9 100644 --- a/src/commands/package/build.ts +++ b/src/commands/package/build.ts @@ -6,7 +6,7 @@ import { exec } from '../../shell.js'; import { checkPluginPackageInfo, loadPackageInfo } from '../../package.js'; interface Ctx { - pckg?: unknown; + pckg?: { name?: string }; } export default class Build extends Command { @@ -17,7 +17,8 @@ export default class Build extends Command { static flags = { check: Flags.boolean({ summary: 'Check package info', - description: 'Check for required package.json fields before bundling. Disable using --no-check.', + description: + 'Check for required package.json fields before bundling. Disable using --no-check.', required: false, default: true, allowNo: true @@ -32,14 +33,14 @@ export default class Build extends Command { [ { title: 'Load package info', - task: async (ctx) => { + async task(ctx) { ctx.pckg = await loadPackageInfo(); } }, { title: 'Check package info', enabled: () => flags.check, - task: async (ctx) => { + async task(ctx) { const { errors } = checkPluginPackageInfo(ctx.pckg); if (errors?.length) { throw new Error(errors.join('\n')); @@ -48,20 +49,23 @@ export default class Build extends Command { }, { title: 'Bundle package', - task: async (ctx, task) => { - // @ts-ignore + async task(ctx, task) { task.title = chalk`Bundle package {magenta ${ctx.pckg?.name}}`; return task.newListr(() => [ { title: 'Create module bundle', - task: async () => { - exec('BROWSERSLIST_ENV=modern npx microbundle -f modern,esm,cjs --css inline'); + async task() { + exec( + 'BROWSERSLIST_ENV=modern npx microbundle -f modern,esm,cjs --css inline' + ); } }, { title: 'Create standalone bundle', - task: async () => { - exec('BROWSERSLIST_ENV=production npx microbundle -f umd --css inline --external none --define process.env.NODE_ENV=production'); + async task() { + exec( + 'BROWSERSLIST_ENV=production npx microbundle -f umd --css inline --external none --define process.env.NODE_ENV=production' + ); } } ]); diff --git a/src/commands/package/check.ts b/src/commands/package/check.ts index 4c30b56..2b74a82 100644 --- a/src/commands/package/check.ts +++ b/src/commands/package/check.ts @@ -18,13 +18,13 @@ export default class Check extends Command { [ { title: 'Load package info', - task: async () => { + async task() { ctx.pckg = await loadPackageInfo(); } }, { title: 'Check package info', - task: async (ctx) => { + async task(ctx) { const { errors } = checkPluginPackageInfo(ctx.pckg); if (errors?.length) { throw new Error(errors.join('\n')); diff --git a/src/commands/package/dev.ts b/src/commands/package/dev.ts index 40882c8..175d089 100644 --- a/src/commands/package/dev.ts +++ b/src/commands/package/dev.ts @@ -6,18 +6,21 @@ import { exec } from '../../shell.js'; import { checkPluginPackageInfo, loadPackageInfo } from '../../package.js'; interface Ctx { - pckg?: unknown; + pckg?: { name?: string }; } export default class Dev extends Command { static summary = 'Develop package'; - static description = 'Build package code for development using microbundle and watch for changes'; + static description = + 'Build package code for development using microbundle and watch for changes'; + static examples = ['<%= config.bin %> <%= command.id %>']; static flags = { check: Flags.boolean({ summary: 'Check package info', - description: 'Check for required package.json fields before bundling. Disable using --no-check.', + description: + 'Check for required package.json fields before bundling. Disable using --no-check.', required: false, default: true, allowNo: true @@ -32,14 +35,14 @@ export default class Dev extends Command { [ { title: 'Load package info', - task: async (ctx) => { + async task(ctx) { ctx.pckg = await loadPackageInfo(); } }, { title: 'Check package info', enabled: () => flags.check, - task: async (ctx) => { + async task(ctx) { const { errors } = checkPluginPackageInfo(ctx.pckg); if (errors?.length) { throw new Error(errors.join('\n')); @@ -48,14 +51,15 @@ export default class Dev extends Command { }, { title: 'Bundle package', - task: async (ctx, task) => { - // @ts-ignore + async task(ctx, task) { task.title = chalk`Bundle package {magenta ${ctx.pckg?.name}} and watch for changes`; return task.newListr(() => [ { title: 'Start watch mode', - task: async () => { - exec('BROWSERSLIST_ENV=development npx microbundle -f modern --css inline -w'); + async task() { + exec( + 'BROWSERSLIST_ENV=development npx microbundle -f modern --css inline -w' + ); } } ]); diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 829dff6..8d37274 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,6 +1,3 @@ -import * as fs from 'fs/promises'; -import { join } from 'path'; - import { Command, Flags } from '@oclif/core'; import chalk from 'chalk'; import { Listr, ListrTask } from 'listr2'; @@ -11,9 +8,10 @@ import { createBrowser, validateAnimationDuration, validateAnimationStyles, + validateContainers, visitPage } from '../browser.js'; -import { getLocalUrl, isUrl, isValidUrl, n } from '../util.js'; +import { getLocalUrl, isValidUrl, n, readSitemapUrls } from '../util.js'; import { loadConfig, type Config } from '../config.js'; interface Ctx { @@ -28,6 +26,7 @@ export default class Validate extends Command { static summary = 'Validate a swup-powered site'; static description = 'Crawl your site and validate that all pages are accessible and render correctly'; + static examples = [ `<%= config.bin %> <%= command.id %>`, `<%= config.bin %> <%= command.id %> --url https://mysite.com/about`, @@ -35,6 +34,7 @@ export default class Validate extends Command { `<%= config.bin %> <%= command.id %> --tests containers,transition-duration`, `<%= config.bin %> <%= command.id %> --asynchronous` ]; + static flags = { url: Flags.string({ char: 'u', @@ -110,11 +110,11 @@ export default class Validate extends Command { const tasks: ListrTask[] = [ { title: 'Set up', - task: async (ctx, task) => { + async task(ctx, task) { return task.newListr(() => [ { title: 'Launch browser', - task: async (ctx) => { + async task(ctx) { const { browser, teardown } = await createBrowser(); ctx.browser = browser; ctx.teardown = teardown; @@ -136,17 +136,21 @@ export default class Validate extends Command { }, { title: 'Limit pages', - enabled: (ctx) => ctx.config.validate.limit > 0 && ctx.urls.length > ctx.config.validate.limit, - task: async (ctx, task) => { + enabled: (ctx) => + ctx.config.validate.limit > 0 && ctx.urls.length > ctx.config.validate.limit, + async task(ctx, task) { ctx.urls = ctx.urls.slice(0, ctx.config.validate.limit); - task.title = chalk`Limit to {blue ${ctx.urls.length} ${n(ctx.urls.length, 'page')}}`; + task.title = chalk`Limit to {blue ${ctx.urls.length} ${n( + ctx.urls.length, + 'page' + )}}`; } }, { title: 'Validate pages', task: async (ctx, task) => { return task.newListr( - ctx.urls.map((url, index) => ({ + ctx.urls.map((url) => ({ title: `Validating ${getLocalUrl(url)}`, task: async (_, subtask) => { try { @@ -155,6 +159,8 @@ export default class Validate extends Command { } catch (error) { ctx.errors.push(error as Error); subtask.title = chalk`{red Failed} ${getLocalUrl(url)}`; + // Rethrow to mark the task as failed, exitOnError: false keeps going + throw error; } } })), @@ -165,13 +171,12 @@ export default class Validate extends Command { { title: 'Report results', task: async ({ errors, urls: { length: total } }, task) => { - if (errors.length) { - errors.forEach((error) => this.warn(error)); + if (errors.length > 0) { + for (const error of errors) this.warn(error); this.error( - chalk`{red.bold Validation failed} for {red ${errors.length}/${total}} ${n( - total, - 'page' - )}` + chalk`{red.bold Validation failed} for {red ${ + errors.length + }/${total}} ${n(total, 'page')}` ); } else { task.title = chalk`{green.bold Validation passed} for {green ${total}/${total}} ${n( @@ -187,7 +192,7 @@ export default class Validate extends Command { task.newListr(() => [ { title: 'Closing browser', - task: async (ctx) => { + async task(ctx) { await ctx.teardown!(); ctx.teardown = undefined; } @@ -202,12 +207,12 @@ export default class Validate extends Command { if (ctx.teardown) { await ctx.teardown(); } + throw error; } } async catch(error: Error) { - // this.error(error as Error) throw error; } @@ -215,7 +220,7 @@ export default class Validate extends Command { const { flags } = await this.parse(Validate); const overrides = { swup: { - animationSelector: flags.animationSelector, + animationSelector: flags.animation, containers: flags.containers.split(',').map((style) => style.trim()) }, validate: { @@ -223,7 +228,7 @@ export default class Validate extends Command { crawl: flags.crawl, sitemap: flags.sitemap, limit: Number(flags.limit), - asynchronous: flags.asynchronous, + parallel: flags.parallel, tests: flags.tests .split(',') .map((style) => style.trim()) @@ -231,7 +236,7 @@ export default class Validate extends Command { styles: flags.styles.split(',').map((style) => style.trim()) } }; - return await loadConfig(overrides); + return loadConfig(overrides); } async getPageUrls(ctx: Ctx): Promise<{ urls: string[]; source: string }> { @@ -244,6 +249,7 @@ export default class Validate extends Command { `Invalid URL: ${url}. Make sure you include the protocol and hostname.` ); } + if (crawl) { source = 'crawled site'; urls = await crawlSiteForUrls(url); @@ -253,60 +259,50 @@ export default class Validate extends Command { } } else if (sitemap) { source = 'parsed sitemap'; - urls = await this.getPageUrlsFromSitemap(ctx); + urls = await readSitemapUrls(sitemap); } else { throw new Error('You must specify either a url or a sitemap to validate.'); } - urls = [...new Set(urls)]; - return { urls, source }; - } - async getPageUrlsFromSitemap(ctx: Ctx): Promise { - const { sitemap } = ctx.config.validate; - let contents; - if (isUrl(sitemap)) { - try { - contents = await fetch(sitemap).then((res) => res.text()); - } catch (error) { - throw new Error(`Error fetching sitemap: ${error}`); - } - } else { - try { - contents = await fs.readFile(join(process.cwd(), sitemap), 'utf8'); - } catch (error) { - throw new Error(`Error reading sitemap: ${error}`); - } + urls = [...new Set(urls)]; + if (urls.length === 0) { + throw new Error(`No pages found in ${source}.`); } - // return (JSON.parse(parser.toJson(sitemap)).urlset.url.map(i => i.loc)) - return []; + return { urls, source }; } async validatePage(ctx: Ctx, url: string): Promise { const { tests, styles } = ctx.config.validate; - const { animationSelector } = ctx.config.swup; + const { animationSelector, containers } = ctx.config.swup; const { browser } = ctx; const page = await visitPage(browser!, url); const allChecks = { + 'containers': () => validateContainers(page, containers), 'transition-duration': () => validateAnimationDuration(page, animationSelector), 'transition-styles': () => validateAnimationStyles(page, animationSelector, styles) }; const checks = Object.entries(allChecks).filter( - ([test]) => !tests.length || tests.includes(test) + ([test]) => tests.length === 0 || tests.includes(test) ); - if (!checks.length) { + if (checks.length === 0) { throw new Error( `No valid tests specified. Available tests: ${Object.keys(allChecks).join(', ')}` ); } - for (const [, test] of checks) { - try { + + try { + // Run checks one after another: they toggle classes on the same page + for (const [, test] of checks) { + // eslint-disable-next-line no-await-in-loop await test(); - } catch (error) { - throw new Error( - chalk`Validation {red failed} for {magenta ${getLocalUrl(url)}}: ${error}` - ); } + } catch (error) { + throw new Error( + chalk`Validation {red failed} for {magenta ${getLocalUrl(url)}}: ${error}` + ); + } finally { + await page.close(); } } } diff --git a/src/package.ts b/src/package.ts index bd187df..37a8ea7 100644 --- a/src/package.ts +++ b/src/package.ts @@ -10,27 +10,35 @@ export function checkPluginPackageInfo(data?: unknown): { errors: string[] } { errors.push('Not a valid package.json'); return { errors }; } + if (!pckg.amdName) { errors.push('package.json missing `amdName` property'); } + if (pckg.type !== 'module') { errors.push('package.json `type` property must be "module"'); } + if (!pckg.source) { errors.push('package.json missing `source` property'); } + if (!pckg.main) { errors.push('package.json missing `main` property'); } + if (!pckg.module) { errors.push('package.json missing `module` property'); } + if (!pckg.unpkg) { errors.push('package.json missing `unpkg` property'); } + if (!pckg.exports) { errors.push('package.json missing `exports` property'); } + if (!pckg.browserslist) { errors.push('package.json missing `browserslist` property'); } @@ -38,7 +46,7 @@ export function checkPluginPackageInfo(data?: unknown): { errors: string[] } { return { errors }; } -export async function loadPackageInfo(): Promise { +export async function loadPackageInfo(): Promise<{ name?: string }> { try { const importPath = new URL(resolve('package.json'), import.meta.url); return JSON.parse(await readFile(importPath, { encoding: 'utf8' })); diff --git a/src/repo.ts b/src/repo.ts index 708a31d..af90bf6 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -1,4 +1,4 @@ -import { exec } from 'child_process'; +import { exec } from 'node:child_process'; export function cloneRepo(repoUrl: string, destination: string): Promise { return new Promise((resolve, reject) => { @@ -8,6 +8,7 @@ export function cloneRepo(repoUrl: string, destination: string): Promise { new Error(`Failed to clone repo. Error: ${error.message}. Stderr: ${stderr}`) ); } + resolve(); }); }); diff --git a/src/resolve.ts b/src/resolve.ts index b411a47..4f8b90b 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -1,12 +1,12 @@ -import * as path from 'path'; -import * as fs from 'fs'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; const appDirectory = fs.realpathSync(process.cwd()); export function resolve(relativePath = '') { if (relativePath) { return path.resolve(appDirectory, relativePath); - } else { - return appDirectory; } + + return appDirectory; } diff --git a/src/shell.ts b/src/shell.ts index af4de40..09fe3c2 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -1,10 +1,12 @@ -import shell from 'shelljs'; import live from 'shelljs-live'; export function exec(command: string | string[]) { - const status = live(command); // shell.exec(command) + const status = live(command); if (status && status >= 0) { - shell.exit(status); + // Exit with the child's status code, so failing builds fail CI + // eslint-disable-next-line no-process-exit, unicorn/no-process-exit + process.exit(status); } + return status; } diff --git a/src/util.ts b/src/util.ts index 827d15f..f212c91 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,10 +1,13 @@ -import * as fs from 'fs/promises'; -import { URL } from 'url'; +import * as fs from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { URL } from 'node:url'; import { camelCase, upperFirst } from 'lodash-es'; export function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); } export function pascalCase(str: string): string { @@ -15,19 +18,71 @@ export function isUrl(str: string): boolean { return /^https?:\/\//.test(str); } +function decodeXmlEntities(str: string): string { + return str + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('&', '&'); +} + +/** + * Read all page URLs from a sitemap file or URL. + * Sitemap indexes (e.g. wp-sitemap.xml) are followed recursively. + * @see https://www.sitemaps.org/protocol.html + * @param sitemap Local file path or URL + * @returns All page URLs found + */ +export async function readSitemapUrls(sitemap: string): Promise { + let contents: string; + if (isUrl(sitemap)) { + let res: Response; + try { + res = await fetch(sitemap); + } catch (error) { + throw new Error(`Error fetching sitemap: ${error}`); + } + + if (!res.ok) { + throw new Error(`Error fetching sitemap: HTTP ${res.status} for ${sitemap}`); + } + + contents = await res.text(); + } else { + try { + contents = await fs.readFile(resolve(process.cwd(), sitemap), 'utf8'); + } catch (error) { + throw new Error(`Error reading sitemap: ${error}`); + } + } + + const locs = [...contents.matchAll(/\s*(?:)?\s*<\/loc>/gs)] + .map(([, loc]) => decodeXmlEntities(loc.trim())) + .filter((loc) => isValidUrl(loc)); + + if (/]/.test(contents)) { + const nested = await Promise.all(locs.map((loc) => readSitemapUrls(loc))); + return nested.flat(); + } + + return locs; +} + export async function fileExists(path: string): Promise { try { await fs.access(path); return true; - } catch (error) { + } catch { return false; } } export async function isDirectory(path: string): Promise { try { - return (await fs.lstat(path)).isDirectory(); - } catch (error) { + const stats = await fs.lstat(path); + return stats.isDirectory(); + } catch { return false; } } @@ -38,7 +93,7 @@ export async function isEmptyDirectory(path: string): Promise { const entry = await directory.read(); await directory.close(); return entry === null; - } catch (error) { + } catch { return false; } } @@ -51,14 +106,17 @@ export function isValidUrl(s: string | URL, protocols: string[] = ['http', 'http if (!s) { return false; } + try { const { protocol } = new URL(String(s)); if (protocols?.length) { - return !!protocol && protocols.map((x) => `${x.toLowerCase()}:`).includes(protocol); - } else { - return true; + return ( + Boolean(protocol) && protocols.map((x) => `${x.toLowerCase()}:`).includes(protocol) + ); } - } catch (err) { + + return true; + } catch { return false; } } @@ -68,12 +126,12 @@ export function isLocalUrl(url: URL, base: URL): boolean { } export function isAssetUrl(url: URL): boolean { - return !!url.pathname.match(/\.(jpe?g|a?png|gif|webp|mp4|webm|mov|pdf|docx?|zip|gz)$/i); + return Boolean(/\.(jpe?g|a?png|gif|webp|mp4|webm|mov|pdf|docx?|zip|gz)$/i.test(url.pathname)); } export function isHtmlContentType(headers: Record): boolean { const contentType = headers['content-type'] || headers['Content-Type']; - return Boolean(contentType && contentType.match(/\bx?html\b/i)); + return Boolean(contentType && /\bx?html\b/i.test(contentType)); } export function removeHash(url: URL | string): string { diff --git a/swup.config.js b/swup.config.js index f1ff4f6..f6683d2 100644 --- a/swup.config.js +++ b/swup.config.js @@ -1,9 +1,9 @@ export default { - swup: { - animationSelector: '[class*="swup-transition-"]' - }, - validate: { - url: 'https://swup.js.org', - limit: 10 - } -} + swup: { + animationSelector: '[class*="swup-transition-"]' + }, + validate: { + url: 'https://swup.js.org', + limit: 10 + } +}; diff --git a/test/commands/create.test.ts b/test/commands/create.test.ts deleted file mode 100644 index 1a62023..0000000 --- a/test/commands/create.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { expect, test } from '@oclif/test' - -describe('create', () => { - test - .stdout() - .command(['create', '--from=oclif']) - .it('runs create cmd', ctx => { - expect(ctx.stdout).to.contain('hello friend from oclif!') - }) -}) diff --git a/test/helpers/init.js b/test/helpers/init.js index a5fa93d..6ab71ae 100644 --- a/test/helpers/init.js +++ b/test/helpers/init.js @@ -1,7 +1,3 @@ -import path from 'node:path' +import path from 'node:path'; -process.env.TS_NODE_PROJECT = path.resolve('test/tsconfig.json') -process.env.NODE_ENV = 'development' - -global.oclif = global.oclif || {} -global.oclif.columns = 80 +process.env.TS_NODE_PROJECT = path.resolve('test/tsconfig.json'); diff --git a/test/sitemap.test.ts b/test/sitemap.test.ts new file mode 100644 index 0000000..48582a3 --- /dev/null +++ b/test/sitemap.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect } from 'chai'; + +import Validate from '../src/commands/validate.js'; +import { readSitemapUrls } from '../src/util.js'; + +describe('readSitemapUrls', () => { + let server: Server; + let base: string; + + before(async () => { + server = createServer((req, res) => { + res.setHeader('content-type', 'application/xml'); + if (req.url === '/missing.xml') { + res.statusCode = 404; + res.end('Not found'); + } else if (req.url === '/empty.xml') { + res.end(''); + } else if (req.url === '/index.xml') { + res.end( + `${base}/pages.xml` + ); + } else { + res.end( + `${base}/a${base}/b` + ); + } + }); + await new Promise((resolve) => { + server.listen(0, resolve); + }); + base = `http://localhost:${(server.address() as AddressInfo).port}`; + }); + + after(() => { + server.close(); + }); + + it('reads a local file by absolute path, decodes entities and CDATA', async () => { + const file = join(await mkdtemp(join(tmpdir(), 'swup-cli-')), 'sitemap.xml'); + await writeFile( + file, + ` + + https://example.com/?a=1&b=2 + + + + not-a-url + ` + ); + expect(await readSitemapUrls(file)).to.deep.equal([ + 'https://example.com/?a=1&b=2', + 'https://example.com/cdata' + ]); + }); + + it('follows sitemap indexes', async () => { + expect(await readSitemapUrls(`${base}/index.xml`)).to.deep.equal([ + `${base}/a`, + `${base}/b` + ]); + }); + + it('throws on a non-OK HTTP status', async () => { + const error = await readSitemapUrls(`${base}/missing.xml`).catch((error) => error); + expect(error).to.be.instanceOf(Error); + expect(error.message).to.contain('HTTP 404'); + }); + + it('makes validate fail if the sitemap has no pages', async () => { + const ctx = { config: { validate: { sitemap: `${base}/empty.xml` } } }; + const error = await Validate.prototype.getPageUrls + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .call({} as any, ctx as any) + .catch((error: Error) => error); + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.contain('No pages found'); + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json index 95898fc..b06a324 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,9 +1,8 @@ { "extends": "../tsconfig", "compilerOptions": { - "noEmit": true + "noEmit": true, + "rootDir": ".." }, - "references": [ - {"path": ".."} - ] + "include": ["../src/**/*", "./**/*"] } diff --git a/tsconfig.json b/tsconfig.json index 6aff22d..dc4e596 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "outDir": "dist", "rootDir": "src", "strict": true, - "target": "es2019" + "target": "es2022" }, "include": [ "src/**/*"