diff --git a/.github/workflows/plugin-live-check.yml b/.github/workflows/plugin-live-check.yml new file mode 100644 index 000000000..2dcb352d5 --- /dev/null +++ b/.github/workflows/plugin-live-check.yml @@ -0,0 +1,87 @@ +name: Plugin Live Check + +on: + pull_request: + branches: [master] + workflow_dispatch: + inputs: + plugin_path: + description: 'Plugin file(s) to check (space-separated relative paths)' + required: true + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + live-check: + name: Plugin Live Check + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get Changed Plugin Files + id: changed-files + if: github.event_name == 'pull_request' + uses: tj-actions/changed-files@v45 + with: + files: | + plugins/**/*.ts + files_ignore: | + plugins/**/*\[*\]*.ts + plugins/multisrc/** + + - name: Determine Targets + id: targets + if: github.event_name == 'workflow_dispatch' || steps.changed-files.outputs.any_changed == 'true' + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "files=${{ inputs.plugin_path }}" >> "$GITHUB_OUTPUT" + else + echo "files=${{ steps.changed-files.outputs.all_changed_files }}" >> "$GITHUB_OUTPUT" + fi + + - name: Setup Node.js + if: steps.targets.outputs.files != '' + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install Dependencies + if: steps.targets.outputs.files != '' + run: npm ci + + # Multisrc-generated files (plugins/**/*[...].ts) are excluded above. + # They're produced from plugins/multisrc/*/template.ts + sources.json, + # not hand-authored, so testing the generator is out of scope here. + - name: Run Live Check + id: live-check + if: steps.targets.outputs.files != '' + run: | + set +e + node scripts/live-check-plugin.js ${{ steps.targets.outputs.files }} > live-check-output.txt 2>&1 + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + cat live-check-output.txt + + - name: Post PR Comment + if: github.event_name == 'pull_request' && steps.changed-files.outputs.any_changed == 'true' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: plugin-live-check + path: live-check-output.txt + + - name: Fail On Real Errors + if: steps.targets.outputs.files != '' && steps.live-check.outputs.exit_code != '0' + run: | + echo "Live check reported at least one FAIL — see the job log or PR comment above." + exit 1 diff --git a/docs/quickstart.md b/docs/quickstart.md index 22cf8a7d0..7a871aaf9 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -3,19 +3,21 @@ 1. [Requirements](#requirements) 2. [Single plugin guide](#quick-guide) 3. [Multi-src guide](#creating-multi-src-plugins) +4. [Testing your plugin](./testing.md) ### Requirements -- [git](https://git-scm.com/doc/ext) basics -- Typescript or Javascript basics -- Node >=22 -- Installing the dependencies with `npm i` +- [git](https://git-scm.com/doc/ext) basics +- Typescript or Javascript basics +- Node >=22 +- Installing the dependencies with `npm i` ### Guide 1. Create plugin script in `/plugins` [(learn more)](#creating-plugin-script) 2. Copy code from [plugin-template.ts](./plugin-template.ts) 3. Start coding [(documentation)](./docs.md) +4. Run `npm run check:plugin -- plugins//yourPlugin.ts` before opening a PR — see [Testing your plugin](./testing.md) #### Creating plugin script diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 000000000..cc9d4d87b --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,44 @@ +# Testing your plugin + +`tsc`, ESLint, and Prettier all check that your plugin _compiles_. None of them can tell you +whether it actually returns novels, chapters, or search results from the real site — plugins fail +in ways the compiler can't see, because the wiki/site content they scrape has no schema: an empty +chapter list, a chapter body that's actually a "back to top" nav page, search results leaking +pages in the wrong language, and so on. + +## `npm run check:plugin` + +Bundles your plugin with esbuild the same way the production build does, then runs it against the +live site — calling `popularNovels`, `searchNovels`, `parseNovel`, and `parseChapter` in sequence, +using your plugin's own default filter values (the same values the app would send). + +```sh +npm run check:plugin -- plugins/english/yourPlugin.ts +``` + +You can check multiple plugins in one run: + +```sh +npm run check:plugin -- plugins/english/yourPlugin.ts plugins/english/anotherPlugin.ts +``` + +Each step reports one of three outcomes: + +- **PASS** — got a plausible result (non-empty novel list, a chapter body over ~200 characters, + etc). +- **FAIL** — the plugin ran but returned something wrong (empty results, a novel with no chapters, + a suspiciously short chapter body, or a thrown error that isn't network-related). This is what + you're looking for before opening a PR. +- **INCONCLUSIVE** — the site itself was unreachable, timed out, or returned a Cloudflare-style + block during this run. Not a plugin bug; re-run later or check the site manually. + +## CI + +Any PR that touches a file under `plugins/**/*.ts` (excluding multisrc-generated files) runs this +same check automatically against just the changed plugins, and posts a summary comment. The check +only fails the PR on a genuine `FAIL` — `INCONCLUSIVE` results (a site being briefly down) never +block a merge. + +You can also trigger it manually against any plugin path from the Actions tab +(`Plugin Live Check` → `Run workflow`), which is useful for re-checking an existing plugin after +its target site changes layout. diff --git a/package-lock.json b/package-lock.json index 9c0b00bc8..d297a38fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "@vitejs/plugin-react-swc": "^3.9.0", "cheerio": "^1.0.0", "dayjs": "^1.11.13", + "esbuild": "^0.25.3", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "globals": "^15.6.0", @@ -2999,6 +3000,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.5", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.14", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz", @@ -9743,8 +9798,7 @@ } }, "@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "version": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", "optional": true, "requires": { @@ -11143,6 +11197,56 @@ "@napi-rs/wasm-runtime": "^1.0.5", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" + }, + "dependencies": { + "@emnapi/core": { + "version": "1.5.0", + "bundled": true, + "optional": true, + "requires": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "@emnapi/runtime": { + "version": "1.5.0", + "bundled": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@emnapi/wasi-threads": { + "version": "1.1.0", + "bundled": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@napi-rs/wasm-runtime": { + "version": "1.0.5", + "bundled": true, + "optional": true, + "requires": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "@tybys/wasm-util": { + "version": "0.10.1", + "bundled": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "tslib": { + "version": "2.8.1", + "bundled": true, + "optional": true + } } }, "@tailwindcss/oxide-win32-arm64-msvc": { diff --git a/package.json b/package.json index e0bf91369..fb2cfd621 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "format": "prettier --write \"./**/*.{js,ts}\"", "format:check": "prettier --check \"./**/*.{js,ts}\"", "check:sites": "node scripts/check-plugin-sites.js", + "check:plugin": "node scripts/live-check-plugin.js", "prepare": "husky" }, "author": "LNReader", @@ -70,6 +71,7 @@ "dayjs": "^1.11.13", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", + "esbuild": "^0.25.3", "globals": "^15.6.0", "htmlparser2": "^9.1.0", "http-proxy": "^1.18.1", diff --git a/scripts/live-check-plugin.js b/scripts/live-check-plugin.js new file mode 100644 index 000000000..553991697 --- /dev/null +++ b/scripts/live-check-plugin.js @@ -0,0 +1,377 @@ +#!/usr/bin/env node + +// Bundles one or more LNReader plugin source files and runs them against the +// real target site, exercising the same PluginBase surface a live install +// would use. Type-checking/lint alone have missed real bugs (zero chapters, +// search leaking foreign-language pages) that only show up when the plugin +// actually talks to its site — see docs/testing.md. + +import * as esbuild from 'esbuild'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; +import path, { dirname } from 'path'; +import fs from 'fs/promises'; +import os from 'os'; + +const require = createRequire(import.meta.url); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const REPO_ROOT = path.join(__dirname, '..'); + +const MIN_CHAPTER_LENGTH = 200; +const STEP_TIMEOUT_MS = 30_000; +const CLOUDFLARE_HEADER_HINTS = ['cf-ray', 'cf-cache-status', 'cf-request-id']; + +/** @typedef {'PASS' | 'FAIL' | 'INCONCLUSIVE'} StepStatus */ + +function isNetworkOrBlockError(error) { + const code = error?.code || error?.cause?.code; + const message = String(error?.message || ''); + if (['ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT'].includes(code)) { + return { inconclusive: true, reason: `Network error (${code})` }; + } + if (/timed? ?out/i.test(message)) { + return { inconclusive: true, reason: 'Timeout' }; + } + // Only 403/503 indicate a block — a cf-ray/cf-cache-status header alone + // just means the site is fronted by Cloudflare's CDN (true for a huge + // share of the web) and says nothing about whether we were blocked. + const status = error?.response?.status ?? error?.status; + if (status === 403 || status === 503) { + const headers = error?.response?.headers; + const headerKeys = headers + ? Object.keys( + typeof headers.entries === 'function' + ? Object.fromEntries(headers.entries()) + : headers, + ).map(k => k.toLowerCase()) + : []; + const isCloudflare = CLOUDFLARE_HEADER_HINTS.some(h => + headerKeys.includes(h), + ); + return { + inconclusive: true, + reason: `HTTP ${status}${isCloudflare ? ' (Cloudflare)' : ' (likely anti-bot block)'}`, + }; + } + return { inconclusive: false }; +} + +async function withTimeout(promise, ms, label) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + Object.assign(new Error(`${label} timed out`), { code: 'ETIMEDOUT' }), + ), + ms, + ); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + clearTimeout(timer); + } +} + +async function bundlePlugin(pluginPath) { + const absPath = path.resolve(REPO_ROOT, pluginPath); + const result = await esbuild.build({ + entryPoints: [absPath], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + write: false, + alias: { + '@libs': path.join(REPO_ROOT, 'src/libs'), + '@': path.join(REPO_ROOT, 'src'), + }, + }); + const code = result.outputFiles[0].text; + const unique = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const tmpFile = path.join( + os.tmpdir(), + `live-check-${path.basename(pluginPath, '.ts')}-${unique}.cjs`, + ); + await fs.writeFile(tmpFile, code, 'utf8'); + return tmpFile; +} + +async function loadPluginInstance(pluginPath) { + const bundledPath = await bundlePlugin(pluginPath); + try { + // Plain CJS require(), not ESM import() — importing a CJS module from an + // ESM context wraps the whole `module.exports` as `.default` (so a + // `default` *named* export inside it ends up double-nested at + // `mod.default.default`). require() resolves it the way the plugin + // author actually wrote it: `export default plugin` -> `mod.default`. + const mod = require(bundledPath); + return mod.default ?? mod; + } finally { + delete require.cache[require.resolve(bundledPath)]; + await fs.unlink(bundledPath).catch(() => undefined); + } +} + +function makeStep(name) { + return { name, status: /** @type {StepStatus} */ ('FAIL'), detail: '' }; +} + +/** + * The real app always calls popularNovels with the plugin's own default + * filter values (from its `filters` schema), never a bare `undefined` - + * `undefined` is only valid for plugins that declare no filters at all. + * Passing it to a plugin that assumes its filters are populated (e.g. + * `options.filters.language`) produces a crash that looks like a plugin bug + * but is really just an unrealistic call from the harness. + */ +function defaultFilterValues(plugin) { + if (!plugin.filters) return undefined; + // FilterToValues keeps the {value, type} shape per key — plugins + // read e.g. filters.language.value, not filters.language directly. + return Object.fromEntries( + Object.entries(plugin.filters).map(([key, filter]) => [ + key, + { value: filter.value, type: filter.type }, + ]), + ); +} + +async function runChecks(plugin) { + const steps = []; + const filters = defaultFilterValues(plugin); + + // 1. popularNovels + const popularStep = makeStep('popularNovels'); + steps.push(popularStep); + let popular; + try { + popular = await withTimeout( + plugin.popularNovels(1, { filters }), + STEP_TIMEOUT_MS, + 'popularNovels', + ); + if (!Array.isArray(popular) || popular.length === 0) { + popularStep.status = 'FAIL'; + popularStep.detail = 'Returned no novels'; + return steps; + } + popularStep.status = 'PASS'; + popularStep.detail = `${popular.length} novels`; + } catch (error) { + const net = isNetworkOrBlockError(error); + popularStep.status = net.inconclusive ? 'INCONCLUSIVE' : 'FAIL'; + popularStep.detail = net.reason || error.message; + return steps; + } + + const firstNovel = popular[0]; + + // 2. searchNovels + const searchStep = makeStep('searchNovels'); + steps.push(searchStep); + try { + const results = await withTimeout( + plugin.searchNovels(firstNovel.name, 1), + STEP_TIMEOUT_MS, + 'searchNovels', + ); + if (!Array.isArray(results)) { + searchStep.status = 'FAIL'; + searchStep.detail = 'Did not return an array'; + } else { + searchStep.status = 'PASS'; + searchStep.detail = `${results.length} results for "${firstNovel.name}"`; + } + } catch (error) { + const net = isNetworkOrBlockError(error); + searchStep.status = net.inconclusive ? 'INCONCLUSIVE' : 'FAIL'; + searchStep.detail = net.reason || error.message; + } + + // 3. parseNovel + const parseNovelStep = makeStep('parseNovel'); + steps.push(parseNovelStep); + let novel; + try { + novel = await withTimeout( + plugin.parseNovel(firstNovel.path), + STEP_TIMEOUT_MS, + 'parseNovel', + ); + const isPagePlugin = typeof plugin.parsePage === 'function'; + let chapters = novel?.chapters; + if (isPagePlugin && (!chapters || chapters.length === 0)) { + const page = await withTimeout( + plugin.parsePage(firstNovel.path, '1'), + STEP_TIMEOUT_MS, + 'parsePage', + ); + chapters = page?.chapters; + } + if (!novel?.name || !chapters || chapters.length === 0) { + parseNovelStep.status = 'FAIL'; + parseNovelStep.detail = !novel?.name + ? 'Missing novel name' + : 'No chapters returned'; + return steps; + } + parseNovelStep.status = 'PASS'; + parseNovelStep.detail = `${chapters.length} chapters`; + novel = { ...novel, chapters }; + } catch (error) { + const net = isNetworkOrBlockError(error); + parseNovelStep.status = net.inconclusive ? 'INCONCLUSIVE' : 'FAIL'; + parseNovelStep.detail = net.reason || error.message; + return steps; + } + + // 4. parseChapter + const parseChapterStep = makeStep('parseChapter'); + steps.push(parseChapterStep); + try { + const firstChapter = novel.chapters[0]; + const content = await withTimeout( + plugin.parseChapter(firstChapter.path), + STEP_TIMEOUT_MS, + 'parseChapter', + ); + const length = typeof content === 'string' ? content.trim().length : 0; + if (length < MIN_CHAPTER_LENGTH) { + parseChapterStep.status = 'FAIL'; + parseChapterStep.detail = `Content too short (${length} chars, expected >= ${MIN_CHAPTER_LENGTH})`; + } else { + parseChapterStep.status = 'PASS'; + parseChapterStep.detail = `${length} chars`; + } + } catch (error) { + const net = isNetworkOrBlockError(error); + parseChapterStep.status = net.inconclusive ? 'INCONCLUSIVE' : 'FAIL'; + parseChapterStep.detail = net.reason || error.message; + } + + return steps; +} + +/** + * fetchText/fetchFile in this repo swallow network and non-2xx errors and + * resolve to '' instead of throwing (see src/lib/fetch.ts), so a plugin using + * them will surface a site-down/anti-bot block as an empty result, not an + * exception — which runChecks() would otherwise misreport as a FAIL. Probe + * the plugin's base site directly first so a known-bad site short-circuits + * to INCONCLUSIVE before running the real checks, using the same Cloudflare + * header heuristic as scripts/check-plugin-sites.js. + */ +async function probeSiteReachability(site) { + try { + const res = await withTimeout( + fetch(site, { + method: 'HEAD', + headers: { 'User-Agent': 'Mozilla/5.0 live-check-plugin' }, + }), + STEP_TIMEOUT_MS, + 'site probe', + ); + if (res.status >= 200 && res.status < 400) { + // A cf-ray/cf-cache-status header here just means the site is fronted + // by Cloudflare's CDN, which is true of a huge share of the web and + // says nothing about whether we were blocked — only a 403/503 does. + return { reachable: true }; + } + const isCloudflare = CLOUDFLARE_HEADER_HINTS.some(h => res.headers.has(h)); + if (res.status === 403 || res.status === 503) { + return { + reachable: false, + reason: `HTTP ${res.status}${isCloudflare ? ' (Cloudflare)' : ''}`, + }; + } + return { reachable: false, reason: `HTTP ${res.status}` }; + } catch (error) { + const net = isNetworkOrBlockError(error); + return { reachable: false, reason: net.reason || error.message }; + } +} + +async function checkPlugin(pluginPath) { + const result = { pluginPath, steps: [], loadError: null }; + let plugin; + try { + plugin = await loadPluginInstance(pluginPath); + } catch (error) { + result.loadError = error.message; + return result; + } + + const probe = await probeSiteReachability(plugin.site); + if (!probe.reachable) { + const step = makeStep('siteReachability'); + step.status = 'INCONCLUSIVE'; + step.detail = probe.reason; + result.steps = [step]; + return result; + } + + result.steps = await runChecks(plugin); + return result; +} + +function printReport(results) { + let hasFail = false; + for (const result of results) { + console.log('\n' + '='.repeat(80)); + console.log(result.pluginPath); + console.log('='.repeat(80)); + + if (result.loadError) { + hasFail = true; + console.log(` BUNDLE/LOAD FAIL — ${result.loadError}`); + continue; + } + + for (const step of result.steps) { + const icon = + step.status === 'PASS' ? '✓' : step.status === 'FAIL' ? '✗' : '~'; + console.log( + ` ${icon} ${step.status.padEnd(12)} ${step.name} — ${step.detail}`, + ); + if (step.status === 'FAIL') hasFail = true; + } + } + console.log('\n' + '='.repeat(80)); + console.log( + hasFail + ? 'RESULT: FAIL (at least one step failed)' + : 'RESULT: OK (no hard failures)', + ); + console.log('='.repeat(80)); + return hasFail; +} + +async function main() { + const pluginPaths = process.argv.slice(2); + if (pluginPaths.length === 0) { + console.error( + 'Usage: node scripts/live-check-plugin.mjs [more.ts...]', + ); + process.exitCode = 2; + return; + } + + const results = []; + for (const pluginPath of pluginPaths) { + results.push(await checkPlugin(pluginPath)); + } + + const hasFail = printReport(results); + // Set exitCode rather than calling process.exit(): fetch's keep-alive + // sockets can still be mid-close here, and an abrupt exit() while libuv + // has a handle in that state crashes the process on Windows. + process.exitCode = hasFail ? 1 : 0; +} + +main().catch(error => { + console.error('Fatal error:', error); + process.exitCode = 1; +});