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 { const source = fs.readFileSync( path.join(REPO_ROOT, "apps", "model", "constant", "error.go"), "utf8" ); const codes = new Set(); 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).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(); 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(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(" { const footer = fs.readFileSync( path.join(FRONTEND_SRC, "layout", "components", "Sidebar", "Footer.vue"), "utf8" ); const anchors = [...footer.matchAll(//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)"); }); }); function readSource(...segments: string[]): string { return fs.readFileSync(path.join(FRONTEND_SRC, ...segments), "utf8"); } /** * Содержимое файла без комментариев. * * Проверять положено КОД, а не упоминания. Комментарий, объясняющий, почему * конструкции здесь больше нет, обязан называть её по имени, иначе он * бесполезен, — и проверка «такого вызова в файле нет» начинает падать ровно * на той документации, которая описывает выполненную ею же работу. * Отбрасываются строчные `//` и строки тела блочных комментариев `*`. */ function codeOf(source: string): string { return source .split("\n") .filter((line) => !/^\s*(\/\/|\/?\*)/.test(line)) .join("\n"); } const LOG_PAGES = [ ["views", "log", "system", "index.vue"], ["views", "log", "hysteria", "index.vue"], ]; describe("страницы журнала", () => { // Обе страницы были побайтово одинаковы и несли одни и те же три дефекта в // двух экземплярах: колонки без ширин, сетевой запрос ВНЕ try и пустой // catch. Починка в двух местах разошлась бы снова. test("обе страницы построены на общем LogViewer", () => { for (const page of LOG_PAGES) { const source = readSource(...page); expect(source).toContain("@/components/LogViewer/index.vue"); // Собственной таблицы у страницы быть не должно: ширины колонок живут в // одном месте, иначе они снова разъедутся. expect(source).not.toContain(" { const viewer = readSource("components", "LogViewer", "index.vue"); // Без width/min-width Element Plus делит ширину практически поровну, и // сообщение — единственное содержимое журнала — получает треть строки. const columns = [...viewer.matchAll(/|>)/g)].map( (match) => match[0] ); expect(columns.length).toBeGreaterThanOrEqual(3); for (const column of columns) { expect(column).toMatch(/\b(width|min-width)=/); } // Колонка сообщения растягивается за счёт остатка, служебные — фиксированы. expect(viewer).toMatch(/prop="msg"[\s\S]*?min-width="\d+"/); expect(viewer).toMatch(/prop="level"[\s\S]*?width="\d+"/); expect(viewer).toMatch(/prop="time"[\s\S]*?width="\d+"/); // Диагностический JSON Hysteria переносится, а не обрезается многоточием. expect(viewer).toContain("pre-wrap"); }); // Регрессия LOG-03. Было: // // const response = await exportLogApi(...); // запрос ВНЕ try // try { ... } catch (e) { /* empty */ } // // Отказ самого запроса этим catch не ловился вовсе, а всё внутри глушилось // молча: оператор не получал ни файла, ни причины. test("выгрузка журнала выполняется внутри try и сообщает об отказе", () => { const viewer = readSource("components", "LogViewer", "index.vue"); const exportCall = viewer.indexOf("await exportLogApi"); const tryBlock = viewer.indexOf("try {"); expect(exportCall).toBeGreaterThan(-1); expect(tryBlock).toBeGreaterThan(-1); expect(tryBlock).toBeLessThan(exportCall); // Пустой catch — это и есть проглатывание причины. expect(viewer).not.toMatch(/catch[^)]*\)?\s*\{\s*(\/\*[\s\S]*?\*\/)?\s*\}/); expect(viewer).toContain("log.exportFailed"); }); // Сборка ссылки на скачивание жила в трёх копиях и все три успели // разойтись. Копия обязана быть одна. test("скачивание файла собрано в одном месте", () => { const owners: string[] = []; for (const file of sourceFiles(FRONTEND_SRC)) { const source = fs.readFileSync(file, "utf8"); if (source.includes("createObjectURL")) { owners.push(path.relative(FRONTEND_SRC, file).replace(/\\/g, "/")); } } expect(owners).toEqual(["utils/download.ts"]); }); }); describe("действия над пиром", () => { const peerList = () => readSource("views", "peer", "list", "index.vue"); // Регрессия UX-06: пункта быстрого включения/отключения в меню не было // вовсе, хотя это самое частое действие оператора. test("меню строки содержит быстрое включение/отключение", () => { const source = peerList(); expect(source).toContain('command="toggle-disabled"'); expect(source).toContain("peer.enablePeer"); expect(source).toContain("peer.disablePeer"); }); // el-dropdown объявляет command штатным способом описывать меню действий: // команда приходит в одно место, и добавить пункт, забыв его подключить, // становится невозможно. test("меню строки построено на command, а не на click по пунктам", () => { const source = peerList(); expect(source).toContain("@command="); const items = [...source.matchAll(//g)].map( (match) => match[0] ); expect(items.length).toBeGreaterThanOrEqual(6); for (const item of items) { expect(item).toContain("command="); expect(item).not.toContain("@click"); } }); // Регрессия UX-07: подсказка без ограничения ширины растягивалась почти на // весь экран одной строкой. Ограничение ставится штатным popper-style, а не // глобальным CSS. test("широкая подсказка ограничена popper-style", () => { const source = peerList(); expect(source).toContain(":popper-style="); expect(source).toMatch(/maxWidth:\s*"\d+px"/); }); // Регрессия UX-09: ElMessageBox отклоняет промис при нажатии «Отмена», и // `await ElMessageBox.confirm(...)` без разбора отказа оставлял // необработанное отклонение промиса на каждую отмену. test("отмена подтверждения не считается ошибкой", () => { // Считается КОД, а не упоминания: комментарий, объясняющий, почему прямой // вызов здесь больше не стоит, обязан называть его по имени — иначе он // бесполезен. Это тот же урок, что записан в code_without_comments в // acceptance.sh, и он повторился прямо на этой проверке. const confirms = [...codeOf(peerList()).matchAll(/ElMessageBox\.confirm\(/g)]; // Единственный вызов — внутри confirmAction, который переводит отмену в // обычное `false`. expect(confirms.length).toBe(1); expect(peerList()).toContain("async function confirmAction"); expect(peerList()).toContain("await confirmAction("); }); // Частичный результат отзыва доступа — не отказ операции: запрет новых // подключений уже записан. Панель обязана отличать его по коду и обновлять // строку, а не показывать «не сработало». test("частичный результат отзыва доступа разбирается по коду", () => { const source = peerList(); expect(source).toContain("ERR_CODE.peerDisconnectFailed"); expect(source).toContain("ElMessage.warning"); }); // Срок временной блокировки называется оператору: раньше `Date.now() + час` // был зашит в обработчик и не сообщался ни до, ни после. test("временная блокировка подтверждается и называет срок", () => { const source = peerList(); expect(source).toContain("peer.kickConfirm"); expect(source).toContain("KICK_DURATION_MS"); }); // Об одном результате оператор узнаёт ОДИН раз. Действия строки сообщают // исход сами, поэтому общий перехватчик обязан молчать: иначе рядом с // предупреждением о частичном отзыве доступа встанет ошибка от перехватчика — // два противоречащих друг другу сообщения об одном событии. test("действия строки не получают второго тоста от перехватчика", () => { const api = codeOf(readSource("api", "peer", "index.ts")); const selfReporting = [ "deletePeerApi", "resetPeerTrafficApi", "releaseKickPeerApi", "kickPeerApi", "updatePeerApi", "savePeerApi", ]; for (const name of selfReporting) { const start = api.indexOf(`export function ${name}`); expect(start).toBeGreaterThan(-1); const rest = api.slice(start + 1); const end = rest.indexOf("\nexport function "); const body = end < 0 ? rest : rest.slice(0, end); expect(body).toContain("skipErrorToast: true"); } }); // Флаг относится только к отказу API. У транспортного отказа конверта нет, // страница о нём сказать ничего не может и молчит — а пока флаг гасил и эту // ветку, обрыв соединения при сохранении, удалении или отзыве доступа не // показывал оператору ничего вовсе. test("транспортный отказ показывается всегда", () => { const request = codeOf(readSource("utils", "request.ts")); const handler = request.slice(request.indexOf("(error: AxiosError)")); expect(handler).toContain("ElMessage.error(message)"); expect(handler).not.toContain("skipErrorToast"); }); }); describe("подсказки формы пира", () => { // Подсказка обещала более узкий набор символов, чем принимает сервер: она // осталась от правила, действовавшего до 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("!@#"); for (const dictionary of [ru, en] as const) { const hint = (dictionary as any).peer?.form?.nameHint ?? ""; expect(hint).toContain(punctuation); expect(hint).toContain("32"); } }); });