fix(admin): закрыть обещания панели, которые продукт не выполнял

Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
2026-09-01 07:27:15 +05:00
parent a1f0db22c2
commit c0a43ae915
86 changed files with 6237 additions and 1819 deletions
+6
View File
@@ -43,6 +43,12 @@ main() {
log_step "Checking orchestrator contracts"
run_orchestrator_tests
# Контракты панели проверяются рано: они не требуют ни собранного bundle, ни
# установленных зависимостей, и падать на них после резолва Hysteria и сборки
# артефактов означало бы платить минутами за ошибку, видимую сразу.
log_step "Checking HY2XS admin frontend contracts"
run_frontend_tests
log_step "Resolving upstream Hysteria and running compatibility gate"
resolve_and_verify_hysteria
+139 -23
View File
@@ -219,7 +219,7 @@ run_fix20_acceptance_subset() {
grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing"
log_step "Acceptance: docs matrix markers"
grep -q 'Fix20 production matrix' docs/11-testing-and-acceptance.md || fail "acceptance: fix20 matrix section missing"
grep -q 'Fix20 production matrix' docs/testing/11-5-negative-and-matrix.md || fail "acceptance: fix20 matrix section missing"
log_step "Acceptance: machine auth URL in templates"
grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template"
@@ -295,30 +295,128 @@ run_fix20_acceptance_subset() {
# разрешается резолвером, и сборка bundle падает — то есть проверяется
# исполняемый импорт, а не совпадение подстроки.
# Каждая иконка обязана давать symbol с viewBox.
# Контракт ассета проверяется ОБЩЕЙ функцией, а не второй её копией здесь.
#
# Без viewBox `<use>` не знает систему координат и рисует иконку в натуральную
# величину, обрезая её по размеру родительского svg. Три иконки из
# семнадцати его не объявляют — для них viewBox синтезируется из width/height,
# как это делал заменённый плагин. Проверка сторожит именно ассеты: иконка,
# добавленная без обоих способов задать координаты, иначе сломала бы
# отрисовку молча.
# Раньше в этом месте лежала самостоятельная реализация проверки viewBox —
# построчный двойник кода из sprite.ts. Пока проверялась одна вещь, это
# выглядело безобидно; с добавлением проверки цвета две копии правил
# разошлись бы так же, как разошлись две копии правила имени пира. Теперь
# findIconContractViolations живёт рядом с кодом, который строит symbol, и
# зовётся отсюда.
#
# Проверяется: система координат (без viewBox `<use>` рисует иконку в
# натуральную величину и обрезает её), отсутствие литеральных цветов у
# монохромных иконок, отсутствие инлайнового style и непустого <style>,
# отсутствие растра.
"$BUN_BIN" -e '
const fs = require("node:fs");
const { findIconContractViolations, iconName } =
await import("./apps/frontend/src/components/SvgIcon/symbol.ts");
const dir = "apps/frontend/src/assets/icons";
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".svg"));
if (files.length === 0) throw new Error("каталог локальных иконок пуст");
const broken = [];
for (const file of files) {
const raw = fs.readFileSync(`${dir}/${file}`, "utf8");
const openTag = raw.replace(/<\?xml[\s\S]*?\?>/gi, "").replace(/<!DOCTYPE[\s\S]*?>/gi, "").match(/<svg\b[^>]*>/i);
if (!openTag) { broken.push(`${file}: нет корневого <svg>`); continue; }
const hasViewBox = /viewBox="[^"]+"/i.test(openTag[0]);
const hasSize = /width="[\d.]+[a-z%]*"/i.test(openTag[0]) && /height="[\d.]+[a-z%]*"/i.test(openTag[0]);
if (!hasViewBox && !hasSize) broken.push(`${file}: нет ни viewBox, ни пары width/height`);
broken.push(...findIconContractViolations(fs.readFileSync(`${dir}/${file}`, "utf8"), iconName(file)));
}
if (broken.length) throw new Error("иконки без системы координат:\n" + broken.join("\n"));
' || fail "acceptance: локальная иконка не даёт корректный <symbol> для спрайта"
if (broken.length) throw new Error("иконки не соответствуют контракту спрайта:\n" + broken.join("\n"));
' || fail "acceptance: локальная иконка не соответствует контракту спрайта"
log_step "Acceptance: icon colour is inherited, not patched per icon"
# Прямой запрет из требований к исправлению: маскировать дефект конвейера
# отрисовки фильтром или перекрашивать иконку по её имени нельзя.
#
# Ищется СИНТАКСИЧЕСКАЯ ФОРМА: правило для .svg-icon с filter и селектор по
# атрибуту icon-class. Комментарий, объясняющий, почему их нет, обязан
# называть их по имени.
# Правило может занимать несколько строк, поэтому проверка читает файл
# целиком, а не построчно: grep здесь дал бы ложный PASS на любом
# отформатированном CSS.
"$BUN_BIN" -e '
const fs = require("node:fs");
const files = fs.readdirSync("apps/frontend/src/styles")
.map((f) => `apps/frontend/src/styles/${f}`)
.concat(["apps/frontend/src/components/SvgIcon/index.vue"]);
const offenders = [];
for (const file of files) {
const source = fs.readFileSync(file, "utf8");
if (/\.svg-icon[^{]*\{[^}]*\bfilter\s*:/i.test(source)) {
offenders.push(`${file}: цвет иконок маскируется CSS-фильтром`);
}
if (/\[icon-class[~^*$|]?=/i.test(source)) {
offenders.push(`${file}: цвет задаётся по имени конкретной иконки`);
}
}
if (offenders.length) throw new Error(offenders.join("\n"));
' || fail "acceptance: дефект отрисовки иконок замаскирован вместо исправления"
# Наследование обязано быть объявлено: ассет без литерального цвета сам по
# себе цвета не даёт, он его получает от компонента.
grep -q 'fill: currentcolor' apps/frontend/src/components/SvgIcon/index.vue \
|| fail "acceptance: SvgIcon больше не наследует цвет через currentColor"
# Проп цвета у компонента приглашал чинить отрисовку точечно, в обход общего
# контракта. Проверяется форма: атрибут fill на элементе use.
! grep -qE '<use[^>]*\bfill\s*=' apps/frontend/src/components/SvgIcon/index.vue \
|| fail "acceptance: SvgIcon снова принимает цвет параметром"
log_step "Acceptance: peer secret generation belongs to the server"
# «Оставьте пустым — сгенерируем автоматически» обязано выполняться для всех
# дверей: панели, прямого вызова API, импорта и будущих клиентов. Генерация
# во frontend выполняла бы обещание ровно для одной из них.
code_has apps/service/peer_secret.go -F -- 'func GeneratePeerSecret' \
|| fail "acceptance: генерация секрета пира не объявлена на сервисном слое"
code_has apps/service/peer_secret.go -F -- 'util.RandomString' \
|| fail "acceptance: генерация секрета пира не использует общий крипто-генератор"
# Импорт без секрета обязан давать пира, неотличимого от созданного формой.
code_has apps/service/peer.go -F -- 'GeneratePeerSecret(name)' \
|| fail "acceptance: импорт пиров генерирует секрет собственным способом"
local frontend_rng_hits
frontend_rng_hits="$(grep -rlE 'crypto\.getRandomValues|Math\.random' apps/frontend/src || true)"
[ -z "$frontend_rng_hits" ] \
|| fail "acceptance: панель генерирует случайные значения сама; секреты пиров создаёт сервер. Найдено:
$frontend_rng_hits"
log_step "Acceptance: validation failures name the field and the rule"
# Раньше и разбор тела, и нарушение любого правила любого поля превращались в
# одно слово `invalid`, а слой vo выбирал HTTP-семантику СРАВНЕНИЕМ текста
# сообщения с тремя литералами. Панель не могла ни подсветить поле, ни
# локализовать причину, не разбирая прозу.
code_has apps/controller/validator.go -F -- 'vo.FailValidation' \
|| fail "acceptance: отказ валидации перестал быть структурированным"
code_has apps/controller/validator.go -F -- 'describeValidationErrors' \
|| fail "acceptance: причины отказа валидации не раскладываются по полям"
code_has apps/model/vo/result.go -F -- 'Errors []FieldError' \
|| fail "acceptance: ответ об ошибке больше не несёт машиночитаемых причин"
# Классификация по тексту сообщения не должна вернуться: ищется форма
# сравнения, а не упоминание — комментарий выше в result.go обязан называть
# убранный приём по имени.
! code_has apps/model/vo/result.go -E -- 'constant\.[A-Za-z]+Error *== *message' \
|| fail "acceptance: код ответа снова выводится сравнением текста сообщения"
local generic_invalid_hits
generic_invalid_hits="$(code_mentions_in 'vo.Fail(constant.InvalidError' apps/controller apps/middleware)"
[ -z "$generic_invalid_hits" ] \
|| fail "acceptance: обобщённый отказ «invalid» вернулся в: $generic_invalid_hits"
# Панель обязана выбирать фразу по коду, а не по тексту ответа.
code_has apps/frontend/src/utils/api-message.ts -F -- 'error.code.' \
|| fail "acceptance: панель не локализует причины отказа по коду"
log_step "Acceptance: Flamy attribution is application-owned, not operator config"
# Оператор HY2XS не должен иметь возможности переназначить, куда ведёт
# подпись разработчика: ни через панель, ни через hy2xs.env, ни через таблицу
# `config`.
[ -f apps/frontend/src/constants/branding.ts ] \
|| fail "acceptance: внутренние константы бренда отсутствуют"
code_has apps/frontend/src/constants/branding.ts -F -- 'https://flamy.studio' \
|| fail "acceptance: адрес атрибуции не объявлен в константах бренда"
local flamy_carriers
flamy_carriers="$(grep -rl 'flamy\.studio' apps/frontend/src || true)"
[ "$flamy_carriers" = "apps/frontend/src/constants/branding.ts" ] \
|| fail "acceptance: адрес атрибуции размазан по исходникам панели: $flamy_carriers"
local flamy_config_hits
flamy_config_hits="$(grep -ril 'flamy' \
package/config package/templates orchestrator/src \
apps/model/constant apps/dao || true)"
[ -z "$flamy_config_hits" ] \
|| fail "acceptance: адрес атрибуции стал операторской настройкой. Найдено в: $flamy_config_hits"
log_step "Acceptance: frontend i18n does not touch Pinia at module import"
! grep -q 'useAppStore' apps/frontend/src/lang/index.ts || fail "acceptance: lang/index.ts must not import/use Pinia store"
@@ -631,7 +729,7 @@ run_clean_install_acceptance() {
log_step "Acceptance: legacy cleanup is a separate, explicit helper"
[ -f tools/legacy/purge-v0.sh ] || fail "acceptance: legacy cleanup helper is missing"
[ -f docs/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing"
[ -f docs/operations/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing"
! grep -q 'purge-v0' "$package_dir/install.sh" \
|| fail "acceptance: the installer must never run destructive cleanup on its own"
@@ -852,11 +950,14 @@ $piped_matcher"
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: прогон тестов админки не фиксируется результатом"
grep -q 'FRONTEND_TESTS_PASSED' tools/build/lib/package.sh \
|| fail "acceptance: прогон контрактных тестов панели не фиксируется результатом"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
for (const [fn, flag] of [
["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""],
["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""]
["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""],
["run_frontend_tests()", "FRONTEND_TESTS_PASSED=\"true\""]
]) {
const start = source.indexOf(fn);
if (start < 0) throw new Error("не найдена функция " + fn);
@@ -1785,14 +1886,29 @@ run_legacy_account_acceptance() {
done
log_step "Acceptance: v1 docs carry no previous-generation vocabulary"
# docs/14 — единственное место, где эти имена обозначают реальные объекты
# для удаления. В обычных docs их быть не должно.
# Руководство по очистке предыдущего поколения — единственное место, где эти
# имена обозначают реальные объекты для удаления. В остальных docs их быть не
# должно.
#
# Обход РЕКУРСИВНЫЙ. Раньше здесь стоял плоский `docs/*.md`, и это работало,
# пока документы лежали одной кучей в корне docs. После разнесения по
# тематическим каталогам такой шаблон совпадал бы ровно с одним файлом —
# docs/README.md, — то есть проверка отчитывалась бы зелёным, не заглянув
# почти никуда.
local doc
for doc in docs/*.md; do
while IFS= read -r doc; do
[ -n "$doc" ] || continue
case "$doc" in
docs/14-legacy-cleanup.md) continue ;;
docs/operations/14-legacy-cleanup.md) continue ;;
esac
! grep -q 'H_UI_' "$doc" \
|| fail "acceptance: previous-generation config keys leaked into $doc"
done
done <<EOF
$(find docs -type f -name '*.md' | sort)
EOF
# Каталог обязан быть непустым: `find` по опечатке в пути вернул бы пустой
# список, а пустой список для проверки «такого здесь нет» означает успех.
[ "$(find docs -type f -name '*.md' | wc -l)" -ge 10 ] \
|| fail "acceptance: обход документации нашёл подозрительно мало файлов"
}
+23
View File
@@ -63,6 +63,27 @@ run_orchestrator_tests() {
export ORCHESTRATOR_TESTS_PASSED
}
run_frontend_tests() {
# Контракты панели, которые не проверяются ни типами, ни сборкой bundle:
# цвет иконок в спрайте, совпадение словарей локализации, соответствие кодов
# ошибок серверным константам, единственность адреса атрибуции.
#
# Исполняет их Bun, уже закреплённый в versions.env, а не vitest. Причина не
# в удобстве: jsdom не вычисляет currentColor и визуальной корректности всё
# равно не доказал бы, зато vitest привёл бы в граф `pnpm audit` — а его
# порог считается по ВСЕМУ lock-файлу frontend — сотню транзитивных
# зависимостей ради нулевой дополнительной гарантии.
#
# Проверяемые модули (SvgIcon/symbol.ts, constants/branding.ts, словари)
# намеренно чистые: ни Vite, ни DOM в них нет, поэтому их можно выполнить вне
# браузера.
"$BUN_BIN" test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts \
|| fail "HY2XS admin frontend contract tests failed"
FRONTEND_TESTS_PASSED="true"
export FRONTEND_TESTS_PASSED
}
run_admin_tests() {
# `go:embed all:dist` требует собранных frontend-ассетов, поэтому эта
# функция должна вызываться только после bundle_ui.
@@ -177,6 +198,8 @@ write_metadata() {
|| fail "write_metadata: контракты оркестратора не проверялись; тесты обязательны для релизного пакета"
[ "${ADMIN_TESTS_PASSED:-false}" = "true" ] \
|| fail "write_metadata: контракты админки не проверялись; тесты обязательны для релизного пакета"
[ "${FRONTEND_TESTS_PASSED:-false}" = "true" ] \
|| fail "write_metadata: контракты панели не проверялись; тесты обязательны для релизного пакета"
{
printf 'name=HY2XS\n'
+1 -1
View File
@@ -217,7 +217,7 @@ verify_api_namespace_contract() {
|| fail "versions contract: post-install env template does not use ${HY2XS_HYSTERIA_MACHINE_AUTH_PATH}"
# Старое пространство имён не имеет права вернуться ни в один компонент.
# Историческое имя допустимо только в docs/14-legacy-cleanup.md и в
# Историческое имя допустимо только в docs/operations/14-legacy-cleanup.md и в
# legacy-маркерах clean-host: там это имя чужого артефакта, а не наше.
#
# Сканируются ТОЛЬКО runtime production sources и то, что уезжает в пакет.
+243
View File
@@ -0,0 +1,243 @@
import { describe, expect, test } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import { ERR_CODE, API_CODE } from "../../apps/frontend/src/utils/api-error";
import {
FLAMY_NAME,
FLAMY_URL,
} from "../../apps/frontend/src/constants/branding";
import ru from "../../apps/frontend/src/lang/package/ru";
import en from "../../apps/frontend/src/lang/package/en";
/**
* Контракты панели, которые нельзя проверить ни типами, ни сборкой bundle.
*
* Все три жили на честном слове: словари локализации расходились молча,
* коды ошибок существовали в двух местах без связи между ними, а адрес
* атрибуции ничто не удерживало от расползания по шаблонам.
*/
const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
const FRONTEND_SRC = path.join(REPO_ROOT, "apps", "frontend", "src");
function sourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...sourceFiles(full));
continue;
}
if (/\.(ts|vue|scss|js)$/i.test(entry.name)) {
out.push(full);
}
}
return out;
}
/** Коды причин, объявленные сервером в constant.ErrCode*. */
function serverErrorCodes(): Set<string> {
const source = fs.readFileSync(
path.join(REPO_ROOT, "apps", "model", "constant", "error.go"),
"utf8"
);
const codes = new Set<string>();
for (const match of source.matchAll(
/ErrCode[A-Za-z]+\s+string\s*=\s*"([^"]+)"/g
)) {
codes.add(match[1]);
}
return codes;
}
function leafKeys(value: unknown, prefix = ""): string[] {
if (typeof value !== "object" || value === null) {
return [prefix];
}
return Object.entries(value as Record<string, unknown>).flatMap(([key, v]) =>
leafKeys(v, prefix ? `${prefix}.${key}` : key)
);
}
describe("локализация", () => {
// Ключ, забытый в одном словаре, не ломает ни типы, ни сборку: vue-i18n
// молча отдаёт сам ключ, и оператор видит `error.code.min_length` вместо
// фразы. Единственное место, где это может быть замечено заранее, — здесь.
test("наборы ключей ru и en совпадают", () => {
const ruKeys = new Set(leafKeys(ru));
const enKeys = new Set(leafKeys(en));
expect([...ruKeys].filter((key) => !enKeys.has(key)).sort()).toEqual([]);
expect([...enKeys].filter((key) => !ruKeys.has(key)).sort()).toEqual([]);
});
// Панель выбирает фразу по коду ответа. Код без фразы доезжает до оператора
// серверным сообщением — это работает, но на языке сервера, а не панели.
test("у каждого известного кода ошибки есть фраза в обоих словарях", () => {
const missing: string[] = [];
for (const code of Object.values(ERR_CODE)) {
for (const [locale, dictionary] of [
["ru", ru],
["en", en],
] as const) {
const messages = (dictionary as any).error?.code ?? {};
if (typeof messages[code] !== "string") {
missing.push(`${locale}: error.code.${code}`);
}
}
}
expect(missing).toEqual([]);
});
test("коды ответа совпадают с серверными константами", () => {
const codeSource = fs.readFileSync(
path.join(REPO_ROOT, "apps", "model", "constant", "code.go"),
"utf8"
);
const declared = new Map<string, number>();
for (const match of codeSource.matchAll(
/(Code[A-Za-z]+)\s+int\s*=\s*(\d+)/g
)) {
declared.set(match[1], Number(match[2]));
}
expect(declared.get("CodeSuccess")).toBe(API_CODE.success);
expect(declared.get("CodeSysError")).toBe(API_CODE.systemError);
expect(declared.get("CodeInvalidError")).toBe(API_CODE.validationFailed);
expect(declared.get("CodeUnauthorizedError")).toBe(API_CODE.unauthorized);
expect(declared.get("CodeForbiddenError")).toBe(API_CODE.forbidden);
});
// Коды причин объявлены на сервере; панель обязана знать их под теми же
// именами. Расхождение здесь тихо отключает локализацию для целого класса
// отказов.
//
// Проверяются ОБА направления. Одного мало: направление «панель → сервер»
// ловит выдуманный код, но не ловит серверный код, о котором панель не
// знает, — а именно так добавляется новое правило. Ровно это здесь и
// случилось: коды min_length/max_length появились на сервере после того, как
// карта кодов панели была написана, и проверка в одну сторону молчала.
test("коды причин совпадают с серверными константами", () => {
const serverCodes = serverErrorCodes();
expect(serverCodes.size).toBeGreaterThan(0);
const unknownToServer = Object.values(ERR_CODE).filter(
(code) => !serverCodes.has(code)
);
expect(unknownToServer).toEqual([]);
const known = new Set<string>(Object.values(ERR_CODE));
expect([...serverCodes].filter((code) => !known.has(code)).sort()).toEqual(
[]
);
});
// Серверный код без фразы доезжает до оператора сообщением сервера — это
// работает, но на языке сервера, а не панели.
test("у каждого серверного кода есть фраза в обоих словарях", () => {
const missing: string[] = [];
for (const code of serverErrorCodes()) {
for (const [locale, dictionary] of [
["ru", ru],
["en", en],
] as const) {
const messages = (dictionary as any).error?.code ?? {};
if (typeof messages[code] !== "string") {
missing.push(`${locale}: error.code.${code}`);
}
}
}
expect(missing).toEqual([]);
});
});
describe("атрибуция Flamy", () => {
test("адрес объявлен один раз и ведёт на flamy.studio", () => {
expect(FLAMY_URL).toBe("https://flamy.studio");
expect(FLAMY_NAME).toBe("Flamy");
const carriers = sourceFiles(FRONTEND_SRC).filter((file) =>
fs.readFileSync(file, "utf8").includes("flamy.studio")
);
expect(carriers.map((file) => path.relative(REPO_ROOT, file))).toEqual([
path.join("apps", "frontend", "src", "constants", "branding.ts"),
]);
});
// Оператор HY2XS не должен иметь возможности переназначить, куда ведёт
// подпись разработчика. Проверяются все каналы, через которые значение
// могло бы стать настраиваемым.
test("адрес не является операторской настройкой", () => {
const operatorSurfaces = [
"package/config/hy2xs.env",
"package/templates/env/post-install.env.tpl",
"apps/model/constant/config.go",
];
for (const relative of operatorSurfaces) {
const source = fs.readFileSync(path.join(REPO_ROOT, relative), "utf8");
expect(source.toLowerCase()).not.toContain("flamy");
}
});
test("футер отрисован в боковом меню и учтён в его высоте", () => {
const sidebar = fs.readFileSync(
path.join(FRONTEND_SRC, "layout", "components", "Sidebar", "index.vue"),
"utf8"
);
expect(sidebar).toContain("<Footer");
// Высота области прокрутки обязана вычитать высоту футера, иначе пункты
// меню наезжают на подпись при длинном списке.
const styles = fs.readFileSync(
path.join(FRONTEND_SRC, "styles", "sidebar.scss"),
"utf8"
);
expect(styles).toContain("$sidebarFooterHeight");
const variables = fs.readFileSync(
path.join(FRONTEND_SRC, "styles", "variables.scss"),
"utf8"
);
expect(variables).toMatch(/\$sidebarFooterHeight:\s*\d+px/);
});
test("внешняя ссылка открывается безопасно", () => {
const footer = fs.readFileSync(
path.join(FRONTEND_SRC, "layout", "components", "Sidebar", "Footer.vue"),
"utf8"
);
const anchors = [...footer.matchAll(/<a\b[\s\S]*?>/g)].map((m) => m[0]);
expect(anchors.length).toBeGreaterThan(0);
for (const anchor of anchors) {
expect(anchor).toContain('target="_blank"');
expect(anchor).toContain('rel="noopener noreferrer"');
}
});
});
describe("секрет пира", () => {
// Прямой запрет из требований: генерация секрета принадлежит серверу.
// Панель, подставляющая значение в пустое поле, выполняла бы обещание
// «сгенерируем автоматически» ровно для одной двери из четырёх.
test("панель не генерирует секрет сама", () => {
const form = fs.readFileSync(
path.join(FRONTEND_SRC, "views", "peer", "list", "index.vue"),
"utf8"
);
expect(form).not.toMatch(/crypto\.getRandomValues/);
expect(form).not.toMatch(/Math\.random/);
// Подстановка значения в пустой секрет перед отправкой — тот же обход
// другими средствами.
expect(form).not.toMatch(/secret\s*=\s*dataForm\.secret\s*\|\|\s*["'`]/);
});
test("пустой секрет уезжает на сервер как есть", () => {
const form = fs.readFileSync(
path.join(FRONTEND_SRC, "views", "peer", "list", "index.vue"),
"utf8"
);
expect(form).toContain("await savePeerApi(dataForm)");
});
});
+312
View File
@@ -0,0 +1,312 @@
import { describe, expect, test } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import {
MULTICOLOR_ICONS,
SYMBOL_PREFIX,
findIconContractViolations,
iconName,
toSymbol,
} from "../../apps/frontend/src/components/SvgIcon/symbol";
/**
* Контракт спрайта локальных иконок.
*
* Почему тест лежит здесь, а не в apps/frontend. Во frontend нет тестового
* рантайма, и заводить его ради этой проверки не нужно: vitest с jsdom не
* вычисляет `currentColor` и визуальной корректности всё равно не доказал бы,
* зато привёл бы в граф `pnpm audit` (порог high по ВСЕМУ lock-файлу) сотню
* транзитивных зависимостей. Проверяемый модуль `SvgIcon/symbol.ts` чистый,
* поэтому его исполняет уже закреплённый в versions.env Bun — тот же, которым
* проверяется оркестратор.
*
* Тест не претендует на доказательство визуальной корректности: цвет на
* экране проверяется человеком и фиксируется в отчёте приёмки. Здесь
* закрепляется то, что машина проверить может, — что ни один ассет не задаёт
* цвет мимо `currentColor` и что обе половины контракта (ассет и CSS) на
* месте.
*/
const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
const FRONTEND_SRC = path.join(REPO_ROOT, "apps", "frontend", "src");
const ICONS_DIR = path.join(FRONTEND_SRC, "assets", "icons");
function iconFiles(): string[] {
return fs
.readdirSync(ICONS_DIR)
.filter((file) => file.toLowerCase().endsWith(".svg"))
.sort();
}
function readIcon(file: string): string {
return fs.readFileSync(path.join(ICONS_DIR, file), "utf8");
}
function readSource(relative: string): string {
return fs.readFileSync(path.join(REPO_ROOT, relative), "utf8");
}
describe("ассеты иконок", () => {
test("каталог иконок не пуст", () => {
expect(iconFiles().length).toBeGreaterThan(0);
});
// Регрессия. Восемь ассетов несли литеральный `fill="#000000"` на <path>:
// атрибут представления перебивает унаследованное CSS-свойство, поэтому
// объявленный в двух местах `fill: currentcolor` не действовал, и все семь
// иконок бокового меню рисовались чёрным по фону #181818.
test("каждый ассет соответствует контракту спрайта", () => {
const violations: string[] = [];
for (const file of iconFiles()) {
violations.push(
...findIconContractViolations(readIcon(file), iconName(file))
);
}
expect(violations).toEqual([]);
});
test("монохромные ассеты не содержат литеральных цветов", () => {
const offenders: string[] = [];
for (const file of iconFiles()) {
const name = iconName(file);
if (MULTICOLOR_ICONS.has(name)) {
continue;
}
if (/#[0-9a-f]{3,8}\b/i.test(readIcon(file))) {
offenders.push(name);
}
}
expect(offenders).toEqual([]);
});
// Список многоцветных — закрытое решение, а не свалка. Устаревшая запись в
// нём молча снимала бы проверку цвета с иконки, которой уже нет.
test("в списке многоцветных нет записей без ассета", () => {
const present = new Set(iconFiles().map(iconName));
for (const name of MULTICOLOR_ICONS) {
expect(present.has(name)).toBe(true);
}
});
// Многоцветный ассет обязан сохранять СВОИ цвета: общая нормализация к нему
// не применяется, и это утверждение проверяется на настоящем файле.
test("многоцветный ассет сохраняет собственную палитру", () => {
const download = readIcon("download.svg");
expect(download).toContain('fill="#00C97C"');
expect(findIconContractViolations(download, "download")).toEqual([]);
});
});
describe("контракт ассета", () => {
const OPEN = '<svg viewBox="0 0 24 24">';
test("литеральный fill у монохромной иконки — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<path fill="#000000" d="M0 0"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("мимо currentColor");
});
test("тот же ассет в списке многоцветных нарушением не является", () => {
expect(
findIconContractViolations(
`${OPEN}<path fill="#000000" d="M0 0"/></svg>`,
"download"
)
).toEqual([]);
});
test("currentColor, none и transparent разрешены", () => {
expect(
findIconContractViolations(
`${OPEN}<path fill="none" stroke="currentColor" d="M0 0"/>` +
`<rect fill="transparent"/></svg>`,
"mono"
)
).toEqual([]);
});
test("цвет в инлайновом style — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<path style="fill:#191919;opacity:.5" d="M0 0"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("инлайновый style");
});
test("непустой <style> внутри ассета — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<style>.a{fill:red}</style><path d="M0 0"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("<style>");
});
test("пустой <style> от редактора нарушением не является", () => {
expect(
findIconContractViolations(
`${OPEN}<defs><style type="text/css"></style></defs>` +
`<path d="M0 0"/></svg>`,
"mono"
)
).toEqual([]);
});
test("растровое <image> — нарушение", () => {
const violations = findIconContractViolations(
`${OPEN}<image href="data:image/png;base64,AA"/></svg>`,
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("<image>");
});
test("ассет без системы координат — нарушение", () => {
const violations = findIconContractViolations(
'<svg><path d="M0 0"/></svg>',
"mono"
);
expect(violations.length).toBe(1);
expect(violations[0]).toContain("viewBox");
});
test("отсутствие корневого <svg> — нарушение", () => {
expect(findIconContractViolations("не svg", "mono").length).toBe(1);
});
});
describe("сборка symbol", () => {
test("symbol получает id с общим префиксом и viewBox", () => {
const symbol = toSymbol(
'<svg viewBox="0 0 32 32"><path d="M0 0"/></svg>',
"user"
);
expect(symbol.startsWith(`<symbol id="${SYMBOL_PREFIX}-user"`)).toBe(true);
expect(symbol).toContain('viewBox="0 0 32 32"');
expect(symbol).toContain('<path d="M0 0"/>');
});
test("viewBox синтезируется из width/height, когда не объявлен", () => {
expect(
toSymbol('<svg width="128" height="128"><path/></svg>', "eye")
).toContain('viewBox="0 0 128 128"');
});
test("пролог, DOCTYPE и комментарии не уезжают в документ", () => {
const symbol = toSymbol(
'<?xml version="1.0"?><!DOCTYPE svg><!-- заметка -->' +
'<svg viewBox="0 0 24 24"><path/></svg>',
"report"
);
expect(symbol).not.toContain("<?xml");
expect(symbol).not.toContain("<!DOCTYPE");
expect(symbol).not.toContain("заметка");
});
test("каждый ассет даёт symbol с системой координат", () => {
for (const file of iconFiles()) {
const symbol = toSymbol(readIcon(file), iconName(file));
expect(symbol).toContain(`id="${SYMBOL_PREFIX}-${iconName(file)}"`);
expect(symbol).toContain("viewBox=");
}
});
});
describe("иконки, которые запрашивает приложение", () => {
// Имя иконки вычисляется в рантайме, поэтому опечатка в meta.icon или в
// icon-class не ломает ни типы, ни сборку: `<use>` просто не находит symbol
// и рисует пустоту.
test("каждое запрошенное имя существует как ассет", () => {
const present = new Set(iconFiles().map(iconName));
const requested = new Set<string>();
const router = readSource("apps/frontend/src/router/index.ts");
for (const match of router.matchAll(/\bicon:\s*"([^"]+)"/g)) {
requested.add(match[1]);
}
const vueFiles = [
"apps/frontend/src/views/login/index.vue",
"apps/frontend/src/layout/components/Navbar.vue",
"apps/frontend/src/components/LangSelect/index.vue",
];
for (const file of vueFiles) {
const source = readSource(file);
// Литеральное имя: `icon-class="user"`. Двоеточие впереди исключается —
// `:icon-class` содержит выражение, а не имя, и разбирается ниже.
for (const match of source.matchAll(/(?<![:\w-])icon-class="([^"]+)"/g)) {
requested.add(match[1]);
}
// Тернарный выбор имени: :icon-class="a ? 'x' : 'y'".
for (const match of source.matchAll(
/:icon-class="[^"]*?'([^']+)'\s*:\s*'([^']+)'/g
)) {
requested.add(match[1]);
requested.add(match[2]);
}
}
expect(requested.size).toBeGreaterThan(0);
expect([...requested].filter((name) => !present.has(name))).toEqual([]);
});
});
describe("вторая половина контракта — CSS", () => {
// Ассет без литерального цвета сам по себе цвета не даёт: он его
// НАСЛЕДУЕТ. Если правило `fill: currentcolor` исчезнет, иконки станут
// чёрными по инициальному значению SVG, и ни одна проверка выше этого не
// заметит.
test("SvgIcon объявляет fill: currentcolor", () => {
expect(
readSource("apps/frontend/src/components/SvgIcon/index.vue")
).toMatch(/fill:\s*currentcolor/i);
});
test("боковое меню не задаёт иконкам собственный цвет", () => {
expect(readSource("apps/frontend/src/styles/sidebar.scss")).toMatch(
/\.svg-icon\s*\{[^}]*fill:\s*currentcolor/i
);
});
// Прямой запрет из требований к исправлению: маскировать дефект pipeline
// фильтром или перекрашивать иконку по её имени нельзя.
test("нет CSS-фильтров и правил на имя иконки", () => {
const styleFiles = fs
.readdirSync(path.join(FRONTEND_SRC, "styles"))
.map((file) => `apps/frontend/src/styles/${file}`);
for (const file of [
...styleFiles,
"apps/frontend/src/components/SvgIcon/index.vue",
]) {
const source = readSource(file);
expect(source).not.toMatch(/\.svg-icon[^{]*\{[^}]*\bfilter\s*:/i);
expect(source).not.toMatch(/\[icon-class[~^*$|]?=/i);
}
});
// Проп цвета убран сознательно: он приглашал чинить цвет точечно, в обход
// общего контракта.
//
// Проверяется СИНТАКСИЧЕСКАЯ ФОРМА, а не подстрока. Комментарий в самом
// компоненте обязан называть убранное по имени — иначе он бесполезен, — и
// скан по тексту падал бы ровно на объяснении выполненной работы.
test("SvgIcon не принимает цвет параметром", () => {
const component = readSource(
"apps/frontend/src/components/SvgIcon/index.vue"
);
const useTag = component.match(/<use\b[^>]*>/);
expect(useTag).not.toBeNull();
expect(useTag![0]).not.toMatch(/\bfill\s*=/);
const propsBlock = component.match(/defineProps\(\{[\s\S]*?\n\}\)/);
expect(propsBlock).not.toBeNull();
expect(propsBlock![0]).not.toMatch(/\bcolor\s*:/);
});
});