diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
index 1f834823..de120fcf 100644
--- a/docs/USER-GUIDE.md
+++ b/docs/USER-GUIDE.md
@@ -995,6 +995,27 @@ names, exact coordinates and external URLs remain in the JSON. Internal plan
files are still referenced rather than embedded and may need to be detached on
another Home Assistant instance.
+### Importing a plan from Sweet Home 3D
+
+If the home is already drawn in [Sweet Home 3D](https://www.sweethome3d.com/),
+there is no need to draw it again: the converter at
+[houseplan.tech/convert](https://houseplan.tech/convert) turns a `.sh3d` file
+into import documents — one per level. From there it is the ordinary space
+import under **Global settings → Backup and transfer**.
+
+This is an **optional shortcut**, not a setup step: the normal paths — a
+background image or drawing from scratch — stay unchanged. The conversion runs
+in your browser; the file is never uploaded.
+
+Carried over: levels, rooms with their names, walls with thickness, doors and
+windows. Not carried over: furniture, materials, textures, lights, cameras —
+and the binding of rooms to Home Assistant areas together with device
+placement, because the file has neither; both are done in the editor. Curved
+walls are straightened, walls thicker than 100 cm are clamped to the limit, and
+a level with no drawn rooms cannot be converted at all — House Plan builds
+geometry from rooms. The page lists every such case before you download
+anything.
+
### Storage locations
| Data | Location |
diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md
index 6bfddfd7..8fb46854 100644
--- a/docs/USER-GUIDE.ru.md
+++ b/docs/USER-GUIDE.ru.md
@@ -1798,6 +1798,26 @@ JSON хранит названия, идентификаторы HA и точн
координаты и внешние URL. Структурные привязки Home Assistant при этом не
переносятся.
+### Импорт плана из Sweet Home 3D
+
+Если план дома уже нарисован в [Sweet Home 3D](https://www.sweethome3d.com/),
+его не нужно рисовать заново: конвертер на
+[houseplan.tech/convert](https://houseplan.tech/convert) превращает файл
+`.sh3d` в документ импорта — по одному на этаж. Дальше это обычный импорт
+пространства из **Общих настроек → Резервная копия и перенос**.
+
+Это **необязательный ярлык**, а не шаг настройки: полный путь остаётся прежним
+— подложка изображением или рисование с нуля. Конверсия идёт в браузере, файл
+никуда не загружается.
+
+Что переносится: этажи, комнаты с названиями, стены с толщиной, двери и окна.
+Что не переносится: мебель, материалы, текстуры, свет, камеры — и привязка
+комнат к областям Home Assistant с расстановкой устройств: их в файле нет, это
+делается в редакторе. Круглые стены спрямляются, стены толще 100 см
+обрезаются до предела, а этаж без нарисованных комнат конвертировать нечем —
+House Plan строит геометрию по комнатам. Страница показывает полный список
+таких мест до скачивания.
+
### Где лежат данные
| Данные | Хранение |
diff --git a/scripts/sh3d-convert/build-page.mjs b/scripts/sh3d-convert/build-page.mjs
new file mode 100644
index 00000000..d63ff8d7
--- /dev/null
+++ b/scripts/sh3d-convert/build-page.mjs
@@ -0,0 +1,67 @@
+#!/usr/bin/env node
+/**
+ * Сборка страницы /convert в один самодостаточный файл (#446).
+ *
+ * Артефакт один: `dist/index.html`. Так же устроен лендинг, и так же его
+ * раскладывают на хост — копированием, без сборщиков и без CDN.
+ *
+ * Модули склеиваются, а не грузятся по относительным путям: страница обязана
+ * работать из любого каталога и без сети. Склейка нарочно тупая — снимаются
+ * строки `import ... from '...'` и префикс `export `. Многострочный импорт
+ * ломает такую склейку молча, поэтому он ОТКАЗ, а не предупреждение.
+ *
+ * node scripts/sh3d-convert/build-page.mjs [--check]
+ *
+ * `--check` не пишет файл, а сверяет закоммиченный артефакт с пересборкой:
+ * этим тест ловит правку страницы или модулей без пересборки.
+ */
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const ORDER = ['xml.mjs', 'zip.mjs', 'convert.mjs', 'sh3d.mjs', 'page/app.mjs'];
+const MARKER = '/* */';
+
+const stripModuleSyntax = (source, name) => {
+ const lines = source.split('\n');
+ const out = [];
+ for (const [index, line] of lines.entries()) {
+ if (/^\s*import\s/.test(line)) {
+ if (!/;\s*$/.test(line)) {
+ throw new Error(
+ `${name}:${index + 1}: многострочный import ломает склейку — запишите его в одну строку`);
+ }
+ continue;
+ }
+ out.push(line.replace(/^export\s+(?=const|function|class|async|let)/, ''));
+ }
+ const remaining = out.find((line) => /^\s*export\s/.test(line));
+ if (remaining) throw new Error(`${name}: не снят export: ${remaining.trim()}`);
+ return out.join('\n');
+};
+
+export function buildPage() {
+ const shell = readFileSync(join(HERE, 'page/shell.html'), 'utf8');
+ if (!shell.includes(MARKER)) throw new Error('в shell.html нет метки вставки');
+ const bundle = ORDER
+ .map((name) => `// --- ${name} ---\n${stripModuleSyntax(readFileSync(join(HERE, name), 'utf8'), name)}`)
+ .join('\n');
+ return shell.replace(MARKER, `${bundle}\nmount();`);
+}
+
+const page = buildPage();
+const target = join(HERE, 'dist', 'index.html');
+if (process.argv.includes('--check')) {
+ const committed = readFileSync(target, 'utf8');
+ if (committed !== page) {
+ console.error('FAILED: dist/index.html отстал от исходников —'
+ + ' пересоберите: node scripts/sh3d-convert/build-page.mjs');
+ process.exit(1);
+ }
+ console.log('ok: dist/index.html совпадает с пересборкой');
+} else {
+ mkdirSync(dirname(target), { recursive: true });
+ writeFileSync(target, page);
+ console.log(`dist/index.html: ${page.length} Б`);
+}
diff --git a/scripts/sh3d-convert/dist/index.html b/scripts/sh3d-convert/dist/index.html
new file mode 100644
index 00000000..68dacdaa
--- /dev/null
+++ b/scripts/sh3d-convert/dist/index.html
@@ -0,0 +1,1258 @@
+
+
+
+
+
+Sweet Home 3D → House Plan converter
+
+
+
+
+
+
+
+
+
+ 🏠 House Plan
+
+
+
+
+
+
+
Sweet Home 3D → House Plan converter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/sh3d-convert/page/app.mjs b/scripts/sh3d-convert/page/app.mjs
new file mode 100644
index 00000000..7f919633
--- /dev/null
+++ b/scripts/sh3d-convert/page/app.mjs
@@ -0,0 +1,288 @@
+/**
+ * Страница /convert: интерфейс конвертера (#446).
+ *
+ * Вся конверсия идёт здесь, в браузере. Ни одного сетевого вызова на этой
+ * странице нет и быть не должно: план дома — приватные данные, а сама страница
+ * живёт на хосте, который по нашему описанию расходный и потенциально
+ * враждебный. Отсутствие `fetch`/`XMLHttpRequest`/`WebSocket`/`sendBeacon`
+ * проверяется тестом по собранному артефакту, а не обещанием в тексте.
+ *
+ * Отчёт показывается ДО скачивания — это требование задачи, а не украшение:
+ * человек должен узнать про спрямлённую дугу и непривязанную дверь заранее, а
+ * не искать их потом в плане из сотен объектов.
+ */
+import { readSh3d } from '../sh3d.mjs';
+import { ConvertError, MIN_HOUSEPLAN, convertHome } from '../convert.mjs';
+
+const TOOL_VERSION = 'sh3d-convert 0.1';
+const MAX_FILE_BYTES = 64 * 1024 * 1024;
+
+/** Словари вынесены в экспорт: полноту ключей проверяет тест страницы. */
+export const TEXT = {
+ ru: {
+ 'page.title': 'Конвертер Sweet Home 3D → House Plan',
+ 'page.lead': 'Файл плана из Sweet Home 3D превращается в документ импорта House Plan — по одному на этаж.',
+ 'page.privacy': 'Конверсия идёт в вашем браузере. Файл никуда не загружается: страница не делает ни одного сетевого запроса.',
+ 'page.requires': `Требуется House Plan ${MIN_HOUSEPLAN} или новее.`,
+ 'drop.title': 'Перетащите сюда файл .sh3d',
+ 'drop.or': 'или',
+ 'drop.button': 'Выбрать файл',
+ 'drop.hint': 'Ничего не уходит с вашего компьютера',
+ 'busy': 'Читаю файл…',
+ 'result.title': 'Что получилось',
+ 'result.download': 'Скачать этаж',
+ 'result.rooms': 'комнат',
+ 'result.walls': 'стен',
+ 'result.openings': 'проёмов',
+ 'result.cell': 'клетка сетки',
+ 'result.skipped': 'этаж пропущен: комнат нет',
+ 'result.notes': 'На что посмотреть после импорта',
+ 'howto.title': 'Как импортировать',
+ 'howto.1': 'Откройте карточку House Plan → Общие настройки → Резервная копия и перенос.',
+ 'howto.2': 'Выберите импорт и укажите скачанный файл: House Plan покажет предпросмотр и ничего не запишет до подтверждения.',
+ 'howto.3': 'Этаж добавится рядом с существующими пространствами. Привязку комнат к областям Home Assistant и расстановку устройств сделайте в редакторе — в файле их нет.',
+ 'error.title': 'Не получилось',
+ 'error.retry': 'Выбрать другой файл',
+ 'back': 'На главную',
+ 'note.vertices_snapped': 'Вершины комнат выровнены по осевым линиям стен: {count}. Sweet Home 3D обводит комнаты по внутренним граням, иначе общая стена превратилась бы в две.',
+ 'note.curved_wall_straightened': 'Круглых стен спрямлено: {count}. Дуг в House Plan нет.',
+ 'note.thickness_clamped': 'Стен толще {limit} см обрезано до предела: {count}.',
+ 'note.edge_without_wall': 'Границ без стены: {count}. Такая граница останется открытой — стены в файле там не было.',
+ 'note.opening_unhosted': 'Проём не привязался ни к одной стене и не перенесён: {id}.',
+ 'note.opening_shortened': 'Проём шире стены и обрезан до неё: {id}.',
+ 'note.opening_without_width': 'Проём без ширины пропущен: {id}.',
+ 'note.room_without_polygon': 'Комната без контура пропущена: {id}.',
+ 'note.room_collapsed': 'Контур комнаты рассыпался после выравнивания и пропущен: {id}.',
+ 'note.level_without_rooms': 'Этаж «{title}» пропущен: в нём нет ни одной комнаты. House Plan строит геометрию по комнатам — нарисуйте их в Sweet Home 3D.',
+ 'note.furniture_dropped': 'Мебель, материалы, свет и камеры не переносятся: House Plan — живая карта дома, а не редактор интерьера.',
+ 'code.not_zip': 'Это не файл Sweet Home 3D: архив не читается.',
+ 'code.entry_missing': 'В архиве нет Home.xml. Пересохраните план в Sweet Home 3D 5.3 или новее.',
+ 'code.encrypted': 'Архив защищён паролем.',
+ 'code.zip64': 'Такой архив (zip64) не поддерживается.',
+ 'code.entry_too_large': 'Файл слишком большой.',
+ 'code.entity_declaration': 'В файле объявлены XML-сущности — такой файл не читается.',
+ 'code.entity_reference': 'В файле есть неизвестная XML-сущность.',
+ 'code.nothing_to_convert': 'В плане нет ни одной комнаты. House Plan строит геометрию по комнатам: нарисуйте их в Sweet Home 3D и сохраните файл заново.',
+ 'code.room_too_complex': 'Слишком сложный контур комнаты.',
+ 'code.unit_not_metric': 'План сохранён не в метрических единицах.',
+ 'code.file_too_large': 'Файл больше 64 МБ.',
+ 'code.unknown': 'Неизвестная ошибка чтения файла.',
+ },
+ en: {
+ 'page.title': 'Sweet Home 3D → House Plan converter',
+ 'page.lead': 'Turns a Sweet Home 3D file into House Plan import documents — one per level.',
+ 'page.privacy': 'The conversion runs in your browser. The file is never uploaded: this page makes no network requests at all.',
+ 'page.requires': `Requires House Plan ${MIN_HOUSEPLAN} or newer.`,
+ 'drop.title': 'Drop a .sh3d file here',
+ 'drop.or': 'or',
+ 'drop.button': 'Choose a file',
+ 'drop.hint': 'Nothing leaves your computer',
+ 'busy': 'Reading the file…',
+ 'result.title': 'What came out',
+ 'result.download': 'Download level',
+ 'result.rooms': 'rooms',
+ 'result.walls': 'walls',
+ 'result.openings': 'openings',
+ 'result.cell': 'grid cell',
+ 'result.skipped': 'level skipped: no rooms',
+ 'result.notes': 'What to check after the import',
+ 'howto.title': 'How to import',
+ 'howto.1': 'Open the House Plan card → General settings → Backup & transfer.',
+ 'howto.2': 'Pick import and choose the downloaded file: House Plan shows a preview and writes nothing until you confirm.',
+ 'howto.3': 'The level is added next to your existing spaces. Bind rooms to Home Assistant areas and place devices in the editor — the file carries neither.',
+ 'error.title': 'That did not work',
+ 'error.retry': 'Choose another file',
+ 'back': 'Back to the site',
+ 'note.vertices_snapped': 'Room vertices aligned to wall centrelines: {count}. Sweet Home 3D traces rooms along inner faces, so without this a shared wall would become two.',
+ 'note.curved_wall_straightened': 'Curved walls straightened: {count}. House Plan has no arcs.',
+ 'note.thickness_clamped': 'Walls thicker than {limit} cm clamped to the limit: {count}.',
+ 'note.edge_without_wall': 'Boundaries without a wall: {count}. They stay open — there was no wall in the file.',
+ 'note.opening_unhosted': 'This opening matched no wall and was skipped: {id}.',
+ 'note.opening_shortened': 'Opening wider than its wall, shortened to fit: {id}.',
+ 'note.opening_without_width': 'Opening without a width, skipped: {id}.',
+ 'note.room_without_polygon': 'Room without an outline, skipped: {id}.',
+ 'note.room_collapsed': 'Room outline collapsed after alignment and was skipped: {id}.',
+ 'note.level_without_rooms': 'Level “{title}” skipped: it has no rooms. House Plan builds geometry from rooms — draw them in Sweet Home 3D.',
+ 'note.furniture_dropped': 'Furniture, materials, lights and cameras are not carried over: House Plan is a live map of the home, not an interior editor.',
+ 'code.not_zip': 'This is not a Sweet Home 3D file: the archive cannot be read.',
+ 'code.entry_missing': 'The archive has no Home.xml. Re-save the plan in Sweet Home 3D 5.3 or newer.',
+ 'code.encrypted': 'The archive is password protected.',
+ 'code.zip64': 'This archive (zip64) is not supported.',
+ 'code.entry_too_large': 'The file is too large.',
+ 'code.entity_declaration': 'The file declares XML entities and cannot be read.',
+ 'code.entity_reference': 'The file contains an unknown XML entity.',
+ 'code.nothing_to_convert': 'The plan has no rooms. House Plan builds geometry from rooms: draw them in Sweet Home 3D and save the file again.',
+ 'code.room_too_complex': 'A room outline is too complex.',
+ 'code.unit_not_metric': 'The plan is not saved in metric units.',
+ 'code.file_too_large': 'The file is larger than 64 MB.',
+ 'code.unknown': 'Unknown error while reading the file.',
+ },
+};
+
+let lang = 'en';
+
+const fill = (template, values) =>
+ String(template).replace(/\{(\w+)\}/g, (whole, key) =>
+ (values && values[key] !== undefined ? String(values[key]) : whole));
+
+const t = (key, values) => fill(TEXT[lang][key] ?? TEXT.en[key] ?? key, values);
+
+function applyLanguage(next) {
+ lang = TEXT[next] ? next : 'en';
+ document.documentElement.lang = lang;
+ try {
+ localStorage.setItem('hp-convert-lang', lang);
+ } catch {
+ // Приватный режим: язык просто не запомнится, это не ошибка.
+ }
+ for (const node of document.querySelectorAll('[data-i18n]')) {
+ node.textContent = t(node.dataset.i18n);
+ }
+ for (const node of document.querySelectorAll('[data-lang]')) {
+ node.classList.toggle('on', node.dataset.lang === lang);
+ }
+ if (lastResult) renderResult(lastResult);
+}
+
+let lastResult = null;
+
+const element = (tag, className, text) => {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+};
+
+const show = (id, visible) => {
+ document.getElementById(id).hidden = !visible;
+};
+
+function noteText(note) {
+ const key = `note.${note.code}`;
+ const known = TEXT[lang][key] || TEXT.en[key];
+ return known ? fill(known, note) : `${note.code}: ${JSON.stringify(note)}`;
+}
+
+function download(name, document_) {
+ const blob = new Blob([`${JSON.stringify(document_, null, 2)}\n`], {
+ type: 'application/json',
+ });
+ const url = URL.createObjectURL(blob);
+ const link = element('a');
+ link.href = url;
+ link.download = name;
+ link.click();
+ // Ссылка живёт ровно до клика: держать её незачем, а память жалко.
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+}
+
+function renderResult(result) {
+ lastResult = result;
+ const { documents, report, stem } = result;
+ const host = document.getElementById('levels');
+ host.textContent = '';
+ let downloadIndex = 0;
+ for (const level of report.levels) {
+ const card = element('div', 'level');
+ card.append(element('h3', null, level.title || level.id || '—'));
+ if (level.skipped) {
+ card.append(element('p', 'muted', t('result.skipped')));
+ } else {
+ const stats = element('p', 'stats');
+ stats.textContent = [
+ `${level.rooms} ${t('result.rooms')}`,
+ `${level.walls} ${t('result.walls')}`,
+ `${level.openings} ${t('result.openings')}`,
+ `${t('result.cell')}: ${level.cellCm} cm`,
+ ].join(' · ');
+ card.append(stats);
+ const index = downloadIndex++;
+ const button = element('button', 'btn primary', t('result.download'));
+ button.addEventListener('click', () =>
+ download(`${stem}.${level.spaceId}.json`, documents[index]));
+ card.append(button);
+ }
+ const notes = [...(level.notes || [])];
+ if (notes.length) {
+ card.append(element('h4', 'notes-title', t('result.notes')));
+ const list = element('ul', 'notes');
+ for (const note of notes) list.append(element('li', null, noteText(note)));
+ card.append(list);
+ }
+ host.append(card);
+ }
+ const shared = element('ul', 'notes');
+ for (const item of report.items) shared.append(element('li', null, noteText(item)));
+ document.getElementById('shared-notes').replaceChildren(shared);
+ show('result', true);
+ show('error', false);
+ show('busy', false);
+}
+
+function renderError(code, detail) {
+ const key = `code.${code}`;
+ const known = TEXT[lang][key] || TEXT.en[key];
+ document.getElementById('error-text').textContent = known || t('code.unknown');
+ const details = document.getElementById('error-detail');
+ details.textContent = detail && !known ? detail : '';
+ details.hidden = !details.textContent;
+ show('error', true);
+ show('result', false);
+ show('busy', false);
+}
+
+async function handleFile(file) {
+ if (!file) return;
+ lastResult = null;
+ show('busy', true);
+ show('error', false);
+ show('result', false);
+ try {
+ if (file.size > MAX_FILE_BYTES) {
+ renderError('file_too_large');
+ return;
+ }
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ const home = await readSh3d(bytes);
+ const { documents, report } = convertHome(home, { toolVersion: TOOL_VERSION });
+ renderResult({
+ documents,
+ report,
+ stem: file.name.replace(/\.sh3d$/i, '') || 'plan',
+ });
+ } catch (error) {
+ const code = error && error.code ? error.code : 'unknown';
+ renderError(code, error instanceof ConvertError ? error.message : String(error?.message || ''));
+ }
+}
+
+export function mount() {
+ const input = document.getElementById('file');
+ const zone = document.getElementById('drop');
+ input.addEventListener('change', () => handleFile(input.files[0]));
+ document.getElementById('pick').addEventListener('click', () => input.click());
+ document.getElementById('retry').addEventListener('click', () => input.click());
+ for (const event of ['dragenter', 'dragover']) {
+ zone.addEventListener(event, (raw) => {
+ raw.preventDefault();
+ zone.classList.add('over');
+ });
+ }
+ for (const event of ['dragleave', 'drop']) {
+ zone.addEventListener(event, () => zone.classList.remove('over'));
+ }
+ zone.addEventListener('drop', (raw) => {
+ raw.preventDefault();
+ handleFile(raw.dataTransfer?.files?.[0]);
+ });
+ for (const node of document.querySelectorAll('[data-lang]')) {
+ node.addEventListener('click', () => applyLanguage(node.dataset.lang));
+ }
+ let saved = null;
+ try {
+ saved = localStorage.getItem('hp-convert-lang');
+ } catch {
+ saved = null;
+ }
+ applyLanguage(saved || (String(navigator.language || '').toLowerCase().startsWith('ru') ? 'ru' : 'en'));
+}
diff --git a/scripts/sh3d-convert/page/shell.html b/scripts/sh3d-convert/page/shell.html
new file mode 100644
index 00000000..e72fbd4b
--- /dev/null
+++ b/scripts/sh3d-convert/page/shell.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+Sweet Home 3D → House Plan converter
+
+
+
+
+
+
+
+
+
+ 🏠 House Plan
+
+
+
+
+
+
+
Sweet Home 3D → House Plan converter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/sh3d-convert-page.test.mjs b/test/sh3d-convert-page.test.mjs
new file mode 100644
index 00000000..979644eb
--- /dev/null
+++ b/test/sh3d-convert-page.test.mjs
@@ -0,0 +1,105 @@
+/**
+ * #446: страница /convert как артефакт.
+ *
+ * Проверяется не вид, а три обещания, каждое из которых можно нарушить молча:
+ * собранный файл действительно содержит рабочий конвертер, он не делает ни
+ * одного сетевого вызова, и он не отстал от исходников.
+ */
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { spawnSync } from 'node:child_process';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+import { TEXT } from '../scripts/sh3d-convert/page/app.mjs';
+
+const root = (relative) => fileURLToPath(new URL(`../${relative}`, import.meta.url));
+const page = () => readFileSync(root('scripts/sh3d-convert/dist/index.html'), 'utf8');
+const shell = () => readFileSync(root('scripts/sh3d-convert/page/shell.html'), 'utf8');
+
+test('#446 собранная страница не отстала от исходников', () => {
+ // Артефакт коммитится, потому что его раскладывают копированием. Значит он
+ // умеет отстать — и тогда сайт конвертирует не тем кодом, который проверен
+ // гейтами. Сверка с пересборкой это ловит.
+ const run = spawnSync(process.execPath,
+ [root('scripts/sh3d-convert/build-page.mjs'), '--check'], { encoding: 'utf8' });
+ assert.equal(run.status, 0, `${run.stdout || ''}${run.stderr || ''}`);
+});
+
+test('#446 в странице нет ни одного сетевого вызова и ни одной внешней загрузки', () => {
+ // Обещание «файл не покидает браузер» обязано быть проверяемым, а не
+ // написанным в тексте страницы. Заодно поэтому же на странице системные
+ // шрифты: к шрифтам — это внешний запрос при загрузке.
+ const text = page();
+ // Ищутся формы ВЫЗОВА, а не имена: первая редакция теста краснела на
+ // собственном комментарии в app.mjs, где эти API перечислены как запрещённые.
+ // Документация, называющая запрет, — не нарушение запрета.
+ for (const forbidden of [
+ /\bfetch\s*\(/, /new\s+XMLHttpRequest/, /new\s+WebSocket/, /new\s+EventSource/,
+ /\.sendBeacon\s*\(/, /\bimportScripts\s*\(/, /navigator\.(?:connection|geolocation)/,
+ /fonts\.googleapis/, /fonts\.gstatic/, /\bnavigator\.sendBeacon/,
+ ]) {
+ assert.ok(!forbidden.test(text), `в странице появилось ${forbidden}`);
+ }
+ assert.ok(!/