@operstack/audit 0.17.2 → 0.18.1
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 +1 -1
- package/src/index.mjs +1 -1
- package/src/render.mjs +40 -8
- package/src/rendered.mjs +85 -8
- package/src/test-overall.mjs +37 -1
- package/src/test-rendered.mjs +56 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
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
|
@@ -460,7 +460,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
460
460
|
for (const t of targets) {
|
|
461
461
|
if (!t.url) continue;
|
|
462
462
|
log(`rendering ${t.url} in a real browser`);
|
|
463
|
-
const dom = renderedDom(t.url);
|
|
463
|
+
const dom = await renderedDom(t.url);
|
|
464
464
|
if (!dom) { log('no browser here, the JavaScript check will say it was not measured'); break; }
|
|
465
465
|
const rawHtml = t.raw || (await get(t.url)).text;
|
|
466
466
|
if (!rawHtml) continue;
|
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, '"');
|
|
@@ -102,6 +103,38 @@ const ruPoints = (n) => { const a = Math.abs(n) % 100; const b = a % 10; if (a >
|
|
|
102
103
|
|
|
103
104
|
const scoreClass = (n) => (n === null || n === undefined ? '' : n <= 3 ? 'low' : n <= 6 ? 'mid' : 'ok');
|
|
104
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
|
+
|
|
105
138
|
export function checkNarrative(audit) {
|
|
106
139
|
const out = [];
|
|
107
140
|
const walk = (v, path) => {
|
|
@@ -211,14 +244,13 @@ export function toHtml(audit, brand = null) {
|
|
|
211
244
|
* независимое измерение по проверкам самого отчёта, а не разбивка заголовка. Сводить их с
|
|
212
245
|
* заголовком не нужно и не получится, и об этом сказано словами.
|
|
213
246
|
*/
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
<div class="overall-
|
|
218
|
-
|
|
219
|
-
<div class="overall-
|
|
220
|
-
|
|
221
|
-
+ (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>` : '');
|
|
222
254
|
const scoreCards = Object.entries(measured).map(([label, n]) => {
|
|
223
255
|
const basis = scoreBasis[label] || {};
|
|
224
256
|
const shown = (L.areas && L.areas[label]) || label;
|
package/src/rendered.mjs
CHANGED
|
@@ -10,11 +10,31 @@
|
|
|
10
10
|
* Браузер берётся тот же, которым печатается PDF отчёта: свой Chrome или Chromium. Если его нет,
|
|
11
11
|
* проверка не выдумывает результат, а говорит, что не измеряла, и почему.
|
|
12
12
|
*/
|
|
13
|
-
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
14
14
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
15
|
import { tmpdir } from 'node:os';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Это страница сайта или собственная страница ошибки браузера.
|
|
20
|
+
*
|
|
21
|
+
* Простым языком: браузер не смог открыть сайт и вместо него напечатал свою страницу «не удалось
|
|
22
|
+
* подключиться». В ней сотни слов интерфейса самого браузера. Если принять её за «как сайт
|
|
23
|
+
* выглядит со скриптами», получится, что у покупателя почти весь текст появляется только в
|
|
24
|
+
* браузере, и мы напишем ему в отчёте, что его сайт не читается без скриптов. Это была бы
|
|
25
|
+
* выдуманная находка по чужому сайту, худший вид ошибки в платном отчёте.
|
|
26
|
+
*
|
|
27
|
+
* Поэтому такую страницу мы не меряем вовсе и честно говорим, что не мерили.
|
|
28
|
+
*/
|
|
29
|
+
export function isBrowserErrorPage(html) {
|
|
30
|
+
const s = String(html || '');
|
|
31
|
+
if (!s) return false;
|
|
32
|
+
// Своя страница ошибки Chrome собирается из этих кусков и ни на одном настоящем сайте их нет
|
|
33
|
+
// всех сразу: контейнер ошибки, скрипт neterror и копирайт Chromium в собственных стилях.
|
|
34
|
+
const marks = [/id="main-frame-error"/i, /neterror/i, /chrome-error:/i, /Chromium Authors/].filter((re) => re.test(s)).length;
|
|
35
|
+
return marks >= 2;
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
/** Тот же поиск браузера, что у печати PDF: одна привычка на весь пакет. */
|
|
19
39
|
export function findChrome() {
|
|
20
40
|
const candidates = [
|
|
@@ -34,17 +54,74 @@ export function findChrome() {
|
|
|
34
54
|
/** Текст страницы после выполнения скриптов. null, если браузера нет или он не справился. */
|
|
35
55
|
export function renderedDom(url, { chrome = null, timeoutMs = 45000, waitMs = 8000 } = {}) {
|
|
36
56
|
const bin = chrome || findChrome();
|
|
37
|
-
if (!bin) return null;
|
|
57
|
+
if (!bin) return Promise.resolve(null);
|
|
38
58
|
const profile = mkdtempSync(join(tmpdir(), 'operstack-render-'));
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* Почему не spawnSync с таймаутом, хотя так было и так короче.
|
|
62
|
+
*
|
|
63
|
+
* 14.09.2026 отчёт по habr.com встал на этой стадии и не двигался сорок минут при нулевой
|
|
64
|
+
* загрузке процессора. Таймаут spawnSync отправляет сигнал только самому Chrome, а тот
|
|
65
|
+
* запускает десяток вспомогательных процессов, и они наследуют тот же канал вывода. Главный
|
|
66
|
+
* умирает, помощники живут, канал не закрывается, и ожидание чтения не кончается никогда.
|
|
67
|
+
* Таймаут при этом честно сработал: висел не Chrome, висело чтение его вывода.
|
|
68
|
+
*
|
|
69
|
+
* Поэтому запускаем своей группой процессов (`detached`) и по истечении срока убиваем всю
|
|
70
|
+
* группу разом, отрицательным номером. Помощники уходят вместе с главным, канал закрывается,
|
|
71
|
+
* и стадия заканчивается в срок при любом поведении чужого сайта.
|
|
72
|
+
*/
|
|
73
|
+
/** Похоже ли прочитанное на целую страницу, а не на обрывок. */
|
|
74
|
+
const complete = (html) => /<\/html\s*>\s*$/i.test(String(html).trimEnd());
|
|
75
|
+
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
let out = ''; let size = 0; let done = false;
|
|
78
|
+
const child = spawn(bin, [
|
|
41
79
|
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
|
|
42
80
|
'--disable-extensions', '--mute-audio', '--hide-scrollbars',
|
|
43
81
|
`--user-data-dir=${profile}`, `--virtual-time-budget=${waitMs}`, '--dump-dom', url,
|
|
44
|
-
], {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
82
|
+
], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
83
|
+
|
|
84
|
+
const finish = (value) => {
|
|
85
|
+
if (done) return;
|
|
86
|
+
done = true;
|
|
87
|
+
// Страница ошибки браузера это не сайт покупателя: меряли бы интерфейс Chrome.
|
|
88
|
+
if (value && isBrowserErrorPage(value)) value = null;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
stopGroup();
|
|
91
|
+
rmSync(profile, { recursive: true, force: true });
|
|
92
|
+
resolve(value);
|
|
93
|
+
};
|
|
94
|
+
const stopGroup = () => {
|
|
95
|
+
// Отрицательный номер это вся группа: сам Chrome и все его помощники.
|
|
96
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* уже ушли */ }
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// По сроку отдаём то, что успели прочитать: браузер мог допечатать страницу целиком и не
|
|
100
|
+
// суметь завершиться. Выбрасывать готовый DOM только потому, что Chrome не ушёл сам, значит
|
|
101
|
+
// сказать покупателю «не измеряли» там, где измерили.
|
|
102
|
+
const timer = setTimeout(() => finish(complete(out) ? out : null), timeoutMs);
|
|
103
|
+
child.stdout.on('data', (chunk) => {
|
|
104
|
+
size += chunk.length;
|
|
105
|
+
// Страница может оказаться огромной. Читаем до потолка и прекращаем: DOM на шестьдесят
|
|
106
|
+
// мегабайт всё равно не нужен, а память нужна.
|
|
107
|
+
if (size > 64 * 1024 * 1024) { finish(complete(out) ? out : null); return; }
|
|
108
|
+
out += chunk;
|
|
109
|
+
// Как только страница допечатана до закрывающего тега, ждать больше нечего: `--dump-dom`
|
|
110
|
+
// печатает разметку целиком и одним куском. Chrome после этого может не завершиться сам,
|
|
111
|
+
// и раньше каждая страница стоила полного срока ожидания вместо секунды.
|
|
112
|
+
if (complete(out)) finish(out);
|
|
113
|
+
});
|
|
114
|
+
child.on('error', () => finish(null));
|
|
115
|
+
/*
|
|
116
|
+
* Ждём именно `exit`, а не `close`.
|
|
117
|
+
*
|
|
118
|
+
* `close` ждёт, пока закроются все потоки, а канал вывода держат вспомогательные процессы
|
|
119
|
+
* Chrome, которые живут дольше главного. Из-за этого стадия не заканчивалась даже на
|
|
120
|
+
* example.com: DOM был давно прочитан, а событие не приходило. `exit` приходит, когда ушёл
|
|
121
|
+
* сам браузер, и этого достаточно: всё, что он собирался напечатать, уже у нас.
|
|
122
|
+
*/
|
|
123
|
+
child.on('exit', (code) => finish(code === 0 && out ? out : (complete(out) ? out : null)));
|
|
124
|
+
});
|
|
48
125
|
}
|
|
49
126
|
|
|
50
127
|
const CHROME_STRIP = /<(nav|footer|aside|script|style|noscript|template)[\s\S]*?<\/\1>/gi;
|
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}`); } };
|
|
@@ -186,6 +186,42 @@ ok('параметры нельзя поменять на ходу', Object.isFr
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
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
|
+
|
|
189
225
|
// ---- движок в пакете это та же программа, что стоит на сайтах
|
|
190
226
|
{
|
|
191
227
|
const here = readFileSync(new URL('./visibility.mjs', import.meta.url), 'utf8');
|
package/src/test-rendered.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Слепота к скриптам: считаем честно, не измерив, так и говорим. */
|
|
3
|
-
import {
|
|
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 } from './rendered.mjs';
|
|
4
7
|
|
|
5
8
|
let bad = 0;
|
|
6
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}`); };
|
|
@@ -57,5 +60,57 @@ ok('и сказано почему', /нет браузера/.test(none.value))
|
|
|
57
60
|
const en = jsBlindnessCheck([{ url: 'https://x.com/', raw: 10, rendered: 400, hiddenShare: 98, verdict: 'bad' }], 'en');
|
|
58
61
|
ok('английская версия без кириллицы', !/[А-Яа-яЁё]/.test(JSON.stringify(en)));
|
|
59
62
|
|
|
63
|
+
// ---- страница ошибки браузера это не сайт покупателя
|
|
64
|
+
//
|
|
65
|
+
// 14.09.2026 браузер не смог открыть сайт и напечатал свою страницу «не удалось подключиться»:
|
|
66
|
+
// сто восемьдесят килобайт интерфейса Chrome. Приняв её за «как выглядит со скриптами», мы бы
|
|
67
|
+
// написали покупателю в платном отчёте, что его сайт не читается без скриптов. Выдуманная
|
|
68
|
+
// находка по чужому сайту это худшее, что может быть в отчёте за деньги.
|
|
69
|
+
{
|
|
70
|
+
const errorPage = '<!DOCTYPE html><html dir="ltr" lang="ru"><head><style>/* Copyright 2017 The Chromium Authors */</style>'
|
|
71
|
+
+ '<script>function neterror(){}</script></head><body><div id="main-frame-error"><span>Не удалось открыть страницу</span></div></body></html>';
|
|
72
|
+
ok('страница ошибки браузера распознана', isBrowserErrorPage(errorPage));
|
|
73
|
+
ok('настоящая страница не принята за ошибку', !isBrowserErrorPage(body(text(300))));
|
|
74
|
+
ok('пустой ответ не принят за ошибку', !isBrowserErrorPage(''));
|
|
75
|
+
// Одного совпадения мало: слово neterror может встретиться в статье про ошибки браузера.
|
|
76
|
+
ok('одного слова недостаточно', !isBrowserErrorPage('<html><body><p>Статья про neterror и как его читать</p></body></html>'));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- браузер обязан отпускать, даже если сам не уходит
|
|
80
|
+
//
|
|
81
|
+
// Chrome печатает разметку и может не завершиться: его вспомогательные процессы держат канал
|
|
82
|
+
// вывода открытым. Отчёт по habr.com из-за этого стоял сорок минут при нулевой загрузке.
|
|
83
|
+
{
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), 'operstack-fake-chrome-'));
|
|
85
|
+
const fake = (script) => {
|
|
86
|
+
const f = join(dir, `chrome-${Math.random().toString(36).slice(2)}.sh`);
|
|
87
|
+
writeFileSync(f, `#!/bin/sh\n${script}\n`);
|
|
88
|
+
chmodSync(f, 0o755);
|
|
89
|
+
return f;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Напечатал страницу целиком и завис. Ждать его незачем: всё уже у нас.
|
|
93
|
+
const printsThenHangs = fake(`printf '%s' '<html><body><p>текст страницы</p></body></html>'\nsleep 60`);
|
|
94
|
+
const t0 = Date.now();
|
|
95
|
+
const dom = await renderedDom('https://example.com/', { chrome: printsThenHangs, timeoutMs: 10000 });
|
|
96
|
+
const took = Date.now() - t0;
|
|
97
|
+
ok('разметка получена, хотя браузер не вышел', typeof dom === 'string' && dom.includes('текст страницы'));
|
|
98
|
+
ok(`и получена сразу, а не по сроку (${(took / 1000).toFixed(1)} с)`, took < 5000);
|
|
99
|
+
|
|
100
|
+
// Ничего не печатает и не уходит. Здесь обязан сработать срок, и вернуться «не измеряли».
|
|
101
|
+
const silentHang = fake('sleep 60');
|
|
102
|
+
const t1 = Date.now();
|
|
103
|
+
const none = await renderedDom('https://example.com/', { chrome: silentHang, timeoutMs: 2500 });
|
|
104
|
+
const waited = Date.now() - t1;
|
|
105
|
+
is('молчащий браузер это «не измеряли», а не выдумка', none, null);
|
|
106
|
+
ok(`и ожидание кончилось в срок (${(waited / 1000).toFixed(1)} с)`, waited >= 2000 && waited < 9000);
|
|
107
|
+
|
|
108
|
+
// Напечатал свою страницу ошибки: не мерим.
|
|
109
|
+
const printsError = fake(`printf '%s' '<html><head><style>/* Copyright The Chromium Authors */</style></head><body><div id="main-frame-error">нет связи</div></body></html>'`);
|
|
110
|
+
is('страница ошибки не идёт в измерение', await renderedDom('https://example.com/', { chrome: printsError, timeoutMs: 8000 }), null);
|
|
111
|
+
|
|
112
|
+
rmSync(dir, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
60
115
|
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
61
116
|
console.log('\nвсе тесты слепоты к скриптам прошли');
|