@operstack/audit 0.35.1 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/package.json +5 -3
- package/src/client-eye.mjs +339 -0
- package/src/collect.mjs +94 -24
- package/src/etalon.mjs +1 -1
- package/src/fix.mjs +1 -1
- package/src/i18n.mjs +37 -23
- package/src/index.mjs +2 -0
- package/src/narrative.mjs +78 -31
- package/src/pagekind.mjs +6 -0
- package/src/presentation.mjs +95 -0
- package/src/prompts.mjs +58 -17
- package/src/render.mjs +41 -25
- package/src/rendered.mjs +8 -3
- package/src/tasks-pdf.mjs +1 -1
- package/src/verify.mjs +19 -12
- package/src/test-agency-ids.mjs +0 -48
- package/src/test-agency.mjs +0 -158
- package/src/test-agent-surface.mjs +0 -46
- package/src/test-blocked.mjs +0 -100
- package/src/test-crawl-links.mjs +0 -87
- package/src/test-demand.mjs +0 -243
- package/src/test-etalon.mjs +0 -42
- package/src/test-fix.mjs +0 -115
- package/src/test-flaky-files.mjs +0 -81
- package/src/test-foundation.mjs +0 -126
- package/src/test-handover.mjs +0 -148
- package/src/test-i18n.mjs +0 -68
- package/src/test-narrative.mjs +0 -117
- package/src/test-overall.mjs +0 -236
- package/src/test-planted.mjs +0 -114
- package/src/test-prompts.mjs +0 -142
- package/src/test-prospect-count.mjs +0 -12
- package/src/test-redirect-host.mjs +0 -63
- package/src/test-rendered.mjs +0 -125
- package/src/test-report-wording.mjs +0 -35
- package/src/test-ru-report.mjs +0 -95
- package/src/test-ru-sources.mjs +0 -41
- package/src/test-sample-fairness.mjs +0 -50
- package/src/test-sample.mjs +0 -91
- package/src/test-scores.mjs +0 -75
- package/src/test-shopify-store.mjs +0 -159
- package/src/test-sitemap.mjs +0 -91
- package/src/test-unmeasured.mjs +0 -173
package/src/test-rendered.mjs
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/** Слепота к скриптам: считаем честно, не измерив, так и говорим. */
|
|
3
|
-
import { chmodSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { compareReadings, jsBlindnessCheck, isBrowserErrorPage, renderedDom, removeProfile } from './rendered.mjs';
|
|
7
|
-
|
|
8
|
-
let bad = 0;
|
|
9
|
-
const is = (n, a, b) => { const ok = JSON.stringify(a) === JSON.stringify(b); if (!ok) { bad++; console.error(`FAIL ${n}\n ждали ${JSON.stringify(b)}\n вышло ${JSON.stringify(a)}`); } else console.log(`ok ${n}`); };
|
|
10
|
-
const ok = (n, c) => is(n, Boolean(c), true);
|
|
11
|
-
|
|
12
|
-
// Профиль Chrome: ENOTEMPTY на macOS ронял весь сбор (brainmd.com, 12.10.2026).
|
|
13
|
-
{
|
|
14
|
-
let opts = null;
|
|
15
|
-
is('удаление профиля с повторами', removeProfile('/tmp/x', (d, o) => { opts = o; }), true);
|
|
16
|
-
ok('повторы включены', opts && opts.maxRetries >= 3 && opts.recursive);
|
|
17
|
-
const fail = () => { const e = new Error('ENOTEMPTY: directory not empty'); e.code = 'ENOTEMPTY'; throw e; };
|
|
18
|
-
is('ENOTEMPTY не роняет сбор', removeProfile('/tmp/x', fail), false);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const text = (n) => '<p>' + 'слово '.repeat(n) + '</p>';
|
|
22
|
-
const body = (inner) => `<html><body>${inner}</body></html>`;
|
|
23
|
-
|
|
24
|
-
is('одинаковые чтения дают ноль скрытого', compareReadings(body(text(300)), body(text(300))).hiddenShare, 0);
|
|
25
|
-
is('и вердикт «в порядке»', compareReadings(body(text(300)), body(text(300))).verdict, 'ok');
|
|
26
|
-
|
|
27
|
-
const empty = compareReadings(body('<div id="root"></div>'), body(text(300)));
|
|
28
|
-
is('пустой каркас против полной страницы это сто процентов', empty.hiddenShare, 100);
|
|
29
|
-
is('и это приговор', empty.verdict, 'bad');
|
|
30
|
-
|
|
31
|
-
const half = compareReadings(body(text(100)), body(text(200)));
|
|
32
|
-
is('половина текста за скриптами считается верно', half.hiddenShare, 50);
|
|
33
|
-
is('и это тоже приговор', half.verdict, 'bad');
|
|
34
|
-
|
|
35
|
-
is('пять процентов за скриптами это ещё в порядке', compareReadings(body(text(285)), body(text(300))).verdict, 'ok');
|
|
36
|
-
const tenth = compareReadings(body(text(270)), body(text(300)));
|
|
37
|
-
is('десятая часть текста за скриптами это уже предупреждение', tenth.verdict, 'warn');
|
|
38
|
-
is('и доля посчитана', tenth.hiddenShare, 10);
|
|
39
|
-
|
|
40
|
-
// Меню и подвал не считаются: их и так никто не цитирует
|
|
41
|
-
const chrome = compareReadings(body('<nav>меню меню меню</nav>' + text(100)), body('<nav>меню меню меню другое</nav>' + text(100)));
|
|
42
|
-
is('разница только в меню не считается скрытым текстом', chrome.hiddenShare, 0);
|
|
43
|
-
|
|
44
|
-
// Браузер увидел меньше: это его беда, а не отрицательная доля
|
|
45
|
-
is('отрицательной доли не бывает', compareReadings(body(text(300)), body(text(100))).hiddenShare, 0);
|
|
46
|
-
|
|
47
|
-
// Строка проверки
|
|
48
|
-
const good = jsBlindnessCheck([{ url: 'https://x.ru/', raw: 300, rendered: 300, hiddenShare: 0, verdict: 'ok' }], 'ru');
|
|
49
|
-
is('чистый сайт получает «ok»', good.status, 'ok');
|
|
50
|
-
ok('и понятный текст', /читаются целиком/.test(good.comment));
|
|
51
|
-
|
|
52
|
-
const worst = jsBlindnessCheck([
|
|
53
|
-
{ url: 'https://x.ru/a', raw: 300, rendered: 300, hiddenShare: 0, verdict: 'ok' },
|
|
54
|
-
{ url: 'https://x.ru/b', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' },
|
|
55
|
-
], 'ru');
|
|
56
|
-
is('одна плохая страница делает проверку проваленной', worst.status, 'bad');
|
|
57
|
-
ok('в тексте названа худшая страница', worst.value.includes('https://x.ru/b'));
|
|
58
|
-
ok('и сказано, сколько слов видно без скриптов', /10 слов без скриптов против 400/.test(worst.value));
|
|
59
|
-
ok('приговор говорит про роботов ИИ', /достают страницу в ответ ИИ/.test(worst.comment));
|
|
60
|
-
|
|
61
|
-
const mild = jsBlindnessCheck([{ url: 'https://x.ru/', raw: 86, rendered: 100, hiddenShare: 14, verdict: 'warn' }], 'ru');
|
|
62
|
-
ok('на четырнадцати процентах не пишем «страница почти пустая»', !/почти пустая/.test(mild.comment));
|
|
63
|
-
ok('а пишем, что часть текста не доходит', /не доходит/.test(mild.comment));
|
|
64
|
-
|
|
65
|
-
const none = jsBlindnessCheck([], 'ru');
|
|
66
|
-
is('без браузера статус «не измеряли»', none.status, 'na');
|
|
67
|
-
ok('и сказано почему, без слов про «машину»', /не измеряли/.test(none.value) && !/машин/.test(none.value));
|
|
68
|
-
|
|
69
|
-
const en = jsBlindnessCheck([{ url: 'https://x.com/', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' }], 'en');
|
|
70
|
-
ok('английская версия без кириллицы', !/[А-Яа-яЁё]/.test(JSON.stringify(en)));
|
|
71
|
-
|
|
72
|
-
// ---- страница ошибки браузера это не сайт покупателя
|
|
73
|
-
//
|
|
74
|
-
// 14.09.2026 браузер не смог открыть сайт и напечатал свою страницу «не удалось подключиться»:
|
|
75
|
-
// сто восемьдесят килобайт интерфейса Chrome. Приняв её за «как выглядит со скриптами», мы бы
|
|
76
|
-
// написали покупателю в платном отчёте, что его сайт не читается без скриптов. Выдуманная
|
|
77
|
-
// находка по чужому сайту это худшее, что может быть в отчёте за деньги.
|
|
78
|
-
{
|
|
79
|
-
const errorPage = '<!DOCTYPE html><html dir="ltr" lang="ru"><head><style>/* Copyright 2017 The Chromium Authors */</style>'
|
|
80
|
-
+ '<script>function neterror(){}</script></head><body><div id="main-frame-error"><span>Не удалось открыть страницу</span></div></body></html>';
|
|
81
|
-
ok('страница ошибки браузера распознана', isBrowserErrorPage(errorPage));
|
|
82
|
-
ok('настоящая страница не принята за ошибку', !isBrowserErrorPage(body(text(300))));
|
|
83
|
-
ok('пустой ответ не принят за ошибку', !isBrowserErrorPage(''));
|
|
84
|
-
// Одного совпадения мало: слово neterror может встретиться в статье про ошибки браузера.
|
|
85
|
-
ok('одного слова недостаточно', !isBrowserErrorPage('<html><body><p>Статья про neterror и как его читать</p></body></html>'));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ---- браузер обязан отпускать, даже если сам не уходит
|
|
89
|
-
//
|
|
90
|
-
// Chrome печатает разметку и может не завершиться: его вспомогательные процессы держат канал
|
|
91
|
-
// вывода открытым. Отчёт по habr.com из-за этого стоял сорок минут при нулевой загрузке.
|
|
92
|
-
{
|
|
93
|
-
const dir = mkdtempSync(join(tmpdir(), 'operstack-fake-chrome-'));
|
|
94
|
-
const fake = (script) => {
|
|
95
|
-
const f = join(dir, `chrome-${Math.random().toString(36).slice(2)}.sh`);
|
|
96
|
-
writeFileSync(f, `#!/bin/sh\n${script}\n`);
|
|
97
|
-
chmodSync(f, 0o755);
|
|
98
|
-
return f;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
// Напечатал страницу целиком и завис. Ждать его незачем: всё уже у нас.
|
|
102
|
-
const printsThenHangs = fake(`printf '%s' '<html><body><p>текст страницы</p></body></html>'\nsleep 60`);
|
|
103
|
-
const t0 = Date.now();
|
|
104
|
-
const dom = await renderedDom('https://example.com/', { chrome: printsThenHangs, timeoutMs: 10000 });
|
|
105
|
-
const took = Date.now() - t0;
|
|
106
|
-
ok('разметка получена, хотя браузер не вышел', typeof dom === 'string' && dom.includes('текст страницы'));
|
|
107
|
-
ok(`и получена сразу, а не по сроку (${(took / 1000).toFixed(1)} с)`, took < 5000);
|
|
108
|
-
|
|
109
|
-
// Ничего не печатает и не уходит. Здесь обязан сработать срок, и вернуться «не измеряли».
|
|
110
|
-
const silentHang = fake('sleep 60');
|
|
111
|
-
const t1 = Date.now();
|
|
112
|
-
const none = await renderedDom('https://example.com/', { chrome: silentHang, timeoutMs: 2500 });
|
|
113
|
-
const waited = Date.now() - t1;
|
|
114
|
-
is('молчащий браузер это «не измеряли», а не выдумка', none, null);
|
|
115
|
-
ok(`и ожидание кончилось в срок (${(waited / 1000).toFixed(1)} с)`, waited >= 2000 && waited < 9000);
|
|
116
|
-
|
|
117
|
-
// Напечатал свою страницу ошибки: не мерим.
|
|
118
|
-
const printsError = fake(`printf '%s' '<html><head><style>/* Copyright The Chromium Authors */</style></head><body><div id="main-frame-error">нет связи</div></body></html>'`);
|
|
119
|
-
is('страница ошибки не идёт в измерение', await renderedDom('https://example.com/', { chrome: printsError, timeoutMs: 8000 }), null);
|
|
120
|
-
|
|
121
|
-
rmSync(dir, { recursive: true, force: true });
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
125
|
-
console.log('\nвсе тесты слепоты к скриптам прошли');
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
// 08.10.2026: отчёт за 800 ₽ не называется аудитом; задание про форму не противоречит строке «Сейчас».
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { labelsFor, toHtml } from './render.mjs';
|
|
4
|
-
import { agentPrompt } from './prompts.mjs';
|
|
5
|
-
|
|
6
|
-
let failed = 0;
|
|
7
|
-
const ok = (name, cond) => { console.log(`${cond ? 'ok ' : 'FAIL'} ${name}`); if (!cond) failed++; };
|
|
8
|
-
|
|
9
|
-
for (const tier of ['9', '29', 'watch', 'free']) {
|
|
10
|
-
const ru = labelsFor('ru', tier); const en = labelsFor('en', tier);
|
|
11
|
-
ok(`ступень ${tier}: русская обложка без слова «аудит»`, !/аудит/i.test(ru.coverTitle + ru.eyebrowAudit + ru.runningHead + ru.auditType + ru.docTitle));
|
|
12
|
-
ok(`ступень ${tier}: английская обложка без слова audit`, !/audit/i.test(en.coverTitle + en.eyebrowAudit + en.runningHead + en.auditType + en.docTitle));
|
|
13
|
-
ok(`ступень ${tier}: сноски без слова «аудит»`, !/аудит/i.test(ru.scorecardFoot + ru.sourcesFoot) && !/audit/i.test(en.scorecardFoot + en.sourcesFoot));
|
|
14
|
-
}
|
|
15
|
-
for (const tier of ['audit', '', undefined]) {
|
|
16
|
-
ok(`ступень «${tier}»: аудит остаётся аудитом`, /Аудит/.test(labelsFor('ru', tier).coverTitle) && /audit/.test(labelsFor('en', tier).coverTitle));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const sample = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url)));
|
|
20
|
-
const html9 = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru', tier: '9' } });
|
|
21
|
-
ok('PDF за 800 ₽: русский title и lang', /<html lang="ru">/.test(html9) && /: отчёт SEO, AEO и GEO<\/title>/.test(html9));
|
|
22
|
-
ok('PDF за 800 ₽: в шапках страниц «Отчёт», не «Аудит»', /Отчёт SEO, AEO и GEO ·/.test(html9) && !/Аудит SEO, AEO и GEO ·/.test(html9));
|
|
23
|
-
const htmlAudit = toHtml({ ...sample, meta: { ...sample.meta, lang: 'ru', tier: 'audit' } });
|
|
24
|
-
ok('аудит за 12 500 ₽: обложка прежняя', /Аудит сайта/.test(htmlAudit));
|
|
25
|
-
|
|
26
|
-
const noForm = agentPrompt({ id: 'conv-form-depth', label: 'На какой глубине лежит форма', value: 'на проверенных страницах формы нет', status: 'bad' }, { lang: 'ru' });
|
|
27
|
-
ok('формы нет: заголовок задачи «Форма заявки»', noForm.startsWith('### Форма заявки'));
|
|
28
|
-
ok('формы нет: задание не просит поднять форму', /нет формы заявки/.test(noForm) && !/Подними форму/.test(noForm));
|
|
29
|
-
const noFormEn = agentPrompt({ id: 'conv-form-depth', label: 'How far down the form sits', value: 'no form on the sampled pages', status: 'bad' }, { lang: 'en' });
|
|
30
|
-
ok('no form: English task asks first', /no enquiry form/.test(noFormEn) && !/Move the enquiry form higher/.test(noFormEn));
|
|
31
|
-
const deep = agentPrompt({ id: 'conv-form-depth', label: 'На какой глубине лежит форма', value: 'медиана 85% вниз по странице, измерено на 3 страницах', status: 'bad' }, { lang: 'ru' });
|
|
32
|
-
ok('форма есть, но низко: прежнее задание', /Подними форму/.test(deep));
|
|
33
|
-
|
|
34
|
-
if (failed) { console.log(`\n${failed} не сошлось`); process.exit(1); }
|
|
35
|
-
console.log('\nвсё сходится');
|
package/src/test-ru-report.mjs
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Русский сайт в русском отчёте: правовые страницы находятся по русским адресам, и в audit.json
|
|
4
|
-
* нет английских подписей.
|
|
5
|
-
*
|
|
6
|
-
* 30.09.2026 образец аудита агентства из Екатеринбурга (collect --lang ru) дал красное «ни политики
|
|
7
|
-
* конфиденциальности, ни условий на сайте нет» при ссылке href="politika-konfidenczialnosti/", а в
|
|
8
|
-
* плане работ, обзоре, источниках и ограничениях стояли «Week 1», «Fix: Privacy and terms»,
|
|
9
|
-
* «The site itself» и «Analytics: no traffic or behaviour data».
|
|
10
|
-
*/
|
|
11
|
-
import { createServer } from 'node:http';
|
|
12
|
-
import { mkdtemp, readFile } from 'node:fs/promises';
|
|
13
|
-
import { tmpdir } from 'node:os';
|
|
14
|
-
import path from 'node:path';
|
|
15
|
-
import { collect, policyKinds } from './collect.mjs';
|
|
16
|
-
import { draftNarrative } from './narrative.mjs';
|
|
17
|
-
import { render } from './render.mjs';
|
|
18
|
-
|
|
19
|
-
let bad = 0;
|
|
20
|
-
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
21
|
-
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
22
|
-
|
|
23
|
-
// ---- какие правовые страницы стоят за ссылкой
|
|
24
|
-
is('русская политика без косой черты в начале', policyKinds('politika-konfidenczialnosti/'), ['privacy']);
|
|
25
|
-
is('русская политика с косой чертой', policyKinds('/politika-konfidencialnosti/'), ['privacy']);
|
|
26
|
-
is('конфиденциальность отдельным словом', policyKinds('/konfidencialnost/'), ['privacy']);
|
|
27
|
-
is('согласие на обработку данных', policyKinds('/soglasie-na-obrabotku-personalnyh-dannyh/'), ['consent']);
|
|
28
|
-
is('пользовательское соглашение это условия, а не согласие', policyKinds('/polzovatelskoe-soglashenie/'), ['terms']);
|
|
29
|
-
is('условия и оферта', [policyKinds('usloviya-ispolzovaniya/'), policyKinds('/docs/oferta.pdf')], [['terms'], ['terms']]);
|
|
30
|
-
is('кириллица в адресе, в том числе закодированная', [policyKinds('/политика-конфиденциальности/'), policyKinds('/%D0%BE%D1%84%D0%B5%D1%80%D1%82%D0%B0/')], [['privacy'], ['terms']]);
|
|
31
|
-
is('полный адрес', policyKinds('https://dial93.ru/politika-konfidenczialnosti/'), ['privacy']);
|
|
32
|
-
is('английские адреса как раньше', [policyKinds('/privacy-policy'), policyKinds('/en/terms-of-use/'), policyKinds('/cookies'), policyKinds('/legal/'), policyKinds('/disclaimer')], [['privacy'], ['terms'], ['cookie'], ['legal'], ['disclaimer']]);
|
|
33
|
-
is('испанские акции не условия', policyKinds('/ofertas/'), []);
|
|
34
|
-
is('почта, телефон и якорь не страницы', [policyKinds('mailto:privacy@x.ru'), policyKinds('tel:+7'), policyKinds('#privacy')], [[], [], []]);
|
|
35
|
-
is('обычные страницы не правовые', [policyKinds('/kvartiry/'), policyKinds('/o-kompanii/'), policyKinds('/politics-news/')], [[], [], []]);
|
|
36
|
-
|
|
37
|
-
// ---- живой русский сайт, отчёт по-русски
|
|
38
|
-
const PAGES = ['/', '/kvartiry/', '/doma/', '/kontakty/'];
|
|
39
|
-
const page = (p) => `<!doctype html><html lang="ru"><head><title>Агентство недвижимости ${p}</title>
|
|
40
|
-
<meta name="description" content="Квартиры и дома в Екатеринбурге: подбор, проверка и сопровождение сделки под ключ.">
|
|
41
|
-
</head><body><main><h1>Недвижимость ${p}</h1>${'<p>Квартира в новом доме, 54 квадратных метра, цена 7,2 млн рублей, сдача в 2027 году.</p>'.repeat(12)}</main>
|
|
42
|
-
<footer><a href="politika-konfidenczialnosti/">Политика конфиденциальности</a> <a href="/soglasie-na-obrabotku/">Согласие на обработку</a></footer></body></html>`;
|
|
43
|
-
const server = createServer((req, res) => {
|
|
44
|
-
const send = (code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
|
|
45
|
-
const www = `http://${req.headers.host}`;
|
|
46
|
-
if (req.url === '/robots.txt') return send(200, 'text/plain', `User-agent: *\nAllow: /\nSitemap: ${www}/sitemap.xml\n`);
|
|
47
|
-
if (req.url === '/sitemap.xml') return send(200, 'application/xml', `<?xml version="1.0"?><urlset>${PAGES.map((p) => `<url><loc>${www}${p}</loc></url>`).join('')}</urlset>`);
|
|
48
|
-
if (PAGES.includes(req.url)) return send(200, 'text/html; charset=utf-8', page(req.url));
|
|
49
|
-
return send(404, 'text/plain', 'нет');
|
|
50
|
-
});
|
|
51
|
-
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
52
|
-
const base = `http://127.0.0.1:${server.address().port}`;
|
|
53
|
-
|
|
54
|
-
// Английские подписи, которые сборщик писал сам до 0.33.1, и общий признак английской фразы.
|
|
55
|
-
const ENGLISH = /\b(Week|Weeks|Months|Critical, quick wins|Foundation|Organic growth|Fix:|CMS \/ stack|Pages in sitemap|Language|What is sold|The site itself|Free, no account|read exactly as|sampled page|Search Console: no|Analytics: no|Keyword and backlink|external tier|Entity clarity|Third-party mentions|Reviews and PR|Contact form|Price visible|Messengers|Paid tool)\b/;
|
|
56
|
-
const strings = (o, at = '', out = []) => {
|
|
57
|
-
if (typeof o === 'string') out.push([at, o]);
|
|
58
|
-
else if (Array.isArray(o)) o.forEach((x, i) => strings(x, `${at}[${i}]`, out));
|
|
59
|
-
else if (o && typeof o === 'object') for (const k of Object.keys(o)) strings(o[k], at ? `${at}.${k}` : k, out);
|
|
60
|
-
return out;
|
|
61
|
-
};
|
|
62
|
-
// Что пишет сам сборщик. Проверки, выборка и сырые замеры несут адреса и значения со страниц.
|
|
63
|
-
const OWN = ['overview', 'critical', 'geo', 'conversion', 'sources', 'limitations', 'roadmap'];
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
const a = await collect(`${base}/`, { pages: 4, pageDelayMs: 0, lang: 'ru', log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
67
|
-
const pol = a.checks.find((c) => c.id === 'trust-policies') || {};
|
|
68
|
-
is('политика и согласие найдены, проверка зелёная', pol.status, 'ok');
|
|
69
|
-
ok(`и подписаны по-русски (${pol.value})`, /политика/.test(pol.value) && /согласие/.test(pol.value) && !/privacy|consent/.test(pol.value));
|
|
70
|
-
|
|
71
|
-
const leaks = strings(Object.fromEntries(OWN.map((k) => [k, a[k]]))).filter(([, s]) => ENGLISH.test(s) && !s.includes('{{'));
|
|
72
|
-
is('в русском audit.json нет английских подписей', leaks, []);
|
|
73
|
-
is('план работ', a.roadmap.map((p) => p.badge), ['Неделя 1', 'Недели 2-4', 'Месяцы 2-3']);
|
|
74
|
-
const badRu = a.checks.filter((c) => c.status === 'bad').map((c) => `Исправить: ${c.label}`);
|
|
75
|
-
ok(`пункты плана по русским названиям проверок (${a.roadmap[0].items[0]})`, badRu.length > 0 && JSON.stringify(a.roadmap[0].items.slice(0, -1)) === JSON.stringify(badRu));
|
|
76
|
-
ok('источник «Сам сайт» с числом страниц словами', a.sources[0].name === 'Сам сайт' && /и 4 страницы выборки/.test(a.sources[0].detail));
|
|
77
|
-
|
|
78
|
-
const d = draftNarrative(a, { lang: 'ru' });
|
|
79
|
-
const after = strings(Object.fromEntries(OWN.map((k) => [k, d[k]]))).filter(([, s]) => ENGLISH.test(s));
|
|
80
|
-
is('и после черновика тоже', after, []);
|
|
81
|
-
is('черновик нашёл русские строки обзора', d.overview.rows.map((r) => r[1].includes('{{')), [false, false, false, false]);
|
|
82
|
-
|
|
83
|
-
const dir = await mkdtemp(path.join(tmpdir(), 'operstack-ru-'));
|
|
84
|
-
const out = path.join(dir, 'ru.html');
|
|
85
|
-
await render(d, { out });
|
|
86
|
-
const text = (await readFile(out, 'utf8')).replace(/<style[\s\S]*?<\/style>|<script[\s\S]*?<\/script>/gi, ' ').replace(/<[^>]+>/g, ' ');
|
|
87
|
-
const m = text.match(ENGLISH);
|
|
88
|
-
ok(`в напечатанном отчёте тоже${m ? `: «${text.slice(Math.max(0, m.index - 60), m.index + 60).replace(/\s+/g, ' ')}»` : ''}`, !m);
|
|
89
|
-
|
|
90
|
-
const en = await collect(`${base}/`, { pages: 4, pageDelayMs: 0, lang: 'en', log: () => {}, visibility: { ok: false, error: 'skipped in test' } });
|
|
91
|
-
is('английский отчёт не изменился', [en.roadmap[0].badge, en.sources[0].name, en.overview.rows[1][0], en.limitations.length], ['Week 1', 'The site itself', 'Pages in sitemap', 3]);
|
|
92
|
-
} finally { server.close(); }
|
|
93
|
-
|
|
94
|
-
if (bad) { console.error(`\n${bad} FAIL`); process.exit(1); }
|
|
95
|
-
console.log('\nвсё сходится');
|
package/src/test-ru-sources.mjs
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Русский сайт, который честно называет источник цифры, получает за это балл.
|
|
4
|
-
*
|
|
5
|
-
* 16.09.2026 живой пользователь сказал прямо: «поиск источника цифр англоязычный, на русском
|
|
6
|
-
* сайте эти пять баллов недостижимы честно». Так и было: в движке платного аудита список слов
|
|
7
|
-
* состоял только из английских выражений, хотя в движке бесплатной проверки русские уже были.
|
|
8
|
-
* Два движка считали одно и то же по-разному.
|
|
9
|
-
*/
|
|
10
|
-
import { readFileSync } from 'node:fs';
|
|
11
|
-
import { resolve, dirname } from 'node:path';
|
|
12
|
-
import { fileURLToPath } from 'node:url';
|
|
13
|
-
|
|
14
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
const src = readFileSync(resolve(here, 'collect.mjs'), 'utf8');
|
|
16
|
-
const vis = readFileSync(resolve(here, 'visibility.mjs'), 'utf8');
|
|
17
|
-
|
|
18
|
-
let bad = 0;
|
|
19
|
-
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
20
|
-
|
|
21
|
-
const line = (text) => (text.match(/^const SOURCE_RE = .+$/m) || [''])[0];
|
|
22
|
-
ok('списки слов в обоих движках совпадают слово в слово', line(src) === line(vis) && line(src).length > 60);
|
|
23
|
-
|
|
24
|
-
const RE = new RegExp(line(src).replace(/^const SOURCE_RE = \//, '').replace(/\/giu;$/, ''), 'giu');
|
|
25
|
-
const count = (t) => (t.match(RE) || []).length;
|
|
26
|
-
|
|
27
|
-
for (const t of ['По данным Росстата, рынок вырос на 12%.',
|
|
28
|
-
'Источник: отчёт Банка России за 2025 год.',
|
|
29
|
-
'Согласно исследованию, доля выросла до 40%.',
|
|
30
|
-
'По информации Минфина, ставка снижена.',
|
|
31
|
-
'По сведениям Росреестра, сделок стало больше.'])
|
|
32
|
-
ok(`русский источник виден: ${t.slice(0, 34)}…`, count(t) > 0);
|
|
33
|
-
|
|
34
|
-
for (const t of ['According to Rosstat, the market grew 12%.', 'Source: Bank of Russia report.'])
|
|
35
|
-
ok(`английский по-прежнему виден: ${t.slice(0, 30)}…`, count(t) > 0);
|
|
36
|
-
|
|
37
|
-
for (const t of ['Мы выросли на 12% за год.', 'Наша команда работает с 2019 года.', 'Согласнее всех был директор.'])
|
|
38
|
-
ok(`пустая похвальба источником не считается: ${t.slice(0, 28)}…`, count(t) === 0);
|
|
39
|
-
|
|
40
|
-
console.log(bad ? `\nпровалов: ${bad}` : '\nвсё сходится');
|
|
41
|
-
process.exit(bad ? 1 : 0);
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Мы меряем то, что человек станет чинить, и говорим ему, что именно измерили.
|
|
4
|
-
*
|
|
5
|
-
* 16.09.2026 живой пользователь прошёл путь с 63 до 95 и назвал два узких места подряд:
|
|
6
|
-
* в выборку попали расшифровки на семь тысяч слов, а таблицу, добавленную на главную, проверка
|
|
7
|
-
* не увидела. Оба оказались правдой. Отбор шёл сортировкой по длине адреса по убыванию, то есть
|
|
8
|
-
* наверх поднимались самые глубокие страницы, а главная в оценку содержания не входила вовсе.
|
|
9
|
-
*/
|
|
10
|
-
import { readFileSync } from 'node:fs';
|
|
11
|
-
import { resolve, dirname } from 'node:path';
|
|
12
|
-
import { fileURLToPath } from 'node:url';
|
|
13
|
-
|
|
14
|
-
const src = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), 'visibility.mjs'), 'utf8');
|
|
15
|
-
|
|
16
|
-
let bad = 0;
|
|
17
|
-
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
18
|
-
|
|
19
|
-
ok('главная входит в оценку содержания наравне с остальными',
|
|
20
|
-
/const contentPages = \[homePage, \.\.\.sampled\]/.test(src));
|
|
21
|
-
ok('сортировки по длине адреса по убыванию больше нет',
|
|
22
|
-
!/sort\(\(a, b\) => b\.length - a\.length\)/.test(src));
|
|
23
|
-
ok('страницы отбираются по близости к корню', /depth\(a\) - depth\(b\)/.test(src));
|
|
24
|
-
ok('и по одной из каждого раздела', /usedSections/.test(src));
|
|
25
|
-
ok('отчёт называет измеренные страницы', /measured: \(list\)/.test(src) && /T\.measured\(/.test(src));
|
|
26
|
-
ok('находка про абзац-ответ называет страницы', /answerFirstMissing: \(n, total, where\)/.test(src));
|
|
27
|
-
|
|
28
|
-
// Отбор должен быть строго определённым: два прогона по одному сайту берут одни и те же страницы,
|
|
29
|
-
// иначе балл гуляет и человек не понимает, что изменилось от его правки.
|
|
30
|
-
const depth = (u) => new URL(u).pathname.replace(/\/$/, '').split('/').filter(Boolean).length;
|
|
31
|
-
const section = (u) => new URL(u).pathname.split('/').filter(Boolean)[0] || '';
|
|
32
|
-
const pick = (pages, want) => {
|
|
33
|
-
const byDepth = [...pages].sort((a, b) => depth(a) - depth(b) || a.length - b.length || (a < b ? -1 : 1));
|
|
34
|
-
const out = []; const used = new Set();
|
|
35
|
-
for (const p of byDepth) { if (out.length >= want) break; const s = section(p); if (used.has(s)) continue; used.add(s); out.push(p); }
|
|
36
|
-
for (const p of byDepth) { if (out.length >= want) break; if (!out.includes(p)) out.push(p); }
|
|
37
|
-
return out;
|
|
38
|
-
};
|
|
39
|
-
const site = [
|
|
40
|
-
'https://x.ru/blog/rasshifrovka-vypuska-nomer-sorok-dva-pro-vsyo-na-svete/',
|
|
41
|
-
'https://x.ru/blog/rasshifrovka-vypuska-nomer-sorok-tri-pro-vsyo-na-svete/',
|
|
42
|
-
'https://x.ru/uslugi/', 'https://x.ru/ceny/', 'https://x.ru/o-nas/', 'https://x.ru/blog/',
|
|
43
|
-
];
|
|
44
|
-
const got = pick(site, 3);
|
|
45
|
-
ok('расшифровки больше не вытесняют витрину', !got.some((u) => u.includes('rasshifrovka')));
|
|
46
|
-
ok('взяты разные разделы', new Set(got.map(section)).size === got.length);
|
|
47
|
-
ok('отбор повторяем', JSON.stringify(pick(site, 3)) === JSON.stringify(got));
|
|
48
|
-
|
|
49
|
-
console.log(bad ? `\nпровалов: ${bad}` : '\nвсё сходится');
|
|
50
|
-
process.exit(bad ? 1 : 0);
|
package/src/test-sample.mjs
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Прочитать меньше не должно выглядеть как «стало лучше».
|
|
4
|
-
*
|
|
5
|
-
* 15.09.2026 один и тот же сайт в шести прогонах подряд дал 46, 54, 59 и 69 баллов. Выше он
|
|
6
|
-
* выходил там, где из выборки успела ответить одна страница вместо двух: области «есть ли что
|
|
7
|
-
* процитировать» и «дата и доверие» считались по одной главной, а она у большинства сайтов
|
|
8
|
-
* лучше внутренних страниц. То есть медленная сеть выглядела как хороший сайт, и покупатель,
|
|
9
|
-
* запустивший проверку дважды, видел два разных числа.
|
|
10
|
-
*
|
|
11
|
-
* Теперь: страниц хватало, а ответили не все, значит эти две области не измерены. Балл считается
|
|
12
|
-
* по остальным и переносится на сто, как и при нечитаемом robots.txt. Здесь это держится на
|
|
13
|
-
* живом сервере: одна страница выборки отвечает сразу, вторая висит дольше отпущенного времени.
|
|
14
|
-
*/
|
|
15
|
-
import { createServer } from 'node:http';
|
|
16
|
-
import { lookup } from 'node:dns/promises';
|
|
17
|
-
import { checkVisibility } from './visibility.mjs';
|
|
18
|
-
|
|
19
|
-
let bad = 0;
|
|
20
|
-
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
21
|
-
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
22
|
-
|
|
23
|
-
const page = (title, extra = '') => `<!doctype html><html lang="ru"><head><title>${title}</title>
|
|
24
|
-
<meta name="description" content="Описание страницы для проверки, достаточно длинное чтобы не считаться пустым.">
|
|
25
|
-
<link rel="canonical" href="http://example.com/"></head><body><main><h1>${title}</h1>
|
|
26
|
-
${'<p>Обычный абзац текста про товар, услугу и цену без источника.</p>'.repeat(12)}${extra}</main></body></html>`;
|
|
27
|
-
|
|
28
|
-
/** Сервер: главная и две страницы в карте сайта, вторая из них отвечает медленнее отпущенного. */
|
|
29
|
-
const serve = ({ slowPages = 0, slowMs = 9000 } = {}) => new Promise((resolve) => {
|
|
30
|
-
const s = createServer((req, res) => {
|
|
31
|
-
const send = (code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
|
|
32
|
-
if (req.url.startsWith('/robots.txt')) return send(200, 'text/plain', `User-agent: *\nAllow: /\nSitemap: http://lvh.me:${s.address().port}/sitemap.xml\n`);
|
|
33
|
-
if (req.url.startsWith('/llms.txt')) return send(404, 'text/plain', 'no');
|
|
34
|
-
if (req.url.startsWith('/sitemap.xml')) {
|
|
35
|
-
const base = `http://lvh.me:${s.address().port}`;
|
|
36
|
-
// Восемь внутренних страниц: движок сортирует их по длине адреса и берёт каждую четвёртую,
|
|
37
|
-
// поэтому выборка из двух складывается только на сайте такого размера.
|
|
38
|
-
const urls = ['/', ...Array.from({ length: 8 }, (_, i) => `/stranica-nomer-${i}${'-x'.repeat(8 - i)}/`)];
|
|
39
|
-
return send(200, 'application/xml', `<?xml version="1.0"?><urlset>${urls.map((u) => `<url><loc>${base}${u}</loc></url>`).join('')}</urlset>`);
|
|
40
|
-
}
|
|
41
|
-
if (req.url === '/') return send(200, 'text/html; charset=utf-8', page('Главная'));
|
|
42
|
-
if (req.url.startsWith('/stranica-')) {
|
|
43
|
-
if (slowPages > 0) { setTimeout(() => send(200, 'text/html; charset=utf-8', page('Медленная')), slowMs); return; }
|
|
44
|
-
return send(200, 'text/html; charset=utf-8', page('Внутренняя'));
|
|
45
|
-
}
|
|
46
|
-
return send(404, 'text/plain', 'no');
|
|
47
|
-
});
|
|
48
|
-
s.listen(0, '127.0.0.1', () => resolve({ s, url: `http://lvh.me:${s.address().port}/` }));
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
let dnsOk = true;
|
|
52
|
-
try { const a = await lookup('lvh.me'); dnsOk = a.address === '127.0.0.1'; } catch { dnsOk = false; }
|
|
53
|
-
if (!dnsOk) { console.log('ok пропуск: lvh.me не указывает на 127.0.0.1 (нет сети)'); process.exit(0); }
|
|
54
|
-
|
|
55
|
-
const area = (v, id) => (v.areas || []).find((a) => a.id === id);
|
|
56
|
-
|
|
57
|
-
// ---- все страницы выборки ответили: области считаются
|
|
58
|
-
{
|
|
59
|
-
const { s, url } = await serve();
|
|
60
|
-
const v = await checkVisibility(url, { lang: 'ru', samplePages: 2, budgetMs: 8000 });
|
|
61
|
-
ok('сайт прочитан', v.ok);
|
|
62
|
-
ok('обе области по страницам измерены', area(v, 'content').measured !== false && area(v, 'trust').measured !== false);
|
|
63
|
-
ok('балл посчитан из всех пяти областей', typeof v.score === 'number' && v.score > 0);
|
|
64
|
-
s.close();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ---- страницы выборки не отвечают даже со второй попытки: балла нет вовсе
|
|
68
|
-
{
|
|
69
|
-
const { s, url } = await serve({ slowPages: 1 });
|
|
70
|
-
const v = await checkVisibility(url, { lang: 'ru', samplePages: 2, budgetMs: 6000 });
|
|
71
|
-
is('балл не выдан', v.ok, false);
|
|
72
|
-
ok('сказано, сколько страниц ответило', /Из \d+ страниц ответили \d+/.test(v.error));
|
|
73
|
-
ok('сказано, что число не показываем и почему', /защитить нельзя/.test(v.error));
|
|
74
|
-
ok('предложено повторить', /через минуту/.test(v.error));
|
|
75
|
-
ok('это не выдаётся за блокировку', v.blocked === false && v.challenged === false);
|
|
76
|
-
ok('в сообщении нет null и undefined', !/null|undefined|NaN/.test(v.error));
|
|
77
|
-
const en = await checkVisibility(url, { lang: 'en', samplePages: 2, budgetMs: 6000 });
|
|
78
|
-
ok('по-английски то же самое', en.ok === false && /not enough of the site to score/.test(en.error) && !/[А-Яа-яЁё]/.test(en.error));
|
|
79
|
-
s.close();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ---- все страницы отвечают быстро: областям есть на чём считаться
|
|
83
|
-
{
|
|
84
|
-
const { s, url } = await serve();
|
|
85
|
-
const v = await checkVisibility(url, { lang: 'ru', samplePages: 2, budgetMs: 8000 });
|
|
86
|
-
ok('быстрый сайт измерен целиком', area(v, 'content').measured !== false && area(v, 'trust').measured !== false);
|
|
87
|
-
s.close();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
91
|
-
console.log('\nвыборка: прочитать меньше больше не выглядит как «стало лучше»');
|
package/src/test-scores.mjs
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rule this file exists to defend: no number in a report is typed by a human.
|
|
3
|
-
* Every score is computed from the collected checks, and an area with no checks is
|
|
4
|
-
* printed as "not measured" rather than scored on an impression.
|
|
5
|
-
*/
|
|
6
|
-
import { computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
|
|
7
|
-
import { toHtml, checkNarrative } from './render.mjs';
|
|
8
|
-
|
|
9
|
-
let failed = 0;
|
|
10
|
-
const is = (name, actual, expected) => {
|
|
11
|
-
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
|
12
|
-
if (!ok) { failed++; console.error(`FAIL ${name}\n expected ${JSON.stringify(expected)}\n actual ${JSON.stringify(actual)}`); }
|
|
13
|
-
else console.log(`ok ${name}`);
|
|
14
|
-
};
|
|
15
|
-
const ok = (name, cond) => is(name, Boolean(cond), true);
|
|
16
|
-
|
|
17
|
-
const checks = [
|
|
18
|
-
{ group: 'technical', status: 'ok' }, { group: 'technical', status: 'ok' },
|
|
19
|
-
{ group: 'technical', status: 'warn' }, { group: 'technical', status: 'na' },
|
|
20
|
-
{ group: 'aeo', status: 'bad' }, { group: 'aeo', status: 'ok' },
|
|
21
|
-
];
|
|
22
|
-
const { scores, scoreBasis } = computeScores(checks);
|
|
23
|
-
|
|
24
|
-
is('ok 1, warn 0.5, bad 0, na ignored', scores['SEO, technical'], 8);
|
|
25
|
-
is('an area with half its checks failing scores 5', scores['AEO, answers and snippets'], 5);
|
|
26
|
-
is('na is not counted', scoreBasis['SEO, technical'].counted, 3);
|
|
27
|
-
is('an area with no checks is not measured', scores['Off-page and trust'], null);
|
|
28
|
-
is('and says why', scoreBasis['Off-page and trust'].note, 'not measured: this audit collected no check in this area');
|
|
29
|
-
is('conversion is not measured either', scores['Conversion and UX'], null);
|
|
30
|
-
is('every area is accounted for', Object.keys(scores).length, SCORE_AREAS.length);
|
|
31
|
-
is('the basis is printable evidence', scoreBasis['AEO, answers and snippets'].note, '1 of 2 checks pass, 1 fails');
|
|
32
|
-
|
|
33
|
-
// an area is scored as soon as its checks are collected, and only then
|
|
34
|
-
const withOffpage = computeScores([
|
|
35
|
-
...checks,
|
|
36
|
-
{ group: 'offpage', status: 'ok' }, { group: 'offpage', status: 'warn' }, { group: 'offpage', status: 'na' },
|
|
37
|
-
{ group: 'conversion', status: 'bad' }, { group: 'conversion', status: 'ok' },
|
|
38
|
-
]);
|
|
39
|
-
is('off-page is scored from its own checks', withOffpage.scores['Off-page and trust'], 8);
|
|
40
|
-
is('and the na row is not counted', withOffpage.scoreBasis['Off-page and trust'].counted, 2);
|
|
41
|
-
is('conversion is scored from its own checks', withOffpage.scores['Conversion and UX'], 5);
|
|
42
|
-
is('an area with checks is never "not measured"', withOffpage.scoreBasis['Conversion and UX'].note, '1 of 2 checks pass, 1 fails');
|
|
43
|
-
|
|
44
|
-
const typed = { checks, scores: { 'SEO, technical': 2, 'Off-page and trust': 6, 'Made-up area': 9 } };
|
|
45
|
-
const problems = verifyScores(typed);
|
|
46
|
-
ok('a typed score is caught', problems.some((p) => p.startsWith('SEO, technical:')));
|
|
47
|
-
ok('a score for an unmeasured area is caught', problems.some((p) => p.startsWith('Off-page and trust:')));
|
|
48
|
-
ok('an invented area is caught', problems.some((p) => p.includes('not one of the six audit areas')));
|
|
49
|
-
is('a report that matches its checks has no problems', verifyScores({ checks, scores }), []);
|
|
50
|
-
|
|
51
|
-
const audit = {
|
|
52
|
-
meta: { host: 'example.com', collectedAt: '2026-09-11T00:00:00.000Z', tool: 'test', auditType: 'test' },
|
|
53
|
-
client: { name: 'Example', subject: 'test', reportDate: '2026-09-11' },
|
|
54
|
-
scores: { 'SEO, technical': 2, 'Off-page and trust': 2 },
|
|
55
|
-
checks,
|
|
56
|
-
summary: { lead: 'x', verdict: 'x', priorities: ['x'] },
|
|
57
|
-
overview: { rows: [['a', 'b']], note: 'x' },
|
|
58
|
-
critical: [{ title: 'An issue with no text', cost: 'what it costs', fix: 'how to fix it' }, { title: 'A bare headline' }],
|
|
59
|
-
content: { strengths: ['x'], weaknesses: ['x'], gaps: ['x'] },
|
|
60
|
-
aeo: { works: ['x'], blocks: ['x'], recommendation: 'x' },
|
|
61
|
-
geo: { rows: [['a', 'b', 'c']], callout: 'x' },
|
|
62
|
-
offpage: { listed: ['x'], note: 'x' },
|
|
63
|
-
conversion: { rows: [['a', 'b']] },
|
|
64
|
-
limitations: ['x'], roadmap: [{ badge: 'w', title: 't', items: ['x'] }], closing: 'x', sample: [],
|
|
65
|
-
};
|
|
66
|
-
const html = toHtml(audit);
|
|
67
|
-
ok('the report prints the computed score, not the typed one', html.includes('8/10'));
|
|
68
|
-
ok('the typed 2/10 never reaches the page', !html.includes('>2/10<'));
|
|
69
|
-
ok('an unmeasured area says so', html.includes('not measured'));
|
|
70
|
-
ok('the scorecard shows the count it came from', html.includes('2 of 3 checks pass'));
|
|
71
|
-
ok('a critical issue prints what it costs and how to fix it', html.includes('What it costs') && html.includes('how to fix it'));
|
|
72
|
-
ok('a critical issue with no body is reported', checkNarrative(audit).some((m) => m.startsWith('critical[1].text')));
|
|
73
|
-
|
|
74
|
-
if (failed) { console.error(`\n${failed} test(s) failed`); process.exit(1); }
|
|
75
|
-
console.log('\nall scoring tests pass');
|