@operstack/audit 0.17.1 → 0.18.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/package.json +1 -1
- package/src/collect.mjs +7 -2
- package/src/index.mjs +1 -1
- package/src/render.mjs +61 -10
- package/src/test-overall.mjs +76 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/collect.mjs
CHANGED
|
@@ -500,9 +500,14 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
500
500
|
if (visibility) log('visibility score: taken from the check the visitor already ran');
|
|
501
501
|
else { log('visibility score'); visibility = await checkVisibility(startUrl, { ...VISIBILITY_DEFAULTS, lang }); }
|
|
502
502
|
const measuredAt = visibility && visibility.checkedAt ? visibility.checkedAt : null;
|
|
503
|
+
/* Из чего сложился балл, словами для отчёта: сколько страниц прочитано и дочиталась ли карта
|
|
504
|
+
* сайта. Без этого подпись под баллом обещает точность, которой у восьмисекундной проверки нет. */
|
|
505
|
+
const basis = visibility && visibility.ok
|
|
506
|
+
? { pages: (visibility.sample || []).length, sitemapRead: Boolean(visibility.sitemap && visibility.sitemap.found), sitemapUnchecked: Boolean(visibility.sitemap && visibility.sitemap.unchecked) }
|
|
507
|
+
: null;
|
|
503
508
|
const overall = visibility && visibility.ok
|
|
504
|
-
? { score: visibility.score, grade: visibility.grade, areas: visibility.areas, source: givenVisibility ? 'visibility:reused' : 'visibility', measuredAt, ms: visibility.ms }
|
|
505
|
-
: { score: null, grade: 'not measured', areas: [], source: givenVisibility ? 'visibility:reused' : 'visibility', error: (visibility && visibility.error) || 'no visibility result' };
|
|
509
|
+
? { score: visibility.score, grade: visibility.grade, areas: visibility.areas, basis, source: givenVisibility ? 'visibility:reused' : 'visibility', measuredAt, ms: visibility.ms }
|
|
510
|
+
: { score: null, grade: 'not measured', areas: [], basis: null, source: givenVisibility ? 'visibility:reused' : 'visibility', error: (visibility && visibility.error) || 'no visibility result' };
|
|
506
511
|
// Шесть областей отчёта остаются, но это другое измерение, а не разбивка общего балла.
|
|
507
512
|
const reportScore = computeOverall(checks);
|
|
508
513
|
|
package/src/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { collect, computeScores, computeOverall, verifyScores, SCORE_AREAS } from './collect.mjs';
|
|
2
2
|
// Единственная копия движка видимости. Сайты и актор зовут его отсюда, своих копий больше нет.
|
|
3
3
|
export { checkVisibility, normaliseInput, parseRobots, agentVerdict, MESSAGES as VISIBILITY_MESSAGES, VISIBILITY_DEFAULTS } from './visibility.mjs';
|
|
4
|
-
export { render, checkNarrative } from './render.mjs';
|
|
4
|
+
export { render, checkNarrative, overallSummary } from './render.mjs';
|
|
5
5
|
export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18n.mjs';
|
|
6
6
|
export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
|
|
7
7
|
export { buildFoundationScope, renderFoundationScope, renderFoundationChecklist } from './foundation.mjs';
|
package/src/render.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { computeScores, computeOverall } from './collect.mjs';
|
|
11
11
|
import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
|
|
12
|
+
import { MESSAGES as VISIBILITY_MESSAGES } from './visibility.mjs';
|
|
12
13
|
import { softenHex } from './agency.mjs';
|
|
13
14
|
|
|
14
15
|
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
@@ -29,7 +30,15 @@ const LABELS = {
|
|
|
29
30
|
grades: { A: 'Strong', B: 'Workable', C: 'Weak', D: 'Poor', E: 'Critical' },
|
|
30
31
|
// Заголовок это то же измерение, что на странице проверки. Подпись обязана это говорить,
|
|
31
32
|
// иначе читатель снова начнёт сводить его с шестью областями ниже.
|
|
32
|
-
|
|
33
|
+
// Подпись обязана сказать три вещи: откуда число, что именно успели прочитать, и почему
|
|
34
|
+
// повтор может дать на пункт-другой иначе. Без последнего человек читает разницу как ошибку.
|
|
35
|
+
overallNote: (o) => {
|
|
36
|
+
const read = o.basis ? `It was read from ${o.basis.pages} page${o.basis.pages === 1 ? '' : 's'} of the site${o.basis.sitemapUnchecked ? ', and the sitemap did not answer in time, which is not counted against the score either way' : o.basis.sitemapRead ? ' and its sitemap' : ''}. ` : '';
|
|
37
|
+
const where = o.source === 'visibility:reused'
|
|
38
|
+
? `This is the same figure you already saw on the free check${o.measuredAt ? ` on ${String(o.measuredAt).slice(0, 10)}` : ''}. It is not measured again here, so the page, the email and this report always carry one number. `
|
|
39
|
+
: 'Measured here by the same code and the same settings as the free check at oper-stack.com/ai-visibility. ';
|
|
40
|
+
return `${where}${read}The five areas below add up to it exactly, so you can recompute it by hand. Running the check again can move it by a point or two: a check that answers in time on one run may not on the next, and this score never counts against a site what it could not read.`;
|
|
41
|
+
},
|
|
33
42
|
secondMeasure: 'A second, separate measurement: how this report scores its own checks',
|
|
34
43
|
secOverview: 'Site overview', overviewEyebrow: '02 · Overview', whatSiteIs: 'What the site is', parameter: 'Parameter', value: 'Value', pagesSampled: 'Pages sampled', colUrl: 'URL', colTitle: 'Title', colWords: 'Words',
|
|
35
44
|
secCritical: 'Critical issues', criticalEyebrow: '03 · P0', criticalTitle: 'Critical issues, fix first', whatItCosts: 'What it costs', theFix: 'Fix', noBody: 'This issue has no description in the audit file.', noCritical: 'No critical defects were found in the public signals.',
|
|
@@ -56,7 +65,15 @@ const LABELS = {
|
|
|
56
65
|
notMeasured: 'не измерялось',
|
|
57
66
|
overallLabel: 'Балл видимости в ИИ',
|
|
58
67
|
grades: { A: 'Сильно', B: 'Рабочее состояние', C: 'Слабо', D: 'Плохо', E: 'Критично' },
|
|
59
|
-
overallNote:
|
|
68
|
+
overallNote: (o) => {
|
|
69
|
+
const p = o.basis ? o.basis.pages : 0;
|
|
70
|
+
const pl = ruPages(p);
|
|
71
|
+
const read = o.basis ? `Прочитано ${p} ${pl} сайта${o.basis.sitemapUnchecked ? ', а карта сайта не ответила вовремя, и это не зачтено ни в плюс, ни в минус' : o.basis.sitemapRead ? ' и его карта' : ''}. ` : '';
|
|
72
|
+
const where = o.source === 'visibility:reused'
|
|
73
|
+
? `Это ровно то число, которое вы уже видели в бесплатной проверке${o.measuredAt ? ` ${String(o.measuredAt).slice(0, 10)}` : ''}. Здесь оно не меряется заново, поэтому страница, письмо и этот отчёт всегда несут одно число. `
|
|
74
|
+
: 'Измерено здесь тем же кодом и с теми же настройками, что у бесплатной проверки на oper-stack.com/ai-visibility. ';
|
|
75
|
+
return `${where}${read}Пять областей ниже дают в сумме ровно этот балл, его можно сложить руками. Повторная проверка может дать на пункт-другой иначе: проверка, которая успела ответить в один прогон, может не успеть в следующий, а балл никогда не засчитывает сайту в минус то, что не удалось прочитать.`;
|
|
76
|
+
},
|
|
60
77
|
secondMeasure: 'Второе, отдельное измерение: как этот отчёт оценивает собственные проверки',
|
|
61
78
|
secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
|
|
62
79
|
secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
|
|
@@ -78,11 +95,46 @@ const LABELS = {
|
|
|
78
95
|
const STATUS_KEYS = { ok: 'statusOk', warn: 'statusWarn', bad: 'statusBad', na: 'statusNa' };
|
|
79
96
|
const STATUS_CLASS = { ok: 'status-ok', warn: 'status-warn', bad: 'status-bad', na: 'status-na' };
|
|
80
97
|
const STATUS_UNUSED = { ok: ['✓ OK', 'status-ok'], warn: ['△ Partial', 'status-warn'], bad: ['✗ Problem', 'status-bad'], na: ['· Note', 'status-na'] };
|
|
98
|
+
/** Склонение «страница»: 1 страница, 4 страницы, 5 страниц. Подпись под баллом читает человек. */
|
|
99
|
+
const ruPages = (n) => { const a = Math.abs(n) % 100; const b = a % 10; if (a > 10 && a < 20) return 'страниц'; if (b === 1) return 'страница'; if (b >= 2 && b <= 4) return 'страницы'; return 'страниц'; };
|
|
100
|
+
|
|
81
101
|
/** Склонение «балл» для русской подписи под общим баллом: 21 балл, 72 балла, 75 баллов. */
|
|
82
102
|
const ruPoints = (n) => { const a = Math.abs(n) % 100; const b = a % 10; if (a > 10 && a < 20) return 'баллов'; if (b === 1) return 'балл'; if (b >= 2 && b <= 4) return 'балла'; return 'баллов'; };
|
|
83
103
|
|
|
84
104
|
const scoreClass = (n) => (n === null || n === undefined ? '' : n <= 3 ? 'low' : n <= 6 ? 'mid' : 'ok');
|
|
85
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Балл видимости и всё, что про него надо сказать словами, одним объектом.
|
|
108
|
+
*
|
|
109
|
+
* Простым языком: это тот самый блок, который стоит на первой странице отчёта. Письмо печатает
|
|
110
|
+
* его же. Раньше письмо собирало свою формулировку и свою цифру, и человек получал на странице
|
|
111
|
+
* одно, в письме другое, а в PDF третье. Теперь текст ровно один и живёт здесь.
|
|
112
|
+
*
|
|
113
|
+
* Названия областей берутся по `id`, а не по готовой подписи из замера: замер мог пройти на
|
|
114
|
+
* английской странице, а отчёт уходить русскому покупателю, и тогда подпись пришла бы на чужом
|
|
115
|
+
* языке. Язык отчёта главнее языка замера.
|
|
116
|
+
*
|
|
117
|
+
* Возвращает null, если балла нет: движок отказывается мерить закрытые и приватные адреса, и в
|
|
118
|
+
* этом случае честнее не называть цифру вовсе, чем подставить ноль.
|
|
119
|
+
*/
|
|
120
|
+
export function overallSummary(overall, { lang = 'en' } = {}) {
|
|
121
|
+
if (!overall || overall.score === null || overall.score === undefined) return null;
|
|
122
|
+
const key = lang === 'ru' ? 'ru' : 'en';
|
|
123
|
+
const L = LABELS[key];
|
|
124
|
+
const names = VISIBILITY_MESSAGES[key].area;
|
|
125
|
+
return {
|
|
126
|
+
label: L.overallLabel,
|
|
127
|
+
score: overall.score,
|
|
128
|
+
grade: (L.grades && L.grades[overall.grade]) || overall.grade,
|
|
129
|
+
note: L.overallNote(overall),
|
|
130
|
+
areas: (Array.isArray(overall.areas) ? overall.areas : []).map((x) => ({
|
|
131
|
+
id: x.id, label: (x.id && names[x.id]) || x.label, score: x.score, max: x.max,
|
|
132
|
+
})),
|
|
133
|
+
secondMeasure: L.secondMeasure,
|
|
134
|
+
secondMeasureFoot: L.scorecardFoot.replace(/<\/?strong>/g, ''),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
86
138
|
export function checkNarrative(audit) {
|
|
87
139
|
const out = [];
|
|
88
140
|
const walk = (v, path) => {
|
|
@@ -192,14 +244,13 @@ export function toHtml(audit, brand = null) {
|
|
|
192
244
|
* независимое измерение по проверкам самого отчёта, а не разбивка заголовка. Сводить их с
|
|
193
245
|
* заголовком не нужно и не получится, и об этом сказано словами.
|
|
194
246
|
*/
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
<div class="overall-
|
|
199
|
-
|
|
200
|
-
<div class="overall-
|
|
201
|
-
|
|
202
|
-
+ (overallAreas.length ? `<table class="areas"><tr>${overallAreas.map((x) => `<th>${esc(x.label)}</th>`).join('')}</tr><tr>${overallAreas.map((x) => `<td>${x.score}<span>/${x.max}</span></td>`).join('')}</tr></table>` : '');
|
|
247
|
+
const head = overallSummary(a.overall, { lang: a.meta?.lang === 'ru' ? 'ru' : 'en' });
|
|
248
|
+
const overallBlock = !head ? '' : `<div class="overall">
|
|
249
|
+
<div class="overall-num ${scoreClass(Math.round(head.score / 10))}">${head.score}<span>/100</span></div>
|
|
250
|
+
<div class="overall-side"><div class="overall-label">${esc(head.label)}</div>
|
|
251
|
+
<div class="overall-grade">${esc(head.grade)}</div>
|
|
252
|
+
<div class="overall-note">${esc(head.note)}</div></div></div>`
|
|
253
|
+
+ (head.areas.length ? `<table class="areas"><tr>${head.areas.map((x) => `<th>${esc(x.label)}</th>`).join('')}</tr><tr>${head.areas.map((x) => `<td>${x.score}<span>/${x.max}</span></td>`).join('')}</tr></table>` : '');
|
|
203
254
|
const scoreCards = Object.entries(measured).map(([label, n]) => {
|
|
204
255
|
const basis = scoreBasis[label] || {};
|
|
205
256
|
const shown = (L.areas && L.areas[label]) || label;
|
package/src/test-overall.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs';
|
|
|
12
12
|
import { createServer } from 'node:http';
|
|
13
13
|
import { collect, computeOverall, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
|
|
14
14
|
import { checkVisibility, VISIBILITY_DEFAULTS } from './visibility.mjs';
|
|
15
|
-
import { toHtml } from './render.mjs';
|
|
15
|
+
import { toHtml, overallSummary } from './render.mjs';
|
|
16
16
|
|
|
17
17
|
let bad = 0;
|
|
18
18
|
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
@@ -147,6 +147,81 @@ ok('параметры нельзя поменять на ходу', Object.isFr
|
|
|
147
147
|
is('и отчёт печатает именно его', Number((toHtml(audit, null).match(/overall-num[^>]*>(\d+)<span>/) || [])[1]), 63);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/*
|
|
151
|
+
* Подпись под баллом обязана объяснить человеку три вещи, иначе он прочитает разницу в пару
|
|
152
|
+
* пунктов как ошибку: откуда взялось число, что успели прочитать, и почему повтор может дать
|
|
153
|
+
* иначе. Это критерий приёмки, а не украшение: человек, прочитавший сначала страницу, а потом
|
|
154
|
+
* отчёт, не должен удивиться.
|
|
155
|
+
*/
|
|
156
|
+
{
|
|
157
|
+
const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
158
|
+
const areas = [{ id: 'access', label: 'Access', score: 20, max: 25 }, { id: 'index', label: 'Index', score: 10, max: 15 },
|
|
159
|
+
{ id: 'entity', label: 'Entity', score: 12, max: 20 }, { id: 'content', label: 'Content', score: 14, max: 25 }, { id: 'trust', label: 'Trust', score: 5, max: 15 }];
|
|
160
|
+
const note = (a) => ((toHtml(a, null).match(/overall-note">([^<]*)</) || [])[1] || '');
|
|
161
|
+
|
|
162
|
+
for (const lang of ['en', 'ru']) {
|
|
163
|
+
audit.meta.lang = lang;
|
|
164
|
+
|
|
165
|
+
audit.overall = { score: 61, grade: 'B', areas, source: 'visibility:reused', measuredAt: '2026-09-14T20:00:00.000Z', basis: { pages: 4, sitemapRead: true, sitemapUnchecked: false } };
|
|
166
|
+
const reused = note(audit);
|
|
167
|
+
ok(`${lang}: сказано, что число уже видели на проверке`, lang === 'en' ? /already saw on the free check/.test(reused) : /уже видели в бесплатной проверке/.test(reused));
|
|
168
|
+
ok(`${lang}: сказано, что здесь не меряли заново`, lang === 'en' ? /not measured again here/.test(reused) : /не меряется заново/.test(reused));
|
|
169
|
+
ok(`${lang}: названа дата измерения`, /2026-09-14/.test(reused));
|
|
170
|
+
ok(`${lang}: сказано, сколько страниц прочитано`, /4/.test(reused));
|
|
171
|
+
ok(`${lang}: предупреждает про разброс в пункт-другой`, lang === 'en' ? /a point or two/.test(reused) : /на пункт-другой/.test(reused));
|
|
172
|
+
|
|
173
|
+
audit.overall = { score: 61, grade: 'B', areas, source: 'visibility', basis: { pages: 1, sitemapRead: false, sitemapUnchecked: true } };
|
|
174
|
+
const fresh = note(audit);
|
|
175
|
+
ok(`${lang}: при собственном измерении сказано именно это`, lang === 'en' ? /Measured here by the same code/.test(fresh) : /Измерено здесь тем же кодом/.test(fresh));
|
|
176
|
+
ok(`${lang}: непрочитанная карта сайта названа, а не спрятана`, lang === 'en' ? /sitemap did not answer in time/.test(fresh) : /карта сайта не ответила вовремя/.test(fresh));
|
|
177
|
+
ok(`${lang}: и сказано, что она не зачтена в минус`, lang === 'en' ? /not counted against the score/.test(fresh) : /не зачтено ни в плюс, ни в минус/.test(fresh));
|
|
178
|
+
ok(`${lang}: в подписи нет чужого языка`, lang === 'en' ? !/[А-Яа-яЁё]/.test(fresh) : !/Measured|sitemap did/.test(fresh));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Русское склонение в подписи: человек это читает.
|
|
182
|
+
audit.meta.lang = 'ru';
|
|
183
|
+
for (const [n, word] of [[1, 'страница'], [2, 'страницы'], [5, 'страниц'], [11, 'страниц'], [21, 'страница']]) {
|
|
184
|
+
audit.overall = { score: 61, grade: 'B', areas, source: 'visibility', basis: { pages: n, sitemapRead: true, sitemapUnchecked: false } };
|
|
185
|
+
ok(`ru: ${n} ${word}`, new RegExp(`Прочитано ${n} ${word} сайта`).test(note(audit)));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- один блок «балл и области» на отчёт и на письмо
|
|
190
|
+
//
|
|
191
|
+
// Письмо собирало свою формулировку и свою цифру, отчёт свою. Разошлись. Теперь обе стороны
|
|
192
|
+
// зовут overallSummary, и разойтись нечему: текст ровно один и живёт в пакете.
|
|
193
|
+
{
|
|
194
|
+
const areas = [{ id: 'access', label: 'Can AI crawlers read it', score: 20, max: 25 },
|
|
195
|
+
{ id: 'index', label: 'Is there a map for agents (llms.txt)', score: 5, max: 15 },
|
|
196
|
+
{ id: 'entity', label: 'Is the entity clear (schema)', score: 12, max: 20 },
|
|
197
|
+
{ id: 'content', label: 'Is there something to quote', score: 18, max: 25 },
|
|
198
|
+
{ id: 'trust', label: 'Can it be dated and trusted', score: 8, max: 15 }];
|
|
199
|
+
const overall = { score: 63, grade: 'B', areas, source: 'visibility:reused', measuredAt: '2026-09-14T20:00:00.000Z', basis: { pages: 4, sitemapRead: true, sitemapUnchecked: false } };
|
|
200
|
+
|
|
201
|
+
const ru = overallSummary(overall, { lang: 'ru' });
|
|
202
|
+
const en = overallSummary(overall, { lang: 'en' });
|
|
203
|
+
is('заголовок письма это заголовок отчёта', ru.score, 63);
|
|
204
|
+
is('сумма пяти областей даёт заголовок', ru.areas.reduce((a, x) => a + x.score, 0), ru.score);
|
|
205
|
+
ok('оценка словом переведена', ru.grade === 'Рабочее состояние' && en.grade === 'Workable');
|
|
206
|
+
const fixture = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
207
|
+
fixture.meta.lang = 'ru'; fixture.overall = overall;
|
|
208
|
+
ok('подпись та же, что печатает отчёт', ru.note === (toHtml(fixture, null).match(/overall-note">([^<]*)</) || [])[1]);
|
|
209
|
+
|
|
210
|
+
// Замер мог пройти на английской странице, а отчёт уходит русскому покупателю: язык отчёта главнее.
|
|
211
|
+
ok('названия областей на языке отчёта, а не замера', ru.areas[0].label === 'Могут ли роботы ИИ прочитать сайт');
|
|
212
|
+
ok('и в английском они английские', en.areas[0].label === 'Can AI crawlers read it');
|
|
213
|
+
ok('в русском блоке нет английских названий', !ru.areas.some((x) => /[A-Za-z]{4}/.test(x.label.replace('llms.txt', ''))));
|
|
214
|
+
|
|
215
|
+
// Шесть областей отчёта это второе измерение, и подпись обязана это говорить в обоих языках.
|
|
216
|
+
ok('подзаголовок второго измерения есть', /Второе, отдельное измерение/.test(ru.secondMeasure) && /second, separate measurement/.test(en.secondMeasure));
|
|
217
|
+
ok('и сказано, что складывать их с заголовком не надо', /в сумме его не дают/.test(ru.secondMeasureFoot) && /will not add up to it/.test(en.secondMeasureFoot));
|
|
218
|
+
ok('в подписи второго измерения нет разметки', !/</.test(ru.secondMeasureFoot) && !/</.test(en.secondMeasureFoot));
|
|
219
|
+
|
|
220
|
+
// Балла может не быть: движок не мерит закрытые адреса. Тогда письмо не называет цифру вовсе.
|
|
221
|
+
is('без балла блока нет', overallSummary({ score: null, grade: 'not measured', areas: [] }, { lang: 'ru' }), null);
|
|
222
|
+
is('и на пустом объекте тоже', overallSummary(null, { lang: 'en' }), null);
|
|
223
|
+
}
|
|
224
|
+
|
|
150
225
|
// ---- движок в пакете это та же программа, что стоит на сайтах
|
|
151
226
|
{
|
|
152
227
|
const here = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');
|