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;
+});