Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
79 changes: 73 additions & 6 deletions scripts/live-check-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +52 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Late responses hide failures

installChallengeDetector reads currentStep after the request finishes. If searchNovels times out, its request keeps running while the checker starts parseNovel. A late Cloudflare response from that search then marks parseNovel as blocked, hiding a real failure there as INCONCLUSIVE.

Capture the step before awaiting the request, and add a test for a late response crossing steps.

Suggested change
const res = await originalFetch(...args);
const step = currentStep;
const step = currentStep;
const res = await originalFetch(...args);

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 || '');
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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');
Expand All @@ -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;
}

Expand Down Expand Up @@ -373,6 +435,7 @@ function printReport(results) {
}

async function main() {
installChallengeDetector();
const pluginPaths = process.argv.slice(2);
if (pluginPaths.length === 0) {
console.error(
Expand All @@ -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;
});
}
97 changes: 97 additions & 0 deletions scripts/live-check-plugin.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Run with: node --test scripts/live-check-plugin.test.mjs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 New tests stay manual

No package script or workflow runs scripts/live-check-plugin.test.mjs. The live-check workflow also skips execution when only checker files change, so these six tests cannot catch regressions automatically.

Add a CI step that runs these local-server tests when the checker or its tests change.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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('<html></html>');
});
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('<html>Just a moment...</html>');
});
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('<html>Just a moment...</html>');
});
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('<html>no novels here</html>');
});
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('<div class="novel-item"></div>');
});
assert.equal(step.status, 'PASS');
});
Loading