fix(rc5): harden frontend runtime and diagnostics
This commit is contained in:
+14
-9
@@ -63,15 +63,16 @@ Windows и macOS можно использовать для редактиров
|
||||
и сверяет каждый архив с контрольной суммой из `versions.env`.
|
||||
6. Выполняет `verify_versions_contract`: рассинхрон версий роняет сборку до создания tarball.
|
||||
7. Прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`).
|
||||
8. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт.
|
||||
9. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
|
||||
10. Копирует package skeleton.
|
||||
11. Собирает install-only orchestrator в standalone binary.
|
||||
12. Собирает frontend и backend HY2XS admin в Linux amd64 binary, проставляя версию админки через ldflags.
|
||||
13. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
|
||||
14. Записывает metadata и checksums.
|
||||
15. Создаёт `dist/hy2xs-install-<version>.tar.gz`.
|
||||
16. Проверяет архив и прогоняет acceptance-проверки.
|
||||
8. Рано прогоняет dependency-free контракты панели.
|
||||
9. Разрешает upstream-версию Hysteria, берёт ожидаемый SHA-256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт.
|
||||
10. Проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS.
|
||||
11. Копирует package skeleton и собирает install-only orchestrator.
|
||||
12. Устанавливает frozen frontend lock-граф, runtime-компилирует все сообщения RU/EN реальным `vue-i18n`, проверяет типы и собирает production bundle.
|
||||
13. Собирает backend HY2XS admin в Linux amd64 binary, проставляя версию через ldflags.
|
||||
14. Прогоняет `go vet` и `go test` для HY2XS admin (после сборки frontend, потому что `go:embed all:dist` требует готовых ассетов).
|
||||
15. Проверяет зависимости через `govulncheck ./...` и `pnpm audit` по всему lock-графу.
|
||||
16. Записывает metadata и checksums.
|
||||
17. Создаёт архив и прогоняет его acceptance-проверки.
|
||||
|
||||
## Контракт версий
|
||||
|
||||
@@ -99,6 +100,10 @@ Go-коде больше нет: она уже успела разъехатьс
|
||||
только политика `HYSTERIA_CHANNEL`, результат резолва — в
|
||||
[`hysteria-lock.env`](hysteria-lock.env)).
|
||||
|
||||
Security overrides frontend находятся в `apps/frontend/pnpm-workspace.yaml`:
|
||||
это канонический файл настроек pnpm. Поле `pnpm` в `package.json` для этой цели
|
||||
не используется, потому что новые версии package manager его игнорируют.
|
||||
|
||||
## Версия Hysteria: разрешение и compatibility gate
|
||||
|
||||
Builder не хранит версию Hysteria вручную. По умолчанию он определяет последнюю стабильную версию сам и замораживает её в пакете.
|
||||
|
||||
@@ -43,9 +43,9 @@ main() {
|
||||
log_step "Checking orchestrator contracts"
|
||||
run_orchestrator_tests
|
||||
|
||||
# Контракты панели проверяются рано: они не требуют ни собранного bundle, ни
|
||||
# установленных зависимостей, и падать на них после резолва Hysteria и сборки
|
||||
# артефактов означало бы платить минутами за ошибку, видимую сразу.
|
||||
# Dependency-free контракты панели проверяются рано. Runtime-компиляция
|
||||
# словарей запускается внутри bundle_ui сразу после frozen install, потому
|
||||
# что ей нужен реальный vue-i18n из frontend lock-графа.
|
||||
log_step "Checking HY2XS admin frontend contracts"
|
||||
run_frontend_tests
|
||||
|
||||
|
||||
@@ -1345,14 +1345,20 @@ $piped_matcher"
|
||||
|| fail "acceptance: прогон тестов оркестратора не фиксируется результатом"
|
||||
grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \
|
||||
|| fail "acceptance: прогон тестов админки не фиксируется результатом"
|
||||
grep -q 'FRONTEND_CONTRACT_TESTS_PASSED' tools/build/lib/package.sh \
|
||||
|| fail "acceptance: ранние контракты панели не фиксируются результатом"
|
||||
grep -q 'FRONTEND_RUNTIME_TESTS_PASSED' tools/build/lib/package.sh \
|
||||
|| fail "acceptance: runtime-компиляция локализации не фиксируется результатом"
|
||||
grep -q 'FRONTEND_TESTS_PASSED' tools/build/lib/package.sh \
|
||||
|| fail "acceptance: прогон контрактных тестов панели не фиксируется результатом"
|
||||
|| 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_frontend_tests()", "FRONTEND_TESTS_PASSED=\"true\""]
|
||||
["run_frontend_tests()", "FRONTEND_CONTRACT_TESTS_PASSED=\"true\""],
|
||||
["bundle_ui()", "FRONTEND_RUNTIME_TESTS_PASSED=\"true\""],
|
||||
["bundle_ui()", "FRONTEND_TESTS_PASSED=\"true\""]
|
||||
]) {
|
||||
const start = source.indexOf(fn);
|
||||
if (start < 0) throw new Error("не найдена функция " + fn);
|
||||
@@ -1366,6 +1372,19 @@ $piped_matcher"
|
||||
if (set < 0) throw new Error(fn + ": результат прогона не фиксируется");
|
||||
if (set < run) throw new Error(fn + ": результат объявляется раньше, чем получен");
|
||||
}
|
||||
|
||||
const bundleStart = source.indexOf("bundle_ui()");
|
||||
const bundleRest = source.slice(bundleStart);
|
||||
const bundleEnd = bundleRest.search(/\n\}[\r\n]/);
|
||||
const bundle = bundleEnd < 0 ? bundleRest : bundleRest.slice(0, bundleEnd);
|
||||
const install = bundle.indexOf("install --frozen-lockfile");
|
||||
const runtime = bundle.indexOf("test test/i18n-runtime.test.ts");
|
||||
const typecheck = bundle.indexOf("run typecheck");
|
||||
const build = bundle.indexOf("run build:prod");
|
||||
if (install < 0 || runtime < 0) throw new Error("runtime i18n gate не найден в bundle_ui");
|
||||
if (!(install < runtime && runtime < typecheck && typecheck < build)) {
|
||||
throw new Error("порядок frontend gate обязан быть install -> i18n runtime -> typecheck -> build");
|
||||
}
|
||||
' || fail "acceptance: утверждение о пройденных тестах обязано следовать за прогоном"
|
||||
|
||||
# Детектор гонок — часть прогона админки, а не пожелание.
|
||||
|
||||
@@ -64,7 +64,7 @@ run_orchestrator_tests() {
|
||||
}
|
||||
|
||||
run_frontend_tests() {
|
||||
# Контракты панели, которые не проверяются ни типами, ни сборкой bundle:
|
||||
# Ранние контракты панели, которые не требуют установленных зависимостей:
|
||||
# цвет иконок в спрайте, совпадение словарей локализации, соответствие кодов
|
||||
# ошибок серверным константам, единственность адреса атрибуции.
|
||||
#
|
||||
@@ -80,8 +80,8 @@ run_frontend_tests() {
|
||||
"$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
|
||||
FRONTEND_CONTRACT_TESTS_PASSED="true"
|
||||
export FRONTEND_CONTRACT_TESTS_PASSED
|
||||
}
|
||||
|
||||
run_admin_tests() {
|
||||
@@ -146,6 +146,9 @@ build_orchestrator() {
|
||||
bundle_ui() {
|
||||
local ui_src="${UI_SRC:-apps}"
|
||||
|
||||
[ "${FRONTEND_CONTRACT_TESTS_PASSED:-false}" = "true" ] \
|
||||
|| fail "bundle_ui: ранние контракты панели не проверялись"
|
||||
|
||||
rm -rf "$ADMIN_BUILD_DIR"
|
||||
mkdir -p "$ADMIN_BUILD_DIR"
|
||||
|
||||
@@ -164,6 +167,12 @@ bundle_ui() {
|
||||
|
||||
"$PNPM_BIN" install --frozen-lockfile
|
||||
|
||||
# Словари являются JavaScript-объектами и компилируются vue-i18n только
|
||||
# при вызове t(). Поэтому ни vue-tsc, ни Vite не видят синтаксическую
|
||||
# ошибку сообщения. Gate использует тот же установленный runtime, обходит
|
||||
# все строки обоих языков и выполняется до typecheck и production bundle.
|
||||
"$BUN_BIN" test test/i18n-runtime.test.ts
|
||||
|
||||
# Проверка типов идёт ПЕРЕД сборкой bundle, и это обязательный шаг релиза.
|
||||
#
|
||||
# Раньше её здесь не было вовсе: `build:prod` означал
|
||||
@@ -176,7 +185,7 @@ bundle_ui() {
|
||||
# кода, который не проходит проверку типов, нет.
|
||||
"$PNPM_BIN" run typecheck
|
||||
"$PNPM_BIN" run build:prod
|
||||
)
|
||||
) || fail "HY2XS admin frontend runtime tests or bundle failed"
|
||||
|
||||
# Версия админки приходит из versions.env через ldflags: один и тот же
|
||||
# исполняемый файл обязан знать версию пакета, из которого он собран.
|
||||
@@ -196,6 +205,12 @@ bundle_ui() {
|
||||
mkdir -p "$STAGE_DIR/ui/hy2xs-admin/docs/sql"
|
||||
install -m 0644 "$ui_src/docs/sql/schema.sql" "$STAGE_DIR/ui/hy2xs-admin/docs/sql/schema.sql"
|
||||
fi
|
||||
|
||||
# Оба утверждения выставляются только после dependency-free контрактов,
|
||||
# runtime-компиляции словарей и успешной сборки production bundle.
|
||||
FRONTEND_RUNTIME_TESTS_PASSED="true"
|
||||
FRONTEND_TESTS_PASSED="true"
|
||||
export FRONTEND_RUNTIME_TESTS_PASSED FRONTEND_TESTS_PASSED
|
||||
}
|
||||
|
||||
write_metadata() {
|
||||
|
||||
@@ -17,6 +17,13 @@ import {
|
||||
ADMIN_USERNAME_PATTERN,
|
||||
isValidAdminPassword,
|
||||
} from "../../apps/frontend/src/constants/credentials";
|
||||
import {
|
||||
PEER_NAME_CHARSET,
|
||||
PEER_NAME_MAX_LENGTH,
|
||||
PEER_NAME_MIN_LENGTH,
|
||||
PEER_NAME_PATTERN,
|
||||
PEER_NAME_PUNCTUATION,
|
||||
} from "../../apps/frontend/src/constants/peer";
|
||||
import ru from "../../apps/frontend/src/lang/package/ru";
|
||||
import en from "../../apps/frontend/src/lang/package/en";
|
||||
|
||||
@@ -310,9 +317,9 @@ describe("страницы журнала", () => {
|
||||
|
||||
// Без width/min-width Element Plus делит ширину практически поровну, и
|
||||
// сообщение — единственное содержимое журнала — получает треть строки.
|
||||
const columns = [...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g)].map(
|
||||
(match) => match[0]
|
||||
);
|
||||
const columns = [
|
||||
...viewer.matchAll(/<el-table-column[\s\S]*?(?:\/>|>)/g),
|
||||
].map((match) => match[0]);
|
||||
expect(columns.length).toBeGreaterThanOrEqual(3);
|
||||
for (const column of columns) {
|
||||
expect(column).toMatch(/\b(width|min-width)=/);
|
||||
@@ -408,7 +415,9 @@ describe("действия над пиром", () => {
|
||||
// вызов здесь больше не стоит, обязан называть его по имени — иначе он
|
||||
// бесполезен. Это тот же урок, что записан в code_without_comments в
|
||||
// acceptance.sh, и он повторился прямо на этой проверке.
|
||||
const confirms = [...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g)];
|
||||
const confirms = [
|
||||
...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g),
|
||||
];
|
||||
// Единственный вызов — внутри confirmAction, который переводит отмену в
|
||||
// обычное `false`.
|
||||
expect(confirms.length).toBe(1);
|
||||
@@ -523,7 +532,9 @@ describe("страница конфигурации Hysteria", () => {
|
||||
// Сам модуль view-модели удалён вместе с дефолтами: пока он существует,
|
||||
// его легко подключить обратно.
|
||||
expect(
|
||||
fs.existsSync(path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts"))
|
||||
fs.existsSync(
|
||||
path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts")
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -532,7 +543,12 @@ describe("страница конфигурации Hysteria", () => {
|
||||
test("страница не изображает редактируемую форму", () => {
|
||||
const source = vueCodeOf(configPage());
|
||||
|
||||
for (const editable of ["<el-input", "<el-switch", "<el-select", "v-model"]) {
|
||||
for (const editable of [
|
||||
"<el-input",
|
||||
"<el-switch",
|
||||
"<el-select",
|
||||
"v-model",
|
||||
]) {
|
||||
expect(source).not.toContain(editable);
|
||||
}
|
||||
});
|
||||
@@ -606,7 +622,15 @@ describe("страница конфигурации Hysteria", () => {
|
||||
expect(types).not.toMatch(/\bsecret\s*[?]?:/);
|
||||
// Секции, которых production-профиль не описывает, в модели ответа не
|
||||
// объявляются вовсе: они попадают в `drift` списком имён.
|
||||
for (const section of ["outbounds", "masquerade", "resolver", "sniff", "acl", "mimic", "realm"]) {
|
||||
for (const section of [
|
||||
"outbounds",
|
||||
"masquerade",
|
||||
"resolver",
|
||||
"sniff",
|
||||
"acl",
|
||||
"mimic",
|
||||
"realm",
|
||||
]) {
|
||||
expect(types).not.toContain(`${section}:`);
|
||||
}
|
||||
});
|
||||
@@ -651,10 +675,14 @@ describe("страница конфигурации Hysteria", () => {
|
||||
// существуют, «универсальный редактор Hysteria» отрастает заново.
|
||||
test("компоненты генерического редактора удалены", () => {
|
||||
for (const component of ["MapAdd", "ImputMultiple", "UnitSelect"]) {
|
||||
expect(fs.existsSync(path.join(FRONTEND_SRC, "components", component))).toBe(false);
|
||||
expect(
|
||||
fs.existsSync(path.join(FRONTEND_SRC, "components", component))
|
||||
).toBe(false);
|
||||
}
|
||||
expect(
|
||||
fs.existsSync(path.join(FRONTEND_SRC, "views", "hysteria", "list", "components"))
|
||||
fs.existsSync(
|
||||
path.join(FRONTEND_SRC, "views", "hysteria", "list", "components")
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -701,7 +729,9 @@ describe("контракт учётных данных администрато
|
||||
if (numeric) {
|
||||
return numeric[1];
|
||||
}
|
||||
const backticked = source.match(new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``));
|
||||
const backticked = source.match(
|
||||
new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``)
|
||||
);
|
||||
if (backticked) {
|
||||
return backticked[1];
|
||||
}
|
||||
@@ -716,22 +746,37 @@ describe("контракт учётных данных администрато
|
||||
// принял бы, и оператор остаётся снаружи с паролем, который на самом деле
|
||||
// подходит.
|
||||
test("границы и набор символов совпадают с Go-контрактом", () => {
|
||||
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength"));
|
||||
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength"));
|
||||
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength"));
|
||||
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength"));
|
||||
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(
|
||||
goConst("AdminUsernameMinLength")
|
||||
);
|
||||
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(
|
||||
goConst("AdminUsernameMaxLength")
|
||||
);
|
||||
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(
|
||||
goConst("AdminPasswordMinLength")
|
||||
);
|
||||
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(
|
||||
goConst("AdminPasswordMaxLength")
|
||||
);
|
||||
// Предел bcrypt — отдельная константа и отдельная сверка. Пока панель знала
|
||||
// только границу в символах, она предлагала оператору назначить пароль из
|
||||
// 64 кириллических букв (128 байт), сервер его принимал всеми проверками, а
|
||||
// хеширование отказывало — и оператор получал системную ошибку на штатной
|
||||
// смене пароля.
|
||||
expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(goConst("AdminPasswordMaxBytes"));
|
||||
expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(
|
||||
goConst("AdminPasswordMaxBytes")
|
||||
);
|
||||
expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset"));
|
||||
|
||||
const declared = goConst("adminUsernameCharacterClass");
|
||||
const mirrored = ADMIN_USERNAME_PATTERN.source
|
||||
.replace(/^\^\[/, "")
|
||||
.replace(new RegExp(`\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`), "");
|
||||
.replace(
|
||||
new RegExp(
|
||||
`\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`
|
||||
),
|
||||
""
|
||||
);
|
||||
expect(mirrored).toBe(declared);
|
||||
});
|
||||
|
||||
@@ -781,9 +826,8 @@ describe("контракт учётных данных администрато
|
||||
// Встроенных границ на пароле поэтому быть не должно вовсе: длина
|
||||
// принадлежит правилу, а не соседям по объекту.
|
||||
test("на пароле нет встроенных min/max от async-validator", async () => {
|
||||
const { adminPasswordFormRule } = await import(
|
||||
"../../apps/frontend/src/constants/credentials"
|
||||
);
|
||||
const { adminPasswordFormRule } =
|
||||
await import("../../apps/frontend/src/constants/credentials");
|
||||
|
||||
// Правило одно на обе формы, и границ как СВОЙСТВ правила у него нет: длина
|
||||
// живёт внутри валидатора, где её можно посчитать правильно.
|
||||
@@ -810,8 +854,12 @@ describe("контракт учётных данных администрато
|
||||
test("правило пароля считает code points и байты, а не UTF-16", () => {
|
||||
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи
|
||||
// ниже проверяли бы не то, что написано.
|
||||
expect(new TextEncoder().encode("я".repeat(36)).length).toBe(ADMIN_PASSWORD_MAX_BYTES);
|
||||
expect(new TextEncoder().encode("😀".repeat(18)).length).toBe(ADMIN_PASSWORD_MAX_BYTES);
|
||||
expect(new TextEncoder().encode("я".repeat(36)).length).toBe(
|
||||
ADMIN_PASSWORD_MAX_BYTES
|
||||
);
|
||||
expect(new TextEncoder().encode("😀".repeat(18)).length).toBe(
|
||||
ADMIN_PASSWORD_MAX_BYTES
|
||||
);
|
||||
|
||||
for (const accepted of [
|
||||
"a".repeat(ADMIN_PASSWORD_MIN_LENGTH),
|
||||
@@ -854,7 +902,9 @@ describe("контракт учётных данных администрато
|
||||
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
|
||||
// множество systemd, а не окрестность подозрительных значений.
|
||||
for (const accepted of [0xfdcf, 0xfdf0, 0xfffd, 0x10fffd]) {
|
||||
expect(isValidAdminPassword(`abcde${String.fromCodePoint(accepted)}`)).toBe(true);
|
||||
expect(
|
||||
isValidAdminPassword(`abcde${String.fromCodePoint(accepted)}`)
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// Именно тот случай, ради которого встроенные min/max убраны: три эмодзи —
|
||||
@@ -868,9 +918,8 @@ describe("контракт учётных данных администрато
|
||||
// обязательно». Сервер ведёт себя так же (DTO приводит пустой пароль к «не
|
||||
// задано» до проверки правил).
|
||||
test("пустой пароль остаётся на правиле required", async () => {
|
||||
const { adminPasswordFormRule } = await import(
|
||||
"../../apps/frontend/src/constants/credentials"
|
||||
);
|
||||
const { adminPasswordFormRule } =
|
||||
await import("../../apps/frontend/src/constants/credentials");
|
||||
const rule = adminPasswordFormRule("format");
|
||||
|
||||
const errors: (Error | undefined)[] = [];
|
||||
@@ -886,11 +935,15 @@ describe("контракт учётных данных администрато
|
||||
test("на пароль не навешен набор символов", () => {
|
||||
for (const form of [loginForm(), changePasswordForm()]) {
|
||||
const code = vueCodeOf(form);
|
||||
expect(code).not.toContain("ADMIN_USERNAME_PATTERN,\n message: t(\"credentials.passwordFormat\"");
|
||||
expect(code).not.toContain(
|
||||
'ADMIN_USERNAME_PATTERN,\n message: t("credentials.passwordFormat"'
|
||||
);
|
||||
expect(code).not.toMatch(/passwordPattern/);
|
||||
}
|
||||
// Правило логина — единственное место, где вообще упоминается набор.
|
||||
expect(vueCodeOf(changePasswordForm())).not.toContain("ADMIN_USERNAME_PATTERN");
|
||||
expect(vueCodeOf(changePasswordForm())).not.toContain(
|
||||
"ADMIN_USERNAME_PATTERN"
|
||||
);
|
||||
});
|
||||
|
||||
// Требование НАЗЫВАЕТСЯ, а не сообщается фактом нарушения: набор символов
|
||||
@@ -917,7 +970,8 @@ describe("контракт учётных данных администрато
|
||||
for (const placeholder of ["{min}", "{max}", "{charset}"]) {
|
||||
expect(reason).toContain(placeholder);
|
||||
}
|
||||
const passwordReason = (dictionary as any).error?.code?.admin_password_format ?? "";
|
||||
const passwordReason =
|
||||
(dictionary as any).error?.code?.admin_password_format ?? "";
|
||||
for (const placeholder of ["{min}", "{max}", "{maxBytes}"]) {
|
||||
expect(passwordReason).toContain(placeholder);
|
||||
}
|
||||
@@ -944,11 +998,15 @@ describe("индикация ошибки на форме входа", () => {
|
||||
const styles = loginForm();
|
||||
|
||||
// Индикация переехала на элемент, который оператор и видит полем.
|
||||
expect(styles).toMatch(/&\.is-error\s*\{[\s\S]*?border-color:\s*var\(--el-color-danger\)/);
|
||||
expect(styles).toMatch(
|
||||
/&\.is-error\s*\{[\s\S]*?border-color:\s*var\(--el-color-danger\)/
|
||||
);
|
||||
|
||||
// Штатная тень враппера гасится селектором, который повторяет чужой и
|
||||
// добавляет атрибут scoped-стиля, — то есть выигрывает по специфичности.
|
||||
expect(styles).toContain(":deep(.el-form-item__content .el-input__wrapper)");
|
||||
expect(styles).toContain(
|
||||
":deep(.el-form-item__content .el-input__wrapper)"
|
||||
);
|
||||
|
||||
// Победа специфичностью, а не силой: `!important` перекрыл бы и те
|
||||
// состояния, о которых правило ничего не знает.
|
||||
@@ -966,27 +1024,128 @@ describe("индикация ошибки на форме входа", () => {
|
||||
});
|
||||
|
||||
describe("подсказки формы пира", () => {
|
||||
const peerContract = () =>
|
||||
fs.readFileSync(
|
||||
path.join(REPO_ROOT, "apps", "service", "peer_import.go"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
function peerGoConst(name: string): string {
|
||||
const source = peerContract();
|
||||
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
|
||||
if (numeric) {
|
||||
return numeric[1];
|
||||
}
|
||||
const backticked = source.match(
|
||||
new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``)
|
||||
);
|
||||
if (backticked) {
|
||||
return backticked[1];
|
||||
}
|
||||
throw new Error(
|
||||
`константа ${name} не найдена в apps/service/peer_import.go`
|
||||
);
|
||||
}
|
||||
|
||||
test("frontend-контракт совпадает с серверным", () => {
|
||||
expect(String(PEER_NAME_MIN_LENGTH)).toBe(peerGoConst("PeerNameMinLength"));
|
||||
expect(String(PEER_NAME_MAX_LENGTH)).toBe(peerGoConst("PeerNameMaxLength"));
|
||||
expect(PEER_NAME_CHARSET).toBe(peerGoConst("PeerNameCharset"));
|
||||
expect(PEER_NAME_CHARSET.split(/\s+/).at(-1)).toBe(PEER_NAME_PUNCTUATION);
|
||||
|
||||
const declaredPattern = peerContract().match(
|
||||
/peerNamePattern\s*=\s*regexp\.MustCompile\(`([^`]+)`\)/
|
||||
);
|
||||
expect(declaredPattern).not.toBeNull();
|
||||
expect(PEER_NAME_PATTERN.source).toBe(declaredPattern![1]);
|
||||
});
|
||||
|
||||
test("форма использует общий контракт без собственных копий", () => {
|
||||
const form = readSource("views", "peer", "list", "index.vue");
|
||||
expect(form).toContain("@/constants/peer");
|
||||
expect(form).toContain("pattern: PEER_NAME_PATTERN");
|
||||
expect(form).toContain("min: PEER_NAME_MIN_LENGTH");
|
||||
expect(form).toContain("max: PEER_NAME_MAX_LENGTH");
|
||||
expect(form).toContain("charset: PEER_NAME_CHARSET");
|
||||
expect(form).toContain("punctuation: PEER_NAME_PUNCTUATION");
|
||||
expect(form).not.toMatch(/const\s+PEER_NAME_PATTERN\s*=/);
|
||||
expect(form).not.toContain('charset: "a-z A-Z 0-9');
|
||||
});
|
||||
|
||||
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
||||
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
||||
// пробовать разрешённые символы.
|
||||
test("подсказка имени описывает действующий набор символов", () => {
|
||||
// Набор символов объявлен на сервере ровно один раз; подсказка обязана
|
||||
// описывать именно его, а не набор из предыдущего поколения правила.
|
||||
const rule = fs.readFileSync(
|
||||
path.join(REPO_ROOT, "apps", "service", "peer_import.go"),
|
||||
"utf8"
|
||||
);
|
||||
const declared = rule.match(/PeerNameCharset\s*=\s*`([^`]+)`/);
|
||||
expect(declared).not.toBeNull();
|
||||
|
||||
// «a-z A-Z 0-9 !@#$%^&*()_+-=» -> «!@#$%^&*()_+-=»
|
||||
const punctuation = declared![1].split(/\s+/).at(-1)!;
|
||||
expect(punctuation).toContain("!@#");
|
||||
expect(PEER_NAME_PUNCTUATION).toContain("!@#");
|
||||
|
||||
for (const dictionary of [ru, en] as const) {
|
||||
const hint = (dictionary as any).peer?.form?.nameHint ?? "";
|
||||
expect(hint).toContain(punctuation);
|
||||
expect(hint).toContain("32");
|
||||
expect(hint).toContain("{punctuation}");
|
||||
expect(hint).toContain("{min}");
|
||||
expect(hint).toContain("{max}");
|
||||
expect(hint).not.toContain(PEER_NAME_PUNCTUATION);
|
||||
expect(hint).not.toContain(
|
||||
`${PEER_NAME_MIN_LENGTH} to ${PEER_NAME_MAX_LENGTH}`
|
||||
);
|
||||
expect(hint).not.toContain(
|
||||
`от ${PEER_NAME_MIN_LENGTH} до ${PEER_NAME_MAX_LENGTH}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("контракт Element Plus", () => {
|
||||
test("radio-компоненты не используют label как value", () => {
|
||||
const deprecated: string[] = [];
|
||||
|
||||
for (const file of sourceFiles(FRONTEND_SRC).filter((candidate) =>
|
||||
candidate.endsWith(".vue")
|
||||
)) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
for (const match of source.matchAll(
|
||||
/<el-radio(?:-button)?\b[\s\S]*?>/g
|
||||
)) {
|
||||
const tag = match[0];
|
||||
if (/\blabel\s*=/.test(tag) && !/\bvalue\s*=/.test(tag)) {
|
||||
deprecated.push(`${path.relative(REPO_ROOT, file)}: ${tag}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(deprecated).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("политика frontend-зависимостей", () => {
|
||||
test("security overrides находятся в читаемой pnpm конфигурации", () => {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(REPO_ROOT, "apps", "frontend", "package.json"),
|
||||
"utf8"
|
||||
)
|
||||
) as Record<string, unknown>;
|
||||
expect(manifest.pnpm).toBeUndefined();
|
||||
|
||||
const workspace = fs.readFileSync(
|
||||
path.join(REPO_ROOT, "apps", "frontend", "pnpm-workspace.yaml"),
|
||||
"utf8"
|
||||
);
|
||||
expect(workspace).toMatch(/^overrides:/m);
|
||||
for (const [dependency, resolution] of Object.entries({
|
||||
lodash: ">=4.18.1",
|
||||
"lodash-es": ">=4.18.1",
|
||||
"brace-expansion": ">=1.1.18",
|
||||
picomatch: ">=2.3.2",
|
||||
"js-yaml": ">=4.3.1",
|
||||
"table>ajv": ">=8.18.0",
|
||||
browserslist: "4.28.7",
|
||||
})) {
|
||||
const dependencyKey = dependency.includes(">")
|
||||
? `"${dependency}"`
|
||||
: dependency;
|
||||
expect(workspace).toContain(`${dependencyKey}: "${resolution}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user