feat(ci): add live-verification check for plugin PRs (#2386)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
+6
-4
@@ -3,19 +3,21 @@
|
|||||||
1. [Requirements](#requirements)
|
1. [Requirements](#requirements)
|
||||||
2. [Single plugin guide](#quick-guide)
|
2. [Single plugin guide](#quick-guide)
|
||||||
3. [Multi-src guide](#creating-multi-src-plugins)
|
3. [Multi-src guide](#creating-multi-src-plugins)
|
||||||
|
4. [Testing your plugin](./testing.md)
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- [git](https://git-scm.com/doc/ext) basics
|
- [git](https://git-scm.com/doc/ext) basics
|
||||||
- Typescript or Javascript basics
|
- Typescript or Javascript basics
|
||||||
- Node >=22
|
- Node >=22
|
||||||
- Installing the dependencies with `npm i`
|
- Installing the dependencies with `npm i`
|
||||||
|
|
||||||
### Guide
|
### Guide
|
||||||
|
|
||||||
1. Create plugin script in `/plugins` [<span style="font-size: 0.8rem;">(learn more)</span>](#creating-plugin-script)
|
1. Create plugin script in `/plugins` [<span style="font-size: 0.8rem;">(learn more)</span>](#creating-plugin-script)
|
||||||
2. Copy code from [plugin-template.ts](./plugin-template.ts)
|
2. Copy code from [plugin-template.ts](./plugin-template.ts)
|
||||||
3. Start coding [<span style="font-size:0.8rem">(documentation)</span>](./docs.md)
|
3. Start coding [<span style="font-size:0.8rem">(documentation)</span>](./docs.md)
|
||||||
|
4. Run `npm run check:plugin -- plugins/<lang>/yourPlugin.ts` before opening a PR — see [Testing your plugin](./testing.md)
|
||||||
|
|
||||||
#### Creating plugin script
|
#### Creating plugin script
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
Generated
+106
-2
@@ -49,6 +49,7 @@
|
|||||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"esbuild": "^0.25.3",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"globals": "^15.6.0",
|
"globals": "^15.6.0",
|
||||||
@@ -2999,6 +3000,60 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.14",
|
"version": "4.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz",
|
||||||
@@ -9743,8 +9798,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@emnapi/runtime": {
|
"@emnapi/runtime": {
|
||||||
"version": "1.5.0",
|
"version": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==",
|
"integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
@@ -11143,6 +11197,56 @@
|
|||||||
"@napi-rs/wasm-runtime": "^1.0.5",
|
"@napi-rs/wasm-runtime": "^1.0.5",
|
||||||
"@tybys/wasm-util": "^0.10.1",
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
"tslib": "^2.4.0"
|
"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": {
|
"@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"format": "prettier --write \"./**/*.{js,ts}\"",
|
"format": "prettier --write \"./**/*.{js,ts}\"",
|
||||||
"format:check": "prettier --check \"./**/*.{js,ts}\"",
|
"format:check": "prettier --check \"./**/*.{js,ts}\"",
|
||||||
"check:sites": "node scripts/check-plugin-sites.js",
|
"check:sites": "node scripts/check-plugin-sites.js",
|
||||||
|
"check:plugin": "node scripts/live-check-plugin.js",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"author": "LNReader",
|
"author": "LNReader",
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"esbuild": "^0.25.3",
|
||||||
"globals": "^15.6.0",
|
"globals": "^15.6.0",
|
||||||
"htmlparser2": "^9.1.0",
|
"htmlparser2": "^9.1.0",
|
||||||
"http-proxy": "^1.18.1",
|
"http-proxy": "^1.18.1",
|
||||||
|
|||||||
@@ -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<Filters> 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 <plugin.ts> [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;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user