1import { test } from 'node:test';
2import assert from 'node:assert/strict';
3import { extractOnPageSeo } from '../src/lib/extract.mjs';
4import { fetchPageWithRedirects } from '../src/lib/fetchPage.mjs';
5import { runPool } from '../src/lib/runPool.mjs';
6import { scoreOnPageSeo } from '../src/lib/score.mjs';
7import { validateHttpUrl } from '../src/lib/validateUrl.mjs';
8
9test('extractOnPageSeo reads core tags', () => {
10 const html = `<!doctype html><html><head>
11 <title> Hello Page </title>
12 <meta name="description" content="Desc here" />
13 <link rel="canonical" href="https://ex.com/canonical" />
14 <meta name="robots" content="index,follow" />
15 <meta property="og:title" content="OG Title" />
16 <meta name="viewport" content="width=device-width, initial-scale=1" />
17 <link rel="icon" href="/favicon.ico" />
18 <link rel="apple-touch-icon" href="/apple.png" />
19 </head><body>
20 <h1> Main Heading </h1>
21 <p>one two three</p>
22 <a href="https://ex.com/other">internal</a>
23 <a href="https://external.com/">external</a>
24 </body></html>`;
25 const r = extractOnPageSeo(html, 'https://ex.com/', 'https://ex.com/in');
26 assert.equal(r.requestedUrl, 'https://ex.com/in');
27 assert.equal(r.finalUrl, 'https://ex.com/');
28 assert.equal(r.title, 'Hello Page');
29 assert.equal(r.metaDescription, 'Desc here');
30 assert.equal(r.canonical, 'https://ex.com/canonical');
31 assert.equal(r.robotsMeta, 'index,follow');
32 assert.equal(r.h1, 'Main Heading');
33 assert.equal(r.h1Count, 1);
34 assert.equal(r.ogTitle, 'OG Title');
35 assert.equal(r.viewport, 'width=device-width, initial-scale=1');
36 assert.equal(r.favicon, '/favicon.ico');
37 assert.equal(r.appleTouchIcon, '/apple.png');
38 assert.equal(r.links.internal, 1);
39 assert.equal(r.links.external, 1);
40 assert.equal(r.links.total, 2);
41 assert.ok(r.wordCount >= 4);
42});
43
44test('relative links resolve against finalUrl as internal', () => {
45 const html = `<!doctype html><html><body>
46 <a href="/about">about</a>
47 <a href="https://other.com/x">ext</a>
48 </body></html>`;
49 const r = extractOnPageSeo(html, 'https://ex.com/page', 'https://ex.com/page');
50 assert.equal(r.links.internal, 1);
51 assert.equal(r.links.external, 1);
52});
53
54test('extractOnPageSeo extracts Twitter, headings, images, JSON-LD', () => {
55 const html = `<!doctype html><html lang="en-GB"><head>
56 <title>JSON-LD Test Page With Enough Characters</title>
57 <meta name="description" content="A meta description that is long enough to avoid short warnings in scoring." />
58 <link rel="alternate" hreflang="en-US" href="https://ex.com/us/" />
59 <link rel="alternate" hreflang="fr" href="https://ex.com/fr/" />
60 <meta name="twitter:card" content="summary_large_image" />
61 <meta name="twitter:title" content="Tw Title" />
62 <script type="application/ld+json">{"@type":"Organization","name":"Acme"}</script>
63 <script type="application/ld+json">{"@type":"WebSite","url":"https://ex.com/"}</script>
64 </head><body>
65 <h1>One</h1><h1>Two</h1><h2>Sub</h2>
66 <img src="/a.png" /><img src="/b.png" alt="ok" />
67 <p>hello world</p>
68 </body></html>`;
69 const r = extractOnPageSeo(html, 'https://ex.com/', 'https://ex.com/');
70 assert.equal(r.htmlLang, 'en-GB');
71 assert.equal(r.jsonLdCount, 2);
72 assert.deepEqual(r.jsonLdSchemas, ['Organization', 'WebSite']);
73 assert.deepEqual(r.hreflang, {
74 'en-US': 'https://ex.com/us/',
75 fr: 'https://ex.com/fr/',
76 });
77 assert.equal(r.twitterCard, 'summary_large_image');
78 assert.equal(r.twitterTitle, 'Tw Title');
79 assert.equal(r.h1Count, 2);
80 assert.equal(r.headings.h2, 1);
81 assert.equal(r.images.total, 2);
82 assert.equal(r.images.missingAlt, 1);
83});
84
85test('scoreOnPageSeo flags missing title and multiple H1', () => {
86 const scored = scoreOnPageSeo({
87 title: null,
88 metaDescription: null,
89 h1: 'A',
90 h1Count: 2,
91 canonical: null,
92 viewport: null,
93 robotsMeta: null,
94 ogTitle: null,
95 ogImage: null,
96 twitterCard: null,
97 images: { total: 2, missingAlt: 2 },
98 wordCount: 10,
99 htmlLang: null,
100 jsonLdCount: 0,
101 });
102 assert.ok(scored.score < 50);
103 const codes = scored.issues.map((i) => i.code);
104 assert.ok(codes.includes('missing_title'));
105 assert.ok(codes.includes('multiple_h1'));
106 assert.ok(codes.includes('images_missing_alt'));
107});
108
109test('scoreOnPageSeo deducts for missing title and meta', () => {
110 const { score, issues } = scoreOnPageSeo({
111 title: null,
112 metaDescription: null,
113 h1: 'Ok',
114 h1Count: 1,
115 canonical: 'https://ex.com/',
116 viewport: 'width=device-width',
117 wordCount: 200,
118 htmlLang: 'en',
119 jsonLdCount: 1,
120 });
121 assert.ok(score < 100);
122 assert.ok(issues.some((i) => i.code === 'missing_title'));
123 assert.ok(issues.some((i) => i.code === 'missing_meta_description'));
124});
125
126test('scoreOnPageSeo rewards a healthy page near 100', () => {
127 const { score, issues } = scoreOnPageSeo({
128 title: 'A solid page title that is long enough here',
129 metaDescription:
130 'A meta description that is comfortably between seventy and one hundred sixty characters for SEO.',
131 h1: 'Main',
132 h1Count: 1,
133 canonical: 'https://ex.com/',
134 viewport: 'width=device-width',
135 ogTitle: 'OG',
136 ogImage: 'https://ex.com/og.png',
137 twitterCard: 'summary',
138 wordCount: 250,
139 htmlLang: 'en',
140 jsonLdCount: 1,
141 images: { total: 1, missingAlt: 0 },
142 });
143 assert.equal(score, 100);
144 assert.equal(issues.length, 0);
145});
146
147test('runPool respects concurrency and completes all', async () => {
148 const order = [];
149 const out = await runPool([1, 2, 3, 4], 2, async (n) => {
150 order.push(`start-${n}`);
151 await new Promise((r) => setTimeout(r, 5));
152 order.push(`end-${n}`);
153 return n * 2;
154 });
155 assert.deepEqual(out, [2, 4, 6, 8]);
156 assert.ok(order.length === 8);
157});
158
159test('live fetch example.com (network)', async () => {
160 const res = await fetch('https://example.com/', {
161 headers: { 'user-agent': 'ApifyActor-test' },
162 });
163 assert.ok(res.ok);
164 const html = await res.text();
165 const r = extractOnPageSeo(html, res.url, 'https://example.com/');
166 assert.equal(r.title, 'Example Domain');
167 assert.ok(r.h1 === null || typeof r.h1 === 'string');
168 assert.equal(r.links.total, 1);
169 const scored = scoreOnPageSeo(r);
170 assert.ok(typeof scored.score === 'number');
171 assert.ok(Array.isArray(scored.issues));
172});
173
174test('validateHttpUrl rejects non-URLs and non-http schemes', () => {
175 assert.equal(validateHttpUrl('not-a-url').valid, false);
176 assert.match(validateHttpUrl('not-a-url').error, /Invalid URL/);
177 assert.equal(validateHttpUrl('ftp://example.com/').valid, false);
178 assert.equal(validateHttpUrl(' https://example.com/ ').valid, true);
179});
180
181test('validateHttpUrl blocks private / loopback / metadata hosts (SSRF)', () => {
182 assert.equal(validateHttpUrl('http://127.0.0.1/').valid, false);
183 assert.equal(validateHttpUrl('http://localhost/x').valid, false);
184 assert.equal(validateHttpUrl('http://192.168.1.1/').valid, false);
185 assert.equal(validateHttpUrl('http://10.0.0.5/').valid, false);
186 assert.equal(validateHttpUrl('http://169.254.169.254/latest/meta').valid, false);
187 assert.equal(validateHttpUrl('https://example.com/ok').valid, true);
188});
189
190test('fetchPageWithRedirects records http→https redirect chain (network)', async () => {
191 const fetched = await fetchPageWithRedirects('http://apify.com/', {
192 maxRedirects: 10,
193 timeoutMs: 30000,
194 maxRetries: 1,
195 });
196 assert.ok(fetched.ok, fetched.error ?? 'fetch failed');
197 assert.ok(fetched.finalUrl.startsWith('https://'));
198 assert.ok(fetched.chain.length >= 2, 'expected at least one redirect hop');
199 assert.equal(fetched.chain[0].url, 'http://apify.com/');
200 assert.ok(fetched.chain[0].status >= 300 && fetched.chain[0].status < 400);
201 assert.equal(fetched.chain.at(-1).status, 200);
202});