1import test from 'node:test';
2import assert from 'node:assert/strict';
3import {
4 analyzeSpeculationRules,
5 analyzeRule,
6 analyzeRuleSet,
7 buildRecommendations,
8 extractInlineRuleBlocks,
9 gradeFromScore,
10 normalizeAndValidateUrl,
11 parseRulesJson,
12 parseSpeculationRulesHeader,
13 scoreSpeculationRules,
14} from '../src/main.js';
15
16
17
18
19
20test('normalizeAndValidateUrl blocks private IPv4 literal', async () => {
21 await assert.rejects(() => normalizeAndValidateUrl('http://127.0.0.1/'), /Private IPv4/);
22});
23
24test('normalizeAndValidateUrl blocks 169.254 link-local', async () => {
25 await assert.rejects(() => normalizeAndValidateUrl('http://169.254.169.254/'), /Private IPv4/);
26});
27
28test('normalizeAndValidateUrl rejects URL credentials', async () => {
29 await assert.rejects(() => normalizeAndValidateUrl('https://user:pass@example.com'), /credentials/);
30});
31
32test('normalizeAndValidateUrl rejects non-HTTP schemes', async () => {
33 await assert.rejects(() => normalizeAndValidateUrl('file:///etc/hosts'), /HTTP and HTTPS/);
34});
35
36test('normalizeAndValidateUrl rejects ftp scheme', async () => {
37 await assert.rejects(() => normalizeAndValidateUrl('ftp://example.com/file'), /HTTP and HTTPS/);
38});
39
40test('normalizeAndValidateUrl accepts public hostname and defaults to https', async () => {
41 const url = await normalizeAndValidateUrl('example.com');
42 assert.equal(url.hostname, 'example.com');
43 assert.equal(url.protocol, 'https:');
44});
45
46
47
48
49
50test('extractInlineRuleBlocks finds a single inline block', () => {
51 const html = '<html><head><script type="speculationrules">{"prefetch":[{"urls":["/next"]}]}</script></head></html>';
52 const blocks = extractInlineRuleBlocks(html);
53 assert.equal(blocks.length, 1);
54 assert.equal(blocks[0].text, '{"prefetch":[{"urls":["/next"]}]}');
55});
56
57test('extractInlineRuleBlocks returns [] for empty input', () => {
58 assert.deepEqual(extractInlineRuleBlocks(''), []);
59 assert.deepEqual(extractInlineRuleBlocks(null), []);
60});
61
62test('extractInlineRuleBlocks handles single-quoted type attribute', () => {
63 const html = "<script type='speculationrules'>{\"prerender\":[]}</script>";
64 const blocks = extractInlineRuleBlocks(html);
65 assert.equal(blocks.length, 1);
66 assert.equal(blocks[0].text, '{"prerender":[]}');
67});
68
69test('extractInlineRuleBlocks ignores non-speculationrules scripts', () => {
70 const html = '<script>const x = 1;</script><script type="application/json">{"a":1}</script>';
71 assert.equal(extractInlineRuleBlocks(html).length, 0);
72});
73
74test('extractInlineRuleBlocks extracts multiple blocks', () => {
75 const html = [
76 '<script type="speculationrules">{"prefetch":[]}</script>',
77 '<script type="speculationrules">{"prerender":[]}</script>',
78 ].join('');
79 assert.equal(extractInlineRuleBlocks(html).length, 2);
80});
81
82
83
84
85
86test('parseSpeculationRulesHeader returns [] for null/empty', () => {
87 assert.deepEqual(parseSpeculationRulesHeader(null, 'https://example.com/'), []);
88 assert.deepEqual(parseSpeculationRulesHeader('', 'https://example.com/'), []);
89 assert.deepEqual(parseSpeculationRulesHeader(undefined, 'https://example.com/'), []);
90});
91
92test('parseSpeculationRulesHeader resolves relative URLs against the page', () => {
93 const urls = parseSpeculationRulesHeader('/speculationrules.json', 'https://example.com/page');
94 assert.deepEqual(urls, ['https://example.com/speculationrules.json']);
95});
96
97test('parseSpeculationRulesHeader splits and strips quotes', () => {
98 const urls = parseSpeculationRulesHeader('"/a.json",\'/b.json\', https://cdn.example.com/c.json', 'https://example.com/');
99 assert.deepEqual(urls, [
100 'https://example.com/a.json',
101 'https://example.com/b.json',
102 'https://cdn.example.com/c.json',
103 ]);
104});
105
106test('parseSpeculationRulesHeader keeps unresolvable values raw', () => {
107
108 const urls = parseSpeculationRulesHeader('http:::', 'https://example.com/');
109 assert.deepEqual(urls, ['http:::']);
110});
111
112
113
114
115
116test('parseRulesJson parses valid object', () => {
117 const { rules, error } = parseRulesJson('{"prefetch":[]}');
118 assert.equal(error, null);
119 assert.deepEqual(rules, { prefetch: [] });
120});
121
122test('parseRulesJson rejects empty body', () => {
123 const { rules, error } = parseRulesJson(' ');
124 assert.equal(rules, null);
125 assert.match(error, /empty script body/);
126});
127
128test('parseRulesJson rejects non-object JSON (array)', () => {
129 const { rules, error } = parseRulesJson('[]');
130 assert.equal(rules, null);
131 assert.match(error, /must be an object/);
132});
133
134test('parseRulesJson rejects non-object JSON (string)', () => {
135 const { rules, error } = parseRulesJson('"hello"');
136 assert.equal(rules, null);
137 assert.match(error, /must be an object/);
138});
139
140test('parseRulesJson rejects malformed JSON', () => {
141 const { rules, error } = parseRulesJson('{not json');
142 assert.equal(rules, null);
143 assert.match(error, /invalid JSON/);
144});
145
146test('parseRulesJson rejects null', () => {
147 const { rules, error } = parseRulesJson('null');
148 assert.equal(rules, null);
149 assert.match(error, /must be an object/);
150});
151
152
153
154
155
156test('analyzeRule flags rule with neither urls nor where', () => {
157 const block = analyzeRule({}, 'prefetch', 'https://example.com/');
158 assert.ok(block.issues.some((i) => i.includes('neither')));
159});
160
161test('analyzeRule flags rule with both urls and where', () => {
162 const block = analyzeRule({ urls: ['/a'], where: { href_matches: '/b' } }, 'prefetch', 'https://example.com/');
163 assert.ok(block.issues.some((i) => i.includes('both')));
164});
165
166test('analyzeRule flags urls that is not an array', () => {
167 const block = analyzeRule({ urls: '/a' }, 'prefetch', 'https://example.com/');
168 assert.ok(block.issues.some((i) => i.includes('not an array')));
169});
170
171test('analyzeRule flags empty urls array', () => {
172 const block = analyzeRule({ urls: [] }, 'prefetch', 'https://example.com/');
173 assert.ok(block.issues.some((i) => i.includes('empty array')));
174 assert.equal(block.defaultEagernessApplied, true);
175});
176
177test('analyzeRule flags duplicate urls entries', () => {
178 const block = analyzeRule({ urls: ['/a', '/a', '/b'] }, 'prerender', 'https://example.com/');
179 assert.equal(block.urlsCount, 2);
180 assert.ok(block.issues.some((i) => i.includes('duplicate')));
181});
182
183test('analyzeRule applies default immediate eagerness for urls', () => {
184 const block = analyzeRule({ urls: ['/next'] }, 'prefetch', 'https://example.com/');
185 assert.equal(block.eagerness, 'immediate');
186 assert.equal(block.defaultEagernessApplied, true);
187});
188
189test('analyzeRule applies default conservative eagerness for where', () => {
190 const block = analyzeRule({ where: { href_matches: '/*' } }, 'prefetch', 'https://example.com/');
191 assert.equal(block.eagerness, 'conservative');
192 assert.equal(block.defaultEagernessApplied, true);
193});
194
195test('analyzeRule validates invalid eagerness value', () => {
196 const block = analyzeRule({ urls: ['/a'], eagerness: 'super' }, 'prefetch', 'https://example.com/');
197 assert.ok(block.issues.some((i) => i.includes('not one of')));
198});
199
200test('analyzeRule flags broad href_matches /* prefetch without not', () => {
201 const block = analyzeRule({ where: { href_matches: '/*' } }, 'prefetch', 'https://example.com/');
202 assert.ok(block.issues.some((i) => i.includes('without') && i.includes('state-changing')));
203});
204
205test('analyzeRule flags broad href_matches * (matches all origins)', () => {
206 const block = analyzeRule({ where: { href_matches: '*' } }, 'prefetch', 'https://example.com/');
207 assert.ok(block.issues.some((i) => i.includes('matches all origins')));
208});
209
210test('analyzeRule does NOT flag broad prefetch when not exclusion present', () => {
211 const block = analyzeRule({ where: { href_matches: '/*', not: { href_matches: '/logout' } } }, 'prefetch', 'https://example.com/');
212 assert.ok(!block.issues.some((i) => i.includes('without') && i.includes('state-changing')));
213});
214
215test('analyzeRule does NOT flag broad prefetch when not is in and group', () => {
216 const block = analyzeRule({
217 where: { and: [{ href_matches: '/*' }, { not: { href_matches: '/logout' } }] },
218 }, 'prefetch', 'https://example.com/');
219 assert.ok(!block.issues.some((i) => i.includes('without') && i.includes('state-changing')));
220});
221
222test('analyzeRule flags aggressive eager prerender on broad matcher without not', () => {
223 const block = analyzeRule({ where: { href_matches: '/*' }, eagerness: 'eager' }, 'prerender', 'https://example.com/');
224 assert.ok(block.issues.some((i) => i.includes('aggressive')));
225});
226
227test('analyzeRule flags immediate prerender with more than 3 URLs', () => {
228 const block = analyzeRule({ urls: ['/a', '/b', '/c', '/d'], eagerness: 'immediate' }, 'prerender', 'https://example.com/');
229 assert.ok(block.issues.some((i) => i.includes('exceeds Chrome')));
230});
231
232test('analyzeRule detects selector_matches predicate', () => {
233 const block = analyzeRule({ where: { selector_matches: '.prerender' } }, 'prerender', 'https://example.com/');
234 assert.equal(block.wherePredicate, 'selector_matches');
235});
236
237test('analyzeRule flags where with no recognized predicate', () => {
238 const block = analyzeRule({ where: { bogus: 1 } }, 'prefetch', 'https://example.com/');
239 assert.ok(block.issues.some((i) => i.includes('no recognized predicate')));
240});
241
242
243
244
245
246test('analyzeRuleSet aggregates by action and eagerness levels', () => {
247 const rules = {
248 prefetch: [{ urls: ['/a'], eagerness: 'eager' }],
249 prerender: [{ where: { href_matches: '/*', not: { href_matches: '/logout' } }, eagerness: 'moderate' }],
250 };
251 const { blocks, byAction, eagernessLevels, totalRules, issues } = analyzeRuleSet(rules, 'https://example.com/');
252 assert.equal(totalRules, 2);
253 assert.equal(byAction.prefetch, 1);
254 assert.equal(byAction.prerender, 1);
255 assert.deepEqual(eagernessLevels, ['eager', 'moderate']);
256 assert.equal(blocks.length, 2);
257
258 assert.equal(issues.length, 0);
259});
260
261test('analyzeRuleSet flags non-array action value', () => {
262 const { byAction, issues } = analyzeRuleSet({ prefetch: 'not-an-array' }, 'https://example.com/');
263 assert.equal(byAction.prefetch, 0);
264 assert.ok(issues.some((i) => i.includes('not an array')));
265});
266
267test('analyzeRuleSet skips missing actions without error', () => {
268 const { totalRules, byAction } = analyzeRuleSet({}, 'https://example.com/');
269 assert.equal(totalRules, 0);
270 assert.deepEqual(byAction, {});
271});
272
273
274
275
276
277test('analyzeSpeculationRules returns empty summary for plain HTML with no rules', () => {
278 const html = '<html><body><h1>Hello</h1></body></html>';
279 const summary = analyzeSpeculationRules(html, 'https://example.com/', null);
280 assert.equal(summary.inlineRuleCount, 0);
281 assert.equal(summary.headerRuleUrls.length, 0);
282 assert.equal(summary.totalRules, 0);
283 assert.deepEqual(summary.byAction, {});
284 assert.deepEqual(summary.eagernessLevels, []);
285 assert.equal(summary.ruleBlocks.length, 0);
286 assert.equal(summary.issues.length, 0);
287});
288
289test('analyzeSpeculationRules parses a well-formed inline block', () => {
290 const html = '<script type="speculationrules">{"prefetch":[{"urls":["/next"],"eagerness":"eager"}]}</script>';
291 const summary = analyzeSpeculationRules(html, 'https://example.com/', null);
292 assert.equal(summary.inlineRuleCount, 1);
293 assert.equal(summary.totalRules, 1);
294 assert.equal(summary.byAction.prefetch, 1);
295 assert.deepEqual(summary.eagernessLevels, ['eager']);
296 assert.equal(summary.ruleBlocks.length, 1);
297});
298
299test('analyzeSpeculationRules flags invalid inline JSON', () => {
300 const html = '<script type="speculationrules">{bad json</script>';
301 const summary = analyzeSpeculationRules(html, 'https://example.com/', null);
302 assert.equal(summary.inlineRuleCount, 0);
303 assert.ok(summary.issues.some((i) => i.includes('invalid JSON')));
304});
305
306test('analyzeSpeculationRules flags empty inline block', () => {
307 const html = '<script type="speculationrules"></script>';
308 const summary = analyzeSpeculationRules(html, 'https://example.com/', null);
309 assert.ok(summary.issues.some((i) => i.includes('empty script body')));
310});
311
312test('analyzeSpeculationRules processes header URL reference', () => {
313 const html = '<html></html>';
314 const summary = analyzeSpeculationRules(html, 'https://example.com/page', '/speculation-rules.json');
315 assert.equal(summary.inlineRuleCount, 0);
316 assert.deepEqual(summary.headerRuleUrls, ['https://example.com/speculation-rules.json']);
317 assert.equal(summary.ruleBlocks.length, 1);
318 assert.equal(summary.ruleBlocks[0].source, 'header');
319 assert.equal(summary.ruleBlocks[0].urlCount, 1);
320});
321
322
323
324
325
326test('scoreSpeculationRules returns 0 when no rules and no header', () => {
327 const summary = { inlineRuleCount: 0, headerRuleUrls: [], byAction: {}, totalRules: 0, issues: [] };
328 assert.equal(scoreSpeculationRules(summary), 0);
329});
330
331test('scoreSpeculationRules rewards inline block presence', () => {
332 const summary = { inlineRuleCount: 1, headerRuleUrls: [], byAction: { prefetch: 1 }, totalRules: 1, issues: [] };
333 const score = scoreSpeculationRules(summary);
334 assert.ok(score > 0 && score <= 100, `expected 0-100, got ${score}`);
335});
336
337test('scoreSpeculationRules rewards graduated prefetch+prerender', () => {
338 const good = { inlineRuleCount: 1, headerRuleUrls: [], byAction: { prefetch: 1, prerender: 1 }, totalRules: 2, issues: [] };
339 const prefetchOnly = { inlineRuleCount: 1, headerRuleUrls: [], byAction: { prefetch: 1 }, totalRules: 1, issues: [] };
340 assert.ok(scoreSpeculationRules(good) > scoreSpeculationRules(prefetchOnly), 'graduated strategy should score higher');
341});
342
343test('scoreSpeculationRules penalises issues but not below 0', () => {
344 const withIssues = { inlineRuleCount: 1, headerRuleUrls: [], byAction: { prefetch: 1, prerender: 1 }, totalRules: 2, issues: ['x', 'y', 'z', 'w', 'w2', 'w3'] };
345 const score = scoreSpeculationRules(withIssues);
346 assert.ok(score >= 0 && score < 100);
347 const allBad = { inlineRuleCount: 1, headerRuleUrls: [], byAction: {}, totalRules: 1, issues: ['a','b','c','d','e','f','g','h','i','j'] };
348 assert.equal(scoreSpeculationRules(allBad), 0);
349});
350
351test('gradeFromScore returns expected letter grades', () => {
352 assert.equal(gradeFromScore(100), 'A+');
353 assert.equal(gradeFromScore(90), 'A');
354 assert.equal(gradeFromScore(80), 'B');
355 assert.equal(gradeFromScore(70), 'C');
356 assert.equal(gradeFromScore(55), 'D');
357 assert.equal(gradeFromScore(40), 'E');
358 assert.equal(gradeFromScore(10), 'F');
359});
360
361
362
363
364
365test('buildRecommendations suggests starting with prefetch when nothing used', () => {
366 const summary = { inlineRuleCount: 0, headerRuleUrls: [], byAction: {}, totalRules: 0, issues: [] };
367 const recs = buildRecommendations(summary, 0);
368 assert.ok(recs.some((r) => r.includes('moderate')));
369});
370
371test('buildRecommendations suggests prerender when only prefetch used', () => {
372 const summary = { inlineRuleCount: 1, headerRuleUrls: [], byAction: { prefetch: 1 }, totalRules: 1, issues: [] };
373 const recs = buildRecommendations(summary, 50);
374 assert.ok(recs.some((r) => r.includes('prerender')));
375});
376
377test('buildRecommendations suggests fixing malformed JSON', () => {
378 const summary = { inlineRuleCount: 0, headerRuleUrls: [], byAction: {}, totalRules: 0, issues: ['inline block: invalid JSON: unexpected token'] };
379 const recs = buildRecommendations(summary, 0);
380 assert.ok(recs.some((r) => r.includes('malformed')));
381});
382
383test('buildRecommendations suggests exclusions when broad matcher flagged', () => {
384 const summary = { inlineRuleCount: 1, headerRuleUrls: [], byAction: { prefetch: 1 }, totalRules: 1, issues: ['prefetch: broad `href_matches: /*` prefetch without a `not` exclusion for state-changing paths (e.g., /logout) risks side-effecting GETs'] };
385 const recs = buildRecommendations(summary, 50);
386 assert.ok(recs.some((r) => r.includes('exclusion') || r.includes('/logout')));
387});
388
389test('buildRecommendations positive note when no issues and rules present', () => {
390 const summary = { inlineRuleCount: 1, headerRuleUrls: [], byAction: { prefetch: 1, prerender: 1 }, totalRules: 2, issues: [] };
391 const recs = buildRecommendations(summary, 90);
392 assert.ok(recs.some((r) => r.includes('well-configured')));
393});