diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 45c71c9..9806c30 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -179,34 +179,120 @@ jobs: go test -count=1 ./... - name: Снимок содержит числа, а не прочерки + # ЯЗЫК СНИМКА ЗАДАЁТСЯ, А НЕ НАСЛЕДУЕТСЯ, и это не мелочь оформления. + # + # Прежняя редакция шага брала снимок на языке машины и искала в нём + # РУССКИЕ слова с АНГЛИЙСКОЙ десятичной точкой («занято ЦП 10.7%») — + # сочетание, которого инструмент не печатает ни на одном языке с тех + # пор, как заговорил на двух: слова стали зависеть от языка, а + # разделитель дробной части — тоже (10,7% против 10.7%). На маковском + # бегунке LANG не задан, язык по умолчанию английский, и шаг три дня + # докладывал «НЕТ ЧИСЛА» о числах, стоявших в той же строке журнала. + # Числа при этом снимались и снимаются: их проверяет шаг выше, + # TestProcessesAreMeasured на живой машине. + # + # Поэтому здесь `--lang`, и обе редакции сразу: дерево обещает полный + # перевод, и снимок, полный по-русски и пустой по-английски, — + # сломанный снимок. + # + # Образцы ниже сверяются с печатью на Linux: host/snapshot_numbers_test.go + # читает ЭТОТ файл, печатает снимок, в котором всё замерено, и валится, + # если хоть один образец отсюда в нём не находится. Так строка здесь не + # разойдётся с переводом молча ещё раз. run: | cd host go build -o "$RUNNER_TEMP/digitdisk" . - "$RUNNER_TEMP/digitdisk" status --sample 300 | tee "$RUNNER_TEMP/status.txt" - fail=0 + mkdir -p "$RUNNER_TEMP/snapshot" "$RUNNER_TEMP/nothing" + for l in ru en; do + # Без трубы нарочно: `| tee` отдаёт код возврата tee, и упавший + # снимок доехал бы сюда пустым файлом и молчаливым нулём. + "$RUNNER_TEMP/digitdisk" status --sample 300 --lang "$l" > "$RUNNER_TEMP/snapshot/status.$l.txt" + cat "$RUNNER_TEMP/snapshot/status.$l.txt" + : > "$RUNNER_TEMP/nothing/status.$l.txt" + done + + # need — строка ОБЯЗАНА нести число: это то, что на маке снимается. + # named — строка ОБЯЗАНА назвать словом то, чего на маке взять нечем: + # непокрытое называется, а не выдаётся за поломку. + # Обе смотрят в $WHERE, чтобы одного и того же сторожа можно было + # натравить и на снимок, и на заведомо пустой файл. need() { - grep -qE "$1" "$RUNNER_TEMP/status.txt" || { echo "НЕТ ЧИСЛА: $2" >&2; fail=1; } + total=$((total + 1)) + grep -qE "$2" "$WHERE/status.$1.txt" || { echo "НЕТ ЧИСЛА [$1]: $3" >&2; bad=$((bad + 1)); } + } + named() { + total=$((total + 1)) + grep -qE "$2" "$WHERE/status.$1.txt" || { echo "НЕ НАЗВАНО [$1]: $3" >&2; bad=$((bad + 1)); } } - need 'занято ЦП [0-9]+\.[0-9]%' 'доля занятого процессора' - need 'свободно [0-9]' 'свободная память' - need 'кэш/буферы [0-9]' 'файловый кэш' - need 'доступно [0-9]' 'доступная память' - need 'закреплённая [0-9]' 'закреплённая память' - need 'сжатая [0-9]' 'сжатая память' - need 'потоков [0-9]' 'число потоков' - need 'выполняется [0-9]' 'сколько процессов работает' - need 'десятка по памяти' 'десятка по памяти' - need 'десятка по процессору' 'десятка по процессору' - exit $fail + guard() { + total=0 + bad=0 + need ru 'занято ЦП [0-9]+,[0-9]%' 'доля занятого процессора' + need ru 'свободно [0-9]' 'свободная память' + need ru 'кэш/буферы [0-9]' 'файловый кэш' + need ru 'доступно [0-9]' 'доступная память' + need ru 'закреплённая [0-9]' 'закреплённая память' + need ru 'сжатая [0-9]' 'сжатая память' + need ru 'потоков [0-9]' 'число потоков' + need ru 'выполняется [0-9]' 'сколько процессов работает' + need ru ' десятка по памяти:' 'десятка по памяти' + need ru '^ +[0-9]+ [^ ]+ +[0-9]+,[0-9] [КМГТ]иБ ' 'в десятке по памяти стоит размер' + need ru ' десятка по процессору:' 'десятка по процессору' + need ru '^ +[0-9]+ [^ ]+ +[0-9]+,[0-9]% ' 'в десятке по процессору стоит доля' + need en 'CPU busy [0-9]+\.[0-9]%' 'доля занятого процессора' + need en 'free [0-9]' 'свободная память' + need en 'cache/buffers [0-9]' 'файловый кэш' + need en 'available [0-9]' 'доступная память' + need en 'wired [0-9]' 'закреплённая память' + need en 'compressed [0-9]' 'сжатая память' + need en 'threads [0-9]' 'число потоков' + need en 'running [0-9]' 'сколько процессов работает' + need en ' top ten by memory:' 'десятка по памяти' + need en '^ +[0-9]+ [^ ]+ +[0-9]+\.[0-9] [KMGT]iB ' 'в десятке по памяти стоит размер' + need en ' top ten by processor:' 'десятка по процессору' + need en '^ +[0-9]+ [^ ]+ +[0-9]+\.[0-9]% ' 'в десятке по процессору стоит доля' + named ru 'НЕ ИЗМЕРЕНО .*заблокированные процессы' 'заблокированные процессы' + named ru 'НЕ ИЗМЕРЕНО .*датчики температуры' 'датчики температуры' + named en 'NOT MEASURED .*blocked processes' 'заблокированные процессы' + named en 'NOT MEASURED .*temperature sensors' 'датчики температуры' + # Приговор выносится по $bad ниже, а не кодом возврата: шаг идёт + # под `bash -e`, и сторож, который вернул бы ненулевой код на + # отрицательном контроле, оборвал бы прогон до самой проверки. + return 0 + } + + # ОТРИЦАТЕЛЬНЫЙ КОНТРОЛЬ, и он здесь ровно потому, что беда была + # именно такой: сторож, который не умеет упасть, три дня выглядел + # работающим. На пустом снимке обязан заругаться КАЖДЫЙ образец — + # не «хотя бы один»: образец, срабатывающий на пустоте, проверяет + # не число, а собственную формулировку. + WHERE="$RUNNER_TEMP/nothing" + guard 2>/dev/null + if [ "$bad" -ne "$total" ]; then + echo "На пустом снимке смолчали $((total - bad)) образцов из $total." >&2 + echo "Эти образцы ничего не проверяют — их надо чинить, а не зеленить." >&2 + exit 1 + fi + echo "отрицательный контроль: на пустом снимке ругаются все $total образцов" + + # А теперь приговор — по настоящему снимку. + WHERE="$RUNNER_TEMP/snapshot" + guard + echo "проверено образцов: $total, не нашлось: $bad" + exit $(( bad > 0 )) - name: В выводе нет инженерного дневника # Сторож против того, ради чего всё это и переписано: пользователю — - # число или прочерк, а объяснения — только по `--why`. + # число или прочерк, а объяснения — только по `--why`. Смотрит в ОБЕ + # редакции: имя системного вызова одинаково выглядит на любом языке, а + # приехать в снимок может через любую из двух половин словаря. run: | cd host - if grep -nE 'Mach|cgo|sysctl|IOKit|libproc|host_statistics|proc_pidinfo|kinfo_proc|KERN_PROCARGS' "$RUNNER_TEMP/status.txt"; then + if grep -nE 'Mach|cgo|sysctl|IOKit|libproc|host_statistics|proc_pidinfo|kinfo_proc|KERN_PROCARGS' \ + "$RUNNER_TEMP/snapshot/status.ru.txt" "$RUNNER_TEMP/snapshot/status.en.txt"; then echo "В снимке снова объяснения вместо чисел." >&2 exit 1 fi - "$RUNNER_TEMP/digitdisk" status --why + "$RUNNER_TEMP/digitdisk" status --why --lang ru + "$RUNNER_TEMP/digitdisk" status --why --lang en "$RUNNER_TEMP/digitdisk" status --json > /dev/null diff --git a/host/snapshot_numbers_test.go b/host/snapshot_numbers_test.go new file mode 100644 index 0000000..be8e7e8 --- /dev/null +++ b/host/snapshot_numbers_test.go @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: 2026 Marat Zimnurov +// SPDX-License-Identifier: BSD-2-Clause + +package main + +import ( + "bytes" + "os" + "regexp" + "testing" + + "digitdisk/internal/gpuinfo" + "digitdisk/internal/lang" + "digitdisk/internal/procfs" + "digitdisk/internal/report" + "digitdisk/internal/sysinfo" +) + +// ЗДЕСЬ СВЕРЯЕТСЯ НЕ ЧИСЛО, А СЛОВО, КОТОРЫМ СНИМОК ЭТО ЧИСЛО НАЗЫВАЕТ. +// +// Прогон .github/workflows/check.yml поднимает маковский бегунок, снимает +// снимок настоящей машины и ищет в нём образцы строк. Что числа на маке +// СНИМАЮТСЯ, доказывают тесты, которым нужен мак +// (internal/sysinfo/sysinfo_darwin_test.go); здесь доказывается второе — что +// образцы прогона совпадают с тем, что печать на самом деле печатает. +// +// БЕЗ ЭТОГО ТЕСТА ОБРАЗЕЦ И ПЕЧАТЬ РАЗОШЛИСЬ МОЛЧА И СТОЯЛИ ТАК ТРОЕ СУТОК. +// Образцы завели, когда снимок был только русским и печатал долю как «10.7%». +// Потом инструмент заговорил на двух языках: слова стали зависеть от языка, а +// разделитель дробной части — тоже («10,7%» по-русски). Образец «занято ЦП +// 10.7%» с тех пор не совпадал НИ С ОДНИМ языком, и шаг прогона докладывал +// «НЕТ ЧИСЛА» о числах, стоявших в той же строке журнала. Ствол был красен на +// каждом коммите, и ни одно настоящее падение под этим уже не было бы видно. +// +// Поэтому образцы читаются ИЗ ФАЙЛА ПРОГОНА, а не переписываются сюда: +// переписанная копия разошлась бы точно так же. Так же читается страница +// руководства в main_test.go — файл вне модуля, сверяемый с кодом. +// +// ЧЕГО ЭТОТ ТЕСТ НЕ ДОКАЗЫВАЕТ. Он печатает снимок, СОБРАННЫЙ ЗДЕСЬ, а не +// снятый с машины: он не может сказать, снимет ли мак эти числа. Это работа +// маковского бегунка и тестов с признаком darwin. Здесь — только про слова. + +// workflowFile — файл прогона. Он вне модуля, и кэш `go test` его не +// отслеживает, поэтому шаг «Проверки хозяина» зовёт тесты с `-count=1`. +const workflowFile = "../.github/workflows/check.yml" + +// sample — одна строка `need`/`named` из шага «Снимок содержит числа». +type sample struct { + kind string // need — строка обязана нести число; named — назвать непокрытое + lang lang.Lang + re *regexp.Regexp + name string +} + +var sampleLine = regexp.MustCompile(`(?m)^\s+(need|named) (ru|en) '([^']*)' '([^']*)'$`) + +func samples(t *testing.T) []sample { + t.Helper() + src, err := os.ReadFile(workflowFile) + if err != nil { + t.Fatalf("файл прогона не читается: %v", err) + } + found := sampleLine.FindAllStringSubmatch(string(src), -1) + out := make([]sample, 0, len(found)) + for _, m := range found { + l, ok := lang.Parse(m[2]) + if !ok { + t.Fatalf("в прогоне язык %q, а их два", m[2]) + } + // (?m) — потому что в прогоне образец отдаётся `grep -E`, а тот + // смотрит КАЖДУЮ строку отдельно. Без этого «^» в Go значил бы + // начало всего снимка, и образец, привязанный к началу строки, + // не сошёлся бы здесь, сходясь в прогоне. + re, err := regexp.Compile("(?m)" + m[3]) + if err != nil { + t.Fatalf("образец %q не разбирается: %v", m[3], err) + } + out = append(out, sample{kind: m[1], lang: l, re: re, name: m[4]}) + } + // Разбор, который ничего не нашёл, тихо зеленеет — а это и есть та + // беда, ради которой файл написан. Порог грубый нарочно: он ловит + // сломавшийся разбор, а не считает образцы. + if len(out) < 20 { + t.Fatalf("в %s нашлось %d образцов — разбор сломался", workflowFile, len(out)) + } + var needs, nameds int + for _, s := range out { + if s.kind == "need" { + needs++ + } else { + nameds++ + } + } + if needs == 0 || nameds == 0 { + t.Fatalf("нашлось need=%d, named=%d — обе половины обязаны быть", needs, nameds) + } + return out +} + +func printed(l lang.Lang, st sysinfo.Status) string { + var b bytes.Buffer + report.Status(&b, l, st) + return b.String() +} + +// naMac — снимок ровно такой, каким его собирает мак: замерено всё, что мак +// замеряет, и НАЗВАНО всё, чего маку взять нечем. Числа взяты из настоящего +// прогона на macos-latest, чтобы образцы проверялись на правдоподобных +// величинах, а не на удобных. +func onMac() sysinfo.Status { + busy := 83.7 + top := func(pid int, rss int64, cpu float64, cmd string) sysinfo.Proc { + c := cpu + return sysinfo.Proc{ + PID: pid, PPID: 1, User: "runner", UID: 501, State: "S", + Comm: "proc", Cmdline: cmd, Threads: 4, + RSSBytes: rss, VSizeBytes: uint64(rss) * 8, CPUPercent: &c, + } + } + ranking := []sysinfo.Proc{ + top(412, 150_786_048, 0.4, "/System/Library/CoreServices/Spotlight.app/Contents/MacOS/Spotlight"), + top(948, 126_672_896, 0.3, "/opt/hca/hosted-compute-agent"), + top(5529, 109_178_880, 0.2, "/Users/runner/actions-runner/extracted/bin/Runner.Worker"), + } + return sysinfo.Status{ + Host: sysinfo.Host{ + Hostname: "sat12.local", Distro: "macOS 26.6.2 (25G83)", + Model: "VirtualMac2,1", CPUModel: "Apple M1 (Virtual)", + KernelRelease: "25.6.0", Machine: "arm64", + }, + Load: sysinfo.Load{ + LoadAvg: procfs.LoadAvg{One: 4.19, Five: 12.23, Fifteen: 9.01}, + CPUCount: 3, BusyPercent: &busy, SampleMillis: 608, + }, + Memory: procfs.Memory{ + Total: 7_516_192_768, + Free: 236_874_137, + BuffCache: 3_543_348_838, + Available: 3_758_096_384, + Used: 3_758_096_384, + // Закреплённая и сжатая — маковские: на Linux этих строк в + // снимке нет вовсе, и образцы на них проверяются только здесь. + Raw: map[string]uint64{ + procfs.RawWired: 954_728_038, + procfs.RawCompressed: 489_053_388, + }, + Present: map[string]bool{ + procfs.FieldTotal: true, procfs.FieldFree: true, + procfs.FieldBuffCache: true, procfs.FieldAvailable: true, + procfs.FieldUsed: true, + }, + }, + Processes: sysinfo.Processes{ + Total: 559, Running: 14, Threads: 946, WithDetail: 315, + TopByMemory: ranking, TopByCPU: ranking, + }, + GPUs: []gpuinfo.Card{{Name: "Apple M1 (Virtual)"}}, + // Чего на маке взять нечем. Обе причины — не наша недоделка: + // показаний датчиков macOS не публикует вовсе, а состояние в записи + // о процессе не отделяет заблокированный от спящего. + Missing: map[string]lang.Phrase{ + sysinfo.FactSensors: lang.Say("macOS не публикует показания датчиков, а угадывать их формат нельзя"), + sysinfo.FactBlocked: lang.Say("macOS не различает заблокированные процессы среди спящих"), + }, + } +} + +// nothing — снимок машины, с которой не сняли ничего. Present пуст НАРОЧНО: +// nil-карта у procfs.Memory значит «замерено всё» (см. Memory.Has), и снимок +// с nil напечатал бы нули там, где замера не было. +func nothing() sysinfo.Status { + return sysinfo.Status{ + Memory: procfs.Memory{Present: map[string]bool{}}, + Missing: map[string]lang.Phrase{ + sysinfo.FactRunning: lang.Say("сколько процессов работает прямо сейчас, видно только по их потокам"), + sysinfo.FactThreads: lang.Say("самопроверка памяти процессов не сошлась — их память и потоки не публикуем"), + sysinfo.FactCPUBusy: lang.Say("ядро не дало счётчики процессорного времени"), + sysinfo.FactProcessRSS: lang.Say("самопроверка памяти процессов не сошлась — их память и потоки не публикуем"), + }, + } +} + +// TestОбразцыПрогонаСовпадаютСПечатью — положительный контроль: снимок мака +// собран, и КАЖДЫЙ образец шага прогона в нём находится. +func TestОбразцыПрогонаСовпадаютСПечатью(t *testing.T) { + st := onMac() + out := map[lang.Lang]string{lang.RU: printed(lang.RU, st), lang.EN: printed(lang.EN, st)} + for _, s := range samples(t) { + if !s.re.MatchString(out[s.lang]) { + t.Errorf("[%s] образец %s %q (%s) не нашёлся в снимке, который его обязан нести.\n"+ + "Прогон %s ищет эту строку на живом маке; печать её больше не печатает.\n%s", + s.lang, s.kind, s.re, s.name, workflowFile, out[s.lang]) + } + } +} + +// TestОбразцыЧиселНеСрабатываютНаНезамеренном — отрицательный контроль для +// `need`. Образец, который находится в снимке БЕЗ замеров, проверяет не число, +// а собственную формулировку: ровно так «выполняется [0-9]» сошёлся бы на +// «выполняется 0», которого никто не мерил. +func TestОбразцыЧиселНеСрабатываютНаНезамеренном(t *testing.T) { + st := nothing() + out := map[lang.Lang]string{lang.RU: printed(lang.RU, st), lang.EN: printed(lang.EN, st)} + for _, s := range samples(t) { + if s.kind != "need" { + continue + } + if s.re.MatchString(out[s.lang]) { + t.Errorf("[%s] образец need %q (%s) нашёлся в снимке, где не замерено НИЧЕГО.\n"+ + "Такой образец не отличит число от прочерка и зазеленеет на пустой машине.\n%s", + s.lang, s.re, s.name, out[s.lang]) + } + } +} + +// TestОбразцыНепокрытогоНеСрабатываютНаПолномСнимке — отрицательный контроль +// для `named`. Строка «НЕ ИЗМЕРЕНО» печатается, только когда есть чего не +// измерить; образец, находящийся и в полном снимке, ничего не сторожит. +func TestОбразцыНепокрытогоНеСрабатываютНаПолномСнимке(t *testing.T) { + st := onMac() + st.Missing = nil + out := map[lang.Lang]string{lang.RU: printed(lang.RU, st), lang.EN: printed(lang.EN, st)} + for _, s := range samples(t) { + if s.kind != "named" { + continue + } + if s.re.MatchString(out[s.lang]) { + t.Errorf("[%s] образец named %q (%s) нашёлся в снимке, где непокрытого нет вовсе.\n"+ + "Такой образец не проверяет, что непокрытое НАЗВАНО.\n%s", + s.lang, s.re, s.name, out[s.lang]) + } + } +} + +// TestНепокрытоеНаМакеНазваноСловами — то, ради чего заведена половина +// `named`: чего на маке взять нечем, обязано стоять в снимке СЛОВОМ, а не +// уезжать в молчание. Причина при этом остаётся под `--why` и в снимок не +// лезет — это отдельный шаг прогона «В выводе нет инженерного дневника». +func TestНепокрытоеНаМакеНазваноСловами(t *testing.T) { + st := onMac() + for _, l := range []lang.Lang{lang.RU, lang.EN} { + out := printed(l, st) + for _, fact := range []string{sysinfo.FactSensors, sysinfo.FactBlocked} { + word := l.Word(fact) + if !regexp.MustCompile(`(?m)^(НЕ ИЗМЕРЕНО|NOT MEASURED) .*` + regexp.QuoteMeta(word)).MatchString(out) { + t.Errorf("[%s] %q не названо в снимке словом:\n%s", l, word, out) + } + // И причина — не в снимке: она печатается по --why. + var why bytes.Buffer + report.Why(&why, l, st) + if why.Len() == 0 { + t.Errorf("[%s] --why ничего не сказал про %q", l, word) + } + } + } +}