score-refresh.test.js
1 /** 2 * Tests for src/reports/score-refresh.js 3 * 4 * Covers: 5 * - refreshScore: missing OPENROUTER_API_KEY throws 6 * - refreshScore: happy path returns scoreJson with computed score + grade 7 * - refreshScore: empty LLM response throws 8 * - refreshScore: invalid JSON in LLM response throws 9 * - refreshScore: thinking blocks are stripped before JSON parse 10 * - refreshScore: markdown code block wrapping stripped 11 * - refreshScore: uses English for unknown country code 12 * - refreshScore: targetLanguage override 13 * - refreshScore: reasoning config added for anthropic/ models 14 * - refreshScore: no reasoning config for non-anthropic models 15 * - refreshScore: copy reference validation triggers retry 16 * - refreshScore: retry failure falls back to null replacements 17 * - refreshScore: persistent mismatch after retry nulls problem_areas refs 18 * - refreshScore: copy reference validation passes cleanly 19 * - refreshScore: null htmlContent skips copy validation 20 * - refreshScore: adds overall_calculation when missing 21 * - refreshScore: API error re-throws 22 * 23 * Run: NODE_ENV=test LOGS_DIR=/tmp/test-logs node --experimental-test-module-mocks --test tests/reports/score-refresh.test.js 24 */ 25 26 import { describe, it, mock } from 'node:test'; 27 import assert from 'node:assert/strict'; 28 import Database from 'better-sqlite3'; 29 import { createPgMock } from '../helpers/pg-mock.js'; 30 31 // ── Set env vars BEFORE any module imports ──────────────────────────────────── 32 33 process.env.OPENROUTER_API_KEY = 'test-openrouter-key'; 34 process.env.NODE_ENV = 'test'; 35 process.env.LOGS_DIR = '/tmp/test-logs'; 36 37 // ── Mutable axios controller ────────────────────────────────────────────────── 38 // score-refresh.js does `const { default: axios } = await import('axios')` on 39 // each call, so we mock axios once but use a mutable handler that each test can 40 // swap out. 41 42 const axiosController = { 43 post: null, // set by each test 44 }; 45 46 const defaultScoreJson = () => ({ 47 factor_scores: { 48 headline_quality: { score: 7, current_text: 'Welcome to our site' }, 49 }, 50 problem_areas: [], 51 strategic_recommendations: ['Fix headline'], 52 report_narratives: { summary: 'Needs improvement.' }, 53 }); 54 55 axiosController.post = async () => ({ 56 data: { 57 choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }], 58 usage: { prompt_tokens: 1000, completion_tokens: 500 }, 59 }, 60 }); 61 62 mock.module('axios', { 63 defaultExport: { 64 post: async (...args) => axiosController.post(...args), 65 }, 66 }); 67 68 // ── Mock sharp (native module) ──────────────────────────────────────────────── 69 70 const sharpController = { 71 width: 800, 72 height: 600, 73 }; 74 75 mock.module('sharp', { 76 defaultExport: mock.fn(buf => ({ 77 metadata: async () => ({ width: sharpController.width, height: sharpController.height }), 78 resize: () => ({ 79 jpeg: () => ({ 80 toBuffer: async () => Buffer.from('resized-jpeg'), 81 }), 82 }), 83 jpeg: () => ({ 84 toBuffer: async () => Buffer.from('jpeg-direct'), 85 }), 86 toBuffer: async () => buf, 87 })), 88 }); 89 90 // ── Mock node:fs readFileSync ───────────────────────────────────────────────── 91 92 const mockAuditPrompt = 'Audit this website: {target_language}\nReturn JSON only.'; 93 94 mock.module('node:fs', { 95 namedExports: { 96 readFileSync: mock.fn((filePath, _enc) => { 97 if (String(filePath).includes('AUDIT-REPORT-SCORING')) { 98 return mockAuditPrompt; 99 } 100 throw Object.assign(new Error(`ENOENT: no such file: ${filePath}`), { code: 'ENOENT' }); 101 }), 102 }, 103 }); 104 105 // ── Mock db.js via createPgMock ─────────────────────────────────────────────── 106 107 const dbController = { 108 outreachRows: [], 109 inboundRows: [], 110 throwOnNew: false, 111 }; 112 113 // score-refresh.js calls getAll() twice for a siteId: once for outreach rows, 114 // once for inbound rows. We intercept at the db.js level. 115 mock.module('../../src/utils/db.js', { 116 namedExports: { 117 getPool: () => ({}), 118 closePool: async () => {}, 119 query: async () => ({ rows: [], rowCount: 0 }), 120 run: async () => ({ changes: 0 }), 121 withTransaction: async fn => fn({ query: async () => ({ rows: [], rowCount: 0 }) }), 122 getOne: async () => null, 123 getAll: async (sql) => { 124 if (dbController.throwOnNew) throw new Error('DB connection failed'); 125 // Distinguish outreach query (direction = 'outbound') from conversation query (direction = 'inbound') 126 if (sql && sql.includes("'outbound'")) { 127 return dbController.outreachRows; 128 } 129 return dbController.inboundRows; 130 }, 131 createDatabaseConnection: () => ({}), 132 closeDatabaseConnection: async () => {}, 133 }, 134 }); 135 136 // ── Mock load-env ───────────────────────────────────────────────────────────── 137 138 mock.module('../../src/utils/load-env.js', { defaultExport: {} }); 139 140 // ── Mock logger ─────────────────────────────────────────────────────────────── 141 142 mock.module('../../src/utils/logger.js', { 143 defaultExport: class { 144 info() {} 145 success() {} 146 error() {} 147 warn() {} 148 }, 149 }); 150 151 // ── Mock score.js ───────────────────────────────────────────────────────────── 152 153 const scoreController = { score: 65, grade: 'D' }; 154 155 mock.module('../../src/score.js', { 156 namedExports: { 157 computeScoreFromFactors: mock.fn(() => scoreController.score), 158 computeGrade: mock.fn(() => scoreController.grade), 159 }, 160 }); 161 162 // ── Import module under test AFTER all mocks ────────────────────────────────── 163 164 const { refreshScore } = await import('../../src/reports/score-refresh.js'); 165 166 // ── Helpers ─────────────────────────────────────────────────────────────────── 167 168 function makeSiteData(overrides = {}) { 169 return { 170 url: 'https://example.com', 171 fullPageBuffer: Buffer.from('full-page-screenshot'), 172 aboveFoldBuffer: Buffer.from('above-fold-screenshot'), 173 htmlContent: '<html><body>Welcome to our site. Call us now.</body></html>', 174 httpHeaders: { 'content-type': 'text/html' }, 175 siteId: null, 176 countryCode: 'AU', 177 ...overrides, 178 }; 179 } 180 181 function resetAxiosToDefault() { 182 axiosController.post = async () => ({ 183 data: { 184 choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }], 185 usage: { prompt_tokens: 1000, completion_tokens: 500 }, 186 }, 187 }); 188 } 189 190 // ── Tests ───────────────────────────────────────────────────────────────────── 191 192 describe('refreshScore', () => { 193 it('returns scoreJson with computed score and grade on happy path', async () => { 194 resetAxiosToDefault(); 195 const result = await refreshScore(makeSiteData()); 196 assert.ok(result, 'should return a result'); 197 assert.ok(result.overall_calculation, 'should have overall_calculation'); 198 assert.equal(result.overall_calculation.conversion_score, 65); 199 assert.equal(result.overall_calculation.letter_grade, 'D'); 200 }); 201 202 it('throws when OPENROUTER_API_KEY is not set', async () => { 203 const savedKey = process.env.OPENROUTER_API_KEY; 204 delete process.env.OPENROUTER_API_KEY; 205 try { 206 await assert.rejects(() => refreshScore(makeSiteData()), /OPENROUTER_API_KEY not configured/); 207 } finally { 208 process.env.OPENROUTER_API_KEY = savedKey; 209 } 210 }); 211 212 it('throws when LLM response content is null (empty response)', async () => { 213 axiosController.post = async () => ({ 214 data: { choices: [{ message: { content: null } }] }, 215 }); 216 await assert.rejects(() => refreshScore(makeSiteData()), /Empty LLM response/); 217 resetAxiosToDefault(); 218 }); 219 220 it('throws when LLM response content is empty string', async () => { 221 axiosController.post = async () => ({ 222 data: { choices: [{ message: { content: '' } }] }, 223 }); 224 await assert.rejects(() => refreshScore(makeSiteData()), /Empty LLM response/); 225 resetAxiosToDefault(); 226 }); 227 228 it('throws when LLM response is not valid JSON', async () => { 229 axiosController.post = async () => ({ 230 data: { choices: [{ message: { content: 'NOT JSON AT ALL !!!!' } }] }, 231 }); 232 await assert.rejects( 233 () => refreshScore(makeSiteData()), 234 /Failed to parse LLM response as JSON/ 235 ); 236 resetAxiosToDefault(); 237 }); 238 239 it('strips thinking blocks before parsing JSON', async () => { 240 const scoreJson = defaultScoreJson(); 241 axiosController.post = async () => ({ 242 data: { 243 choices: [ 244 { 245 message: { 246 content: `<thinking>I need to analyze this website carefully.</thinking>${JSON.stringify(scoreJson)}`, 247 }, 248 }, 249 ], 250 usage: { prompt_tokens: 100, completion_tokens: 200 }, 251 }, 252 }); 253 const result = await refreshScore(makeSiteData()); 254 assert.ok(result.factor_scores, 'should have parsed factor_scores'); 255 assert.ok(result.overall_calculation, 'should have overall_calculation added'); 256 resetAxiosToDefault(); 257 }); 258 259 it('strips markdown code block wrapping from LLM response', async () => { 260 const scoreJson = defaultScoreJson(); 261 axiosController.post = async () => ({ 262 data: { 263 choices: [ 264 { 265 message: { 266 content: `\`\`\`json\n${JSON.stringify(scoreJson)}\n\`\`\``, 267 }, 268 }, 269 ], 270 usage: { prompt_tokens: 100, completion_tokens: 200 }, 271 }, 272 }); 273 const result = await refreshScore(makeSiteData()); 274 assert.ok(result.factor_scores, 'should have parsed factor_scores after stripping markdown'); 275 resetAxiosToDefault(); 276 }); 277 278 it('uses English as default language for unknown country code', async () => { 279 const capturedBodies = []; 280 axiosController.post = async (url, body) => { 281 capturedBodies.push(body); 282 return { 283 data: { 284 choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }], 285 usage: { prompt_tokens: 50, completion_tokens: 50 }, 286 }, 287 }; 288 }; 289 await refreshScore(makeSiteData({ countryCode: 'XX' })); 290 assert.equal(capturedBodies.length, 1); 291 const msgText = capturedBodies[0].messages[0].content[0].text; 292 assert.ok(msgText.includes('English'), 'unknown country code should fall back to English'); 293 resetAxiosToDefault(); 294 }); 295 296 it('uses targetLanguage override when provided', async () => { 297 const capturedBodies = []; 298 axiosController.post = async (url, body) => { 299 capturedBodies.push(body); 300 return { 301 data: { 302 choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }], 303 usage: { prompt_tokens: 50, completion_tokens: 50 }, 304 }, 305 }; 306 }; 307 await refreshScore(makeSiteData({ targetLanguage: 'Klingon', countryCode: 'AU' })); 308 assert.equal(capturedBodies.length, 1); 309 const msgText = capturedBodies[0].messages[0].content[0].text; 310 assert.ok(msgText.includes('Klingon'), 'should use targetLanguage override'); 311 resetAxiosToDefault(); 312 }); 313 314 it('adds reasoning config for anthropic/ model prefix', async () => { 315 const capturedBodies = []; 316 const savedModel = process.env.AUDIT_REPORT_MODEL; 317 process.env.AUDIT_REPORT_MODEL = 'anthropic/claude-opus-4'; 318 axiosController.post = async (url, body) => { 319 capturedBodies.push(body); 320 return { 321 data: { 322 choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }], 323 usage: { prompt_tokens: 50, completion_tokens: 50 }, 324 }, 325 }; 326 }; 327 await refreshScore(makeSiteData()); 328 assert.equal(capturedBodies.length, 1); 329 assert.ok(capturedBodies[0].reasoning, 'anthropic model should have reasoning config'); 330 assert.equal(capturedBodies[0].reasoning.enabled, true); 331 process.env.AUDIT_REPORT_MODEL = savedModel; 332 resetAxiosToDefault(); 333 }); 334 335 it('does not add reasoning config for non-anthropic model', async () => { 336 const capturedBodies = []; 337 const savedModel = process.env.AUDIT_REPORT_MODEL; 338 process.env.AUDIT_REPORT_MODEL = 'openai/gpt-4o'; 339 axiosController.post = async (url, body) => { 340 capturedBodies.push(body); 341 return { 342 data: { 343 choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }], 344 usage: { prompt_tokens: 50, completion_tokens: 50 }, 345 }, 346 }; 347 }; 348 await refreshScore(makeSiteData()); 349 assert.equal(capturedBodies.length, 1); 350 assert.equal( 351 capturedBodies[0].reasoning, 352 undefined, 353 'non-anthropic model should not have reasoning' 354 ); 355 process.env.AUDIT_REPORT_MODEL = savedModel; 356 resetAxiosToDefault(); 357 }); 358 359 it('logs usage info when response has usage data (no throw)', async () => { 360 // usage is logged when response.data.usage exists — just ensure no crash 361 resetAxiosToDefault(); 362 const result = await refreshScore(makeSiteData()); 363 assert.ok(result.overall_calculation); 364 }); 365 366 it('handles siteId with DB returning outreach and conversation rows', async () => { 367 dbController.outreachRows = [ 368 { 369 created_at: '2026-01-01 10:00:00', 370 contact_method: 'sms', 371 status: 'delivered', 372 proposal_text: 'Hi, we found issues with your site', 373 }, 374 ]; 375 dbController.inboundRows = [ 376 { 377 created_at: '2026-01-01 11:00:00', 378 direction: 'inbound', 379 channel: 'sms', 380 message_body: 'Thanks, interested', 381 intent: 'positive', 382 }, 383 ]; 384 resetAxiosToDefault(); 385 386 const result = await refreshScore(makeSiteData({ siteId: 42 })); 387 assert.ok(result, 'should return result with communication context'); 388 assert.ok(result.overall_calculation); 389 390 // Reset 391 dbController.outreachRows = []; 392 dbController.inboundRows = []; 393 }); 394 395 it('handles DB error on siteId gracefully (returns empty context)', async () => { 396 dbController.throwOnNew = true; 397 resetAxiosToDefault(); 398 399 const result = await refreshScore(makeSiteData({ siteId: 99 })); 400 assert.ok(result, 'should still return result despite DB error'); 401 assert.ok(result.overall_calculation); 402 403 dbController.throwOnNew = false; 404 }); 405 406 it('handles API error (re-throws after logging)', async () => { 407 const apiError = new Error('Network timeout'); 408 apiError.response = { data: { error: { message: 'Rate limited' } } }; 409 axiosController.post = async () => { 410 throw apiError; 411 }; 412 await assert.rejects(() => refreshScore(makeSiteData()), /Network timeout/); 413 resetAxiosToDefault(); 414 }); 415 416 it('handles API error without response.data (re-throws)', async () => { 417 axiosController.post = async () => { 418 throw new Error('Connection refused'); 419 }; 420 await assert.rejects(() => refreshScore(makeSiteData()), /Connection refused/); 421 resetAxiosToDefault(); 422 }); 423 424 it('copy reference validation passes cleanly when all refs found in HTML', async () => { 425 const htmlContent = 426 '<html><body>Welcome to our site. Call us now for a free quote.</body></html>'; 427 const cleanScore = { 428 factor_scores: { 429 headline_quality: { score: 7, current_text: 'welcome to our site' }, 430 }, 431 problem_areas: [{ factor: 'cta', current_text: 'call us now' }], 432 strategic_recommendations: ['Improve CTA'], 433 }; 434 axiosController.post = async () => ({ 435 data: { 436 choices: [{ message: { content: JSON.stringify(cleanScore) } }], 437 usage: { prompt_tokens: 100, completion_tokens: 100 }, 438 }, 439 }); 440 441 const result = await refreshScore(makeSiteData({ htmlContent })); 442 assert.ok(result, 'should return result'); 443 assert.ok(result.overall_calculation); 444 resetAxiosToDefault(); 445 }); 446 447 it('copy reference validation triggers retry when mismatches exist in factor_scores and problem_areas', async () => { 448 const htmlContent = '<html><body>Real page content here and nothing else</body></html>'; 449 const scoreWithBadRef = { 450 factor_scores: { 451 headline_quality: { score: 5, current_text: 'PHANTOM TEXT NOT ON PAGE XXXXXX' }, 452 }, 453 problem_areas: [{ factor: 'cta', current_text: 'ANOTHER PHANTOM TEXT YYY' }], 454 strategic_recommendations: [], 455 }; 456 const scoreRetry = { 457 factor_scores: { 458 headline_quality: { score: 5, current_text: 'real page content here' }, 459 }, 460 problem_areas: [{ factor: 'cta', current_text: 'real page content here' }], 461 strategic_recommendations: [], 462 }; 463 464 let callCount = 0; 465 axiosController.post = async () => { 466 callCount++; 467 const content = 468 callCount === 1 ? JSON.stringify(scoreWithBadRef) : JSON.stringify(scoreRetry); 469 return { 470 data: { 471 choices: [{ message: { content } }], 472 usage: { prompt_tokens: 100, completion_tokens: 100 }, 473 }, 474 }; 475 }; 476 477 const result = await refreshScore(makeSiteData({ htmlContent })); 478 assert.ok(result, 'should return result after retry'); 479 assert.equal(callCount, 2, 'should have made two API calls (initial + retry)'); 480 resetAxiosToDefault(); 481 }); 482 483 it('retry failure falls back to null replacements for unverified refs', async () => { 484 const htmlContent = '<html><body>Real page content only</body></html>'; 485 const scoreWithBadRef = { 486 factor_scores: { 487 headline_quality: { score: 5, current_text: 'PHANTOM FACTOR TEXT ZZZZ' }, 488 }, 489 problem_areas: [{ factor: 'cta', current_text: 'PHANTOM PROBLEM TEXT WWWW' }], 490 strategic_recommendations: [], 491 }; 492 493 let callCount = 0; 494 axiosController.post = async () => { 495 callCount++; 496 if (callCount === 1) { 497 return { 498 data: { 499 choices: [{ message: { content: JSON.stringify(scoreWithBadRef) } }], 500 usage: { prompt_tokens: 100, completion_tokens: 100 }, 501 }, 502 }; 503 } 504 throw new Error('Retry API failure'); 505 }; 506 507 const result = await refreshScore(makeSiteData({ htmlContent })); 508 assert.ok(result, 'should return result despite retry failure'); 509 assert.equal( 510 result.factor_scores?.headline_quality?.current_text, 511 null, 512 'factor current_text should be nulled after fallback' 513 ); 514 assert.ok( 515 result.factor_scores?.headline_quality?.current_text_context, 516 'should have context description' 517 ); 518 assert.equal( 519 result.problem_areas?.[0]?.current_text, 520 null, 521 'problem_area current_text should be nulled after fallback' 522 ); 523 assert.ok( 524 result.problem_areas?.[0]?.current_text_context, 525 'problem_area should have context description' 526 ); 527 resetAxiosToDefault(); 528 }); 529 530 it('persistent mismatch after retry nulls both factor_scores and problem_areas refs', async () => { 531 const htmlContent = '<html><body>Only this text is real</body></html>'; 532 const scoreWithBadRef = { 533 factor_scores: { 534 headline_quality: { score: 5, current_text: 'PHANTOM TEXT NOT REAL AAAA' }, 535 }, 536 problem_areas: [{ factor: 'hero', current_text: 'PHANTOM PROBLEM NOT REAL BBBB' }], 537 strategic_recommendations: [], 538 }; 539 540 // Both calls return refs that won't match the HTML 541 axiosController.post = async () => ({ 542 data: { 543 choices: [{ message: { content: JSON.stringify(scoreWithBadRef) } }], 544 usage: { prompt_tokens: 100, completion_tokens: 100 }, 545 }, 546 }); 547 548 const result = await refreshScore(makeSiteData({ htmlContent })); 549 assert.equal( 550 result.problem_areas?.[0]?.current_text, 551 null, 552 'problem_areas ref should be nulled after persistent mismatch' 553 ); 554 assert.ok( 555 result.problem_areas?.[0]?.current_text_context, 556 'should have context description for problem_area' 557 ); 558 assert.equal( 559 result.factor_scores?.headline_quality?.current_text, 560 null, 561 'factor ref should be nulled after persistent mismatch' 562 ); 563 resetAxiosToDefault(); 564 }); 565 566 it('handles null htmlContent — skips copy validation entirely', async () => { 567 resetAxiosToDefault(); 568 const result = await refreshScore(makeSiteData({ htmlContent: null })); 569 assert.ok(result, 'should return result with null htmlContent'); 570 assert.ok(result.overall_calculation); 571 }); 572 573 it('adds overall_calculation when missing from LLM response', async () => { 574 const scoreWithoutCalc = { 575 factor_scores: { headline_quality: { score: 7 } }, 576 problem_areas: [], 577 }; 578 axiosController.post = async () => ({ 579 data: { 580 choices: [{ message: { content: JSON.stringify(scoreWithoutCalc) } }], 581 usage: { prompt_tokens: 50, completion_tokens: 50 }, 582 }, 583 }); 584 const result = await refreshScore(makeSiteData()); 585 assert.ok(result.overall_calculation, 'should create overall_calculation'); 586 assert.equal(typeof result.overall_calculation.conversion_score, 'number'); 587 resetAxiosToDefault(); 588 }); 589 590 it('handles response without usage field (no crash)', async () => { 591 axiosController.post = async () => ({ 592 data: { 593 choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }], 594 // no usage field 595 }, 596 }); 597 const result = await refreshScore(makeSiteData()); 598 assert.ok(result.overall_calculation, 'should work without usage data'); 599 resetAxiosToDefault(); 600 }); 601 602 it('includes outreach context section when siteId has outreach messages', async () => { 603 dbController.outreachRows = [ 604 { 605 created_at: '2026-01-01 10:00:00', 606 contact_method: 'email', 607 status: 'delivered', 608 proposal_text: 'Hello, we can help your website', 609 }, 610 ]; 611 dbController.inboundRows = []; 612 613 const capturedBodies = []; 614 axiosController.post = async (url, body) => { 615 capturedBodies.push(body); 616 return { 617 data: { 618 choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }], 619 usage: { prompt_tokens: 50, completion_tokens: 50 }, 620 }, 621 }; 622 }; 623 624 await refreshScore(makeSiteData({ siteId: 10 })); 625 assert.equal(capturedBodies.length, 1); 626 const msgText = capturedBodies[0].messages[0].content[0].text; 627 assert.ok(msgText.includes('Previous Outreach Messages'), 'should include outreach section'); 628 629 dbController.outreachRows = []; 630 resetAxiosToDefault(); 631 }); 632 633 it('includes conversation history section when siteId has inbound messages', async () => { 634 dbController.outreachRows = []; 635 dbController.inboundRows = [ 636 { 637 created_at: '2026-01-01 11:00:00', 638 direction: 'inbound', 639 channel: 'sms', 640 message_body: 'Yes please send me more info', 641 intent: 'positive', 642 }, 643 ]; 644 645 const capturedBodies = []; 646 axiosController.post = async (url, body) => { 647 capturedBodies.push(body); 648 return { 649 data: { 650 choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }], 651 usage: { prompt_tokens: 50, completion_tokens: 50 }, 652 }, 653 }; 654 }; 655 656 await refreshScore(makeSiteData({ siteId: 11 })); 657 assert.equal(capturedBodies.length, 1); 658 const msgText = capturedBodies[0].messages[0].content[0].text; 659 assert.ok( 660 msgText.includes('Previous Conversation History'), 661 'should include conversation section' 662 ); 663 664 dbController.inboundRows = []; 665 resetAxiosToDefault(); 666 }); 667 668 it('uses null siteId — skips DB entirely and returns empty context', async () => { 669 resetAxiosToDefault(); 670 // siteId: null skips fetchCommunicationContext entirely 671 const result = await refreshScore(makeSiteData({ siteId: null })); 672 assert.ok(result, 'should return result'); 673 assert.ok(result.overall_calculation); 674 }); 675 676 it('handles htmlContent truncated to 15000 chars for HTML excerpt', async () => { 677 const longHtml = `<html><body>${'a'.repeat(20000)}</body></html>`; 678 resetAxiosToDefault(); 679 const result = await refreshScore(makeSiteData({ htmlContent: longHtml })); 680 assert.ok(result, 'should return result with long HTML'); 681 }); 682 });