From 38e26226ec1a6f52b6d7716496e41fea66bf6ee1 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 09:11:49 +0800 Subject: [PATCH 1/2] fix(scripts): report Cloudflare-challenged live-check steps as inconclusive --- scripts/live-check-plugin.js | 79 ++++++++++++++++++++++-- scripts/live-check-plugin.test.mjs | 97 ++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 scripts/live-check-plugin.test.mjs diff --git a/scripts/live-check-plugin.js b/scripts/live-check-plugin.js index 8f0977935..a68515f3e 100644 --- a/scripts/live-check-plugin.js +++ b/scripts/live-check-plugin.js @@ -24,6 +24,55 @@ const CLOUDFLARE_HEADER_HINTS = ['cf-ray', 'cf-cache-status', 'cf-request-id']; /** @typedef {'PASS' | 'FAIL' | 'INCONCLUSIVE'} StepStatus */ +/** + * Cloudflare detection for the plugin's own page loads. fetchApi/fetchText + * resolve for any HTTP status (see src/lib/fetch.ts), so a challenge page + * reaches the plugin as an ordinary response and parses to an empty result. + * Wrapping the global fetch lets the checker see those responses without + * changing what plugins receive. + */ +let currentStep = null; + +function describeCloudflareChallenge(res) { + if (res.headers.get('cf-mitigated')?.toLowerCase() === 'challenge') { + return `HTTP ${res.status} (Cloudflare challenge)`; + } + if ( + (res.status === 403 || res.status === 503) && + CLOUDFLARE_HEADER_HINTS.some(h => res.headers.has(h)) + ) { + return `HTTP ${res.status} (Cloudflare)`; + } + return null; +} + +function installChallengeDetector() { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (...args) => { + const res = await originalFetch(...args); + const step = currentStep; + if (step && !step.blockedBy) { + const reason = describeCloudflareChallenge(res); + if (reason) step.blockedBy = reason; + } + return res; + }; +} + +/** + * A step that did not pass, but whose page loads were challenged by + * Cloudflare, says nothing about the plugin: report it as INCONCLUSIVE. + * Steps that passed, and failures with no observed challenge, are untouched. + */ +function reclassifyBlockedSteps(steps) { + for (const step of steps) { + if (step.status === 'FAIL' && step.blockedBy) { + step.status = 'INCONCLUSIVE'; + step.detail = `Blocked by Cloudflare: ${step.blockedBy} (was: ${step.detail})`; + } + } +} + function isNetworkOrBlockError(error) { const code = error?.code || error?.cause?.code; const message = String(error?.message || ''); @@ -117,7 +166,14 @@ async function loadPluginInstance(pluginPath) { } function makeStep(name) { - return { name, status: /** @type {StepStatus} */ ('FAIL'), detail: '' }; + const step = { + name, + status: /** @type {StepStatus} */ ('FAIL'), + detail: '', + blockedBy: null, + }; + currentStep = step; + return step; } /** @@ -305,6 +361,7 @@ async function checkPlugin(pluginPath) { return result; } + currentStep = null; const probe = await probeSiteReachability(plugin.site); if (!probe.reachable) { const step = makeStep('siteReachability'); @@ -314,7 +371,12 @@ async function checkPlugin(pluginPath) { return result; } - result.steps = await runChecks(plugin); + try { + result.steps = await runChecks(plugin); + } finally { + currentStep = null; + } + reclassifyBlockedSteps(result.steps); return result; } @@ -373,6 +435,7 @@ function printReport(results) { } async function main() { + installChallengeDetector(); const pluginPaths = process.argv.slice(2); if (pluginPaths.length === 0) { console.error( @@ -394,7 +457,11 @@ async function main() { process.exitCode = hasFail ? 1 : 0; } -main().catch(error => { - console.error('Fatal error:', error); - process.exitCode = 1; -}); +export { describeCloudflareChallenge, installChallengeDetector, checkPlugin }; + +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { + main().catch(error => { + console.error('Fatal error:', error); + process.exitCode = 1; + }); +} diff --git a/scripts/live-check-plugin.test.mjs b/scripts/live-check-plugin.test.mjs new file mode 100644 index 000000000..1aa1f90fb --- /dev/null +++ b/scripts/live-check-plugin.test.mjs @@ -0,0 +1,97 @@ +// Run with: node --test scripts/live-check-plugin.test.mjs +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { checkPlugin, installChallengeDetector } from './live-check-plugin.js'; + +installChallengeDetector(); + +// Root probe always succeeds (as on the GitHub runner); /list behaves per mode. +function startServer(listResponse) { + const server = http.createServer((req, res) => { + if (req.url === '/list') return listResponse(res); + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(''); + }); + return new Promise(resolve => + server.listen(0, '127.0.0.1', () => resolve(server)), + ); +} + +async function runAgainst(listResponse) { + const server = await startServer(listResponse); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'live-check-test-')); + const file = path.join(dir, 'plugin.js'); + await fs.writeFile( + file, + `const site = 'http://127.0.0.1:${server.address().port}'; + module.exports = { default: { + site, + popularNovels: async () => { + const html = await (await fetch(site + '/list')).text(); + return html.includes('novel-item') ? [{ name: 'a', path: '/a' }] : []; + }, + } };`, + ); + try { + const result = await checkPlugin(file); + return result.steps[0]; + } finally { + server.close(); + await fs.rm(dir, { recursive: true, force: true }); + } +} + +test('cf-mitigated challenge page -> INCONCLUSIVE', async () => { + const step = await runAgainst(res => { + res.writeHead(403, { 'cf-mitigated': 'challenge' }); + res.end('Just a moment...'); + }); + assert.equal(step.name, 'popularNovels'); + assert.equal(step.status, 'INCONCLUSIVE'); + assert.match(step.detail, /Cloudflare/); +}); + +test('challenge served as 200 with cf-mitigated -> INCONCLUSIVE', async () => { + const step = await runAgainst(res => { + res.writeHead(200, { 'cf-mitigated': 'challenge' }); + res.end('Just a moment...'); + }); + assert.equal(step.status, 'INCONCLUSIVE'); +}); + +test('403 with Cloudflare headers -> INCONCLUSIVE', async () => { + const step = await runAgainst(res => { + res.writeHead(403, { 'cf-ray': 'abc-AMS' }); + res.end('blocked'); + }); + assert.equal(step.status, 'INCONCLUSIVE'); +}); + +test('unchallenged empty result still FAILs', async () => { + const step = await runAgainst(res => { + res.writeHead(200, { 'cf-ray': 'abc-AMS' }); // CDN header alone is no block + res.end('no novels here'); + }); + assert.equal(step.status, 'FAIL'); + assert.equal(step.detail, 'Returned no novels'); +}); + +test('plain 403 without Cloudflare headers still FAILs', async () => { + const step = await runAgainst(res => { + res.writeHead(403); + res.end('forbidden'); + }); + assert.equal(step.status, 'FAIL'); +}); + +test('challenged response does not downgrade a passing step', async () => { + const step = await runAgainst(res => { + res.writeHead(200, { 'cf-mitigated': 'challenge' }); + res.end('
'); + }); + assert.equal(step.status, 'PASS'); +}); From 1483e285c745e9ce4a5e49ed002e62bbb622d449 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 09:15:33 +0800 Subject: [PATCH 2/2] no-mistakes(document): Document Cloudflare challenge as INCONCLUSIVE in testing guide --- docs/testing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 37136b95c..e3c364cdc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -29,8 +29,8 @@ Each step reports one of three outcomes: - **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. +- **INCONCLUSIVE** — the site itself was unreachable, timed out, or was blocked by a Cloudflare + challenge (including a challenge page served for a plugin's own page loads) during this run. Not a plugin bug; re-run later or check the site manually. ## CI