Skip to content

Commit 694a02b

Browse files
committed
feat(runtime): require Node.js 22 and refresh PDF parsing
Coverage: 95.93% (was 94.97%) BREAKING CHANGE: Node.js 22 or later is now required.
1 parent ab6bcc3 commit 694a02b

10 files changed

Lines changed: 406 additions & 37 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
strategy:
2020
fail-fast: false
2121
matrix:
22-
node-version: ['20', '22', '24', '26.7.0']
22+
node-version: ['22', '24', '26.7.0']
2323

2424
steps:
2525
- name: Checkout code
@@ -42,3 +42,6 @@ jobs:
4242

4343
- name: Test coverage
4444
run: npm run test:coverage
45+
46+
- name: E2E tests
47+
run: npm run test:e2e

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ For SearXNG deployment, configuration, and troubleshooting, see
181181

182182
## Installation
183183

184-
Node.js 20 remains supported but is deprecated and end-of-life. Node.js 22 or later is recommended. Node.js 20 will be removed only in a future major release.
184+
Node.js 22 or later is required.
185185

186186
<details>
187187
<summary>NPM (global install)</summary>

__tests__/e2e/url-reader.e2e.ts

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,129 @@
1111
*/
1212

1313
import { strict as assert } from 'node:assert';
14+
import http from 'node:http';
15+
import net from 'node:net';
1416
import { fileURLToPath } from 'node:url';
1517
import {
1618
checkSkipConditions,
1719
INIT_PARAMS,
1820
spawnWithMessages,
21+
spawnWithMessagesAsync,
1922
LIVE_URL,
2023
} from './helpers/spawn-server.js';
2124
import { testFunction, createTestResults, printTestSummary } from '../helpers/test-utils.js';
25+
import { createTextPdf } from '../helpers/pdf-fixtures.js';
2226

2327
const results = createTestResults();
28+
const E2E_TIMEOUT_MS = 15_000;
29+
const LOCAL_PDF_FIRST_PAGE = 'Local PDF first page for E2E';
30+
const LOCAL_PDF_SECOND_PAGE = 'Local PDF second page for E2E';
31+
32+
interface TestServer {
33+
url: string;
34+
close: () => Promise<void>;
35+
}
36+
37+
function startPdfServer(pdf: Uint8Array): Promise<TestServer> {
38+
return new Promise((resolve, reject) => {
39+
const server = http.createServer((_, response) => {
40+
response.writeHead(200, {
41+
'content-type': 'application/pdf',
42+
'content-length': String(pdf.byteLength),
43+
});
44+
response.end(Buffer.from(pdf));
45+
});
46+
server.listen(0, '127.0.0.1', () => {
47+
const address = server.address() as net.AddressInfo;
48+
resolve({
49+
url: `http://127.0.0.1:${address.port}/fixture.pdf`,
50+
close: () => new Promise<void>((done, rejectClose) => {
51+
server.closeAllConnections();
52+
server.close((error) => error ? rejectClose(error) : done());
53+
}),
54+
});
55+
});
56+
server.once('error', reject);
57+
});
58+
}
59+
60+
function readUrlMessages(url: string): object[] {
61+
return [
62+
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: INIT_PARAMS },
63+
{
64+
jsonrpc: '2.0',
65+
id: 2,
66+
method: 'tools/call',
67+
params: {
68+
name: 'web_url_read',
69+
arguments: { url },
70+
},
71+
},
72+
];
73+
}
74+
75+
async function withinTestBoundary<T>(operation: Promise<T>): Promise<T> {
76+
let timeout: NodeJS.Timeout | undefined;
77+
try {
78+
return await Promise.race([
79+
operation,
80+
new Promise<never>((_, reject) => {
81+
timeout = setTimeout(
82+
() => reject(new Error(`Local PDF E2E test timed out after ${E2E_TIMEOUT_MS}ms`)),
83+
E2E_TIMEOUT_MS,
84+
);
85+
}),
86+
]);
87+
} finally {
88+
if (timeout) clearTimeout(timeout);
89+
}
90+
}
2491

2592
async function runTests() {
2693
console.log('🌐 E2E Testing: web_url_read (live)\n');
2794

28-
const skip = checkSkipConditions();
29-
if (skip) {
30-
console.log(skip);
31-
return { passed: 0, failed: 0, errors: [] };
95+
await testFunction('web_url_read reads a two-page local PDF only with the private URL override', async () => {
96+
await withinTestBoundary((async () => {
97+
const server = await startPdfServer(createTextPdf([
98+
LOCAL_PDF_FIRST_PAGE,
99+
LOCAL_PDF_SECOND_PAGE,
100+
]));
101+
try {
102+
const blockedResponses = await spawnWithMessagesAsync(
103+
readUrlMessages(server.url),
104+
'https://test-searx.example.com',
105+
E2E_TIMEOUT_MS,
106+
);
107+
const blockedResponse = blockedResponses[2];
108+
assert.ok(blockedResponse?.error, 'loopback PDF request should be blocked by the SSRF boundary');
109+
assert.match(
110+
String(blockedResponse.error.message ?? ''),
111+
/URL blocked by security policy/i,
112+
JSON.stringify(blockedResponse.error),
113+
);
114+
115+
const allowedResponses = await spawnWithMessagesAsync(
116+
readUrlMessages(server.url),
117+
'https://test-searx.example.com',
118+
E2E_TIMEOUT_MS,
119+
{ MCP_HTTP_ALLOW_PRIVATE_URLS: 'true' },
120+
);
121+
const allowedResponse = allowedResponses[2];
122+
assert.ok(allowedResponse && !allowedResponse.error, JSON.stringify(allowedResponse?.error));
123+
const text: string = allowedResponse.result?.content?.[0]?.text ?? '';
124+
assert.ok(text.includes(LOCAL_PDF_FIRST_PAGE), text);
125+
assert.ok(text.includes(LOCAL_PDF_SECOND_PAGE), text);
126+
} finally {
127+
await server.close();
128+
}
129+
})());
130+
}, results);
131+
132+
const liveSkip = checkSkipConditions();
133+
if (liveSkip) {
134+
console.log(liveSkip);
135+
printTestSummary(results, 'E2E: URL Reader');
136+
return results;
32137
}
33138

34139
await testFunction('web_url_read fetches example.com and returns markdown', async () => {

__tests__/unit/documentation.test.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ type PackageManifest = {
8383
version?: unknown;
8484
};
8585

86+
type PackageLock = {
87+
packages?: Record<string, PackageManifest>;
88+
};
89+
8690
function toYamlLine(rawLine: string): YamlLine {
8791
return {
8892
indentation: rawLine.length - rawLine.trimStart().length,
@@ -148,23 +152,46 @@ function hasDependabotIgnoreEntry(source: string, dependencyName: string): boole
148152

149153
function assertPackageMetadata(): void {
150154
const packageManifest = JSON.parse(readText(new URL('../../package.json', import.meta.url))) as PackageManifest;
155+
const packageLock = JSON.parse(readText(new URL('../../package-lock.json', import.meta.url))) as PackageLock;
151156
assert.equal(packageManifest.version, '1.16.0');
152-
assert.equal(packageManifest.engines?.node, '>=20');
157+
assert.equal(packageManifest.engines?.node, '>=22');
158+
assert.equal(packageManifest.dependencies?.unpdf, '1.8.1');
159+
assert.equal(packageLock.packages?.[''].engines?.node, '>=22');
160+
assert.equal(packageLock.packages?.[''].dependencies?.unpdf, '1.8.1');
153161
assert.equal(packageManifest.dependencies?.['express-rate-limit'], '^8.5.2');
154162
}
155163

156164
function assertReadmeNodePolicy(readme: string): void {
157-
assert.ok(readme.includes('Node.js 20 remains supported but is deprecated and end-of-life.'));
158-
assert.ok(readme.includes('Node.js 22 or later is recommended.'));
159-
assert.ok(readme.includes('Node.js 20 will be removed only in a future major release.'));
165+
assert.ok(readme.includes('Node.js 22 or later is required.'));
166+
assert.ok(!readme.includes('Node.js 20'), 'README must not claim Node.js 20 support');
167+
for (const formerPromise of [
168+
'Node.js 20 remains supported',
169+
'Node.js 20 will be removed',
170+
'Node.js 22 or later is recommended',
171+
]) {
172+
assert.ok(!readme.includes(formerPromise), `README must not retain: ${formerPromise}`);
173+
}
174+
}
175+
176+
function assertClientGuideNodePolicy(guide: string): void {
177+
assert.ok(guide.includes('Requires Node.js 22 or later.'));
178+
assert.ok(!guide.includes('Node.js 20'), 'client guide must not claim Node.js 20 support');
179+
for (const formerPromise of [
180+
'Requires Node.js 20',
181+
'Node.js 20 remains supported',
182+
'Node.js 20 will be removed',
183+
'Node.js 22 or later is recommended',
184+
]) {
185+
assert.ok(!guide.includes(formerPromise), `client guide must not retain: ${formerPromise}`);
186+
}
160187
}
161188

162189
function assertCiMatrix(ci: string): void {
163190
const matrix = ci.match(/^\s*node-version:\s*\[([^\]]+)\]\s*$/mu);
164191
assert.ok(matrix, 'CI must declare an inline Node version matrix');
165192
assert.deepEqual(
166193
[...matrix[1].matchAll(/['"]([^'"]+)['"]/gu)].map((match) => match[1]),
167-
['20', '22', '24', '26.7.0'],
194+
['22', '24', '26.7.0'],
168195
);
169196
}
170197

@@ -174,9 +201,16 @@ function assertCiCommonJob(ci: string): void {
174201
assert.match(ci, /uses:\s*actions\/checkout@/u);
175202
assert.match(ci, /uses:\s*actions\/setup-node@/u);
176203
assert.match(ci, /cache:\s*['"]npm['"]/u);
177-
for (const command of [/run:\s*npm ci/u, /run:\s*npm run lint/u, /run:\s*npm run build/u, /run:\s*npm run test:coverage/u]) {
204+
for (const command of [
205+
/run:\s*npm ci/u,
206+
/run:\s*npm run lint/u,
207+
/run:\s*npm run build/u,
208+
/run:\s*npm run test:coverage/u,
209+
/run:\s*npm run test:e2e/u,
210+
]) {
178211
assert.match(ci, command);
179212
}
213+
assert.match(ci, /run:\s*npm run test:coverage\s*\n\s*- name: E2E tests\s*\n\s*run:\s*npm run test:e2e/u);
180214
assert.doesNotMatch(ci, /^\s*include\s*:/mu);
181215
assert.doesNotMatch(ci, /^\s*if\s*:/mu);
182216
assert.doesNotMatch(ci, /continue-on-error\s*:/u);
@@ -392,6 +426,7 @@ export async function runTests(): Promise<TestResult> {
392426
const dependabot = readText(new URL('../../.github/dependabot.yml', import.meta.url));
393427
assertPackageMetadata();
394428
assertReadmeNodePolicy(readme);
429+
assertClientGuideNodePolicy(readText(guideUrl));
395430
assertCiMatrix(ci);
396431
assertCiCommonJob(ci);
397432
assertCodeqlActionPins(codeql);

0 commit comments

Comments
 (0)