Files
portal/tools/night/shoot.test.mjs
T

235 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Проверки снималки экранов (кусок 7, задача 5). Номера П — из плана
// 2026-08-01-kusok7-utro-priyomshchik-pervyy-boy.md, раздел «Список проверок словами».
// Мои: П66…П77 и П119. Проверки с пометкой Э — свои, названы в отчёте смены.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { execFileSync } from 'node:child_process';
import { PREDEL_ZHIVOGO_ZAPUSKA } from './predel-zhivogo-zapuska.mjs';
// 🔴 Живой `node` под нагрузкой семерых в 5000 мс не укладывается и краснеет от СОСЕДЕЙ,
// а не от поломки. Предел берётся ОБЩИЙ, а не своим набранным числом.
vi.setConfig({ testTimeout: PREDEL_ZHIVOGO_ZAPUSKA });
import { shotFileName, urlsFromEnv, shootUrls } from './shoot.mjs';
let outDir;
beforeEach(() => {
outDir = mkdtempSync(join(tmpdir(), 'night-shots-'));
});
afterEach(() => {
rmSync(outDir, { recursive: true, force: true });
});
// Подставной браузер. Живого браузера здесь не запускается ни одного: сторож
// `zhivoy-zapusk-v-proverkah` живой запуск в проверках без поимённого разрешения запрещает,
// а предмет этих проверок — правила снималки, а не сам браузер.
function podstavnoyBrauzer({ padaet_na = [], pishet = () => 'картинка', schyot = {} } = {}) {
schyot.zakryt = 0;
schyot.snyato = 0;
return {
schyot,
async snyat(url, put) {
schyot.snyato++;
if (padaet_na.includes(url)) throw new Error(`страница ${url} не открылась`);
const soderzhimoe = pishet(url);
if (soderzhimoe !== null) writeFileSync(put, soderzhimoe, 'utf8');
},
async close() { schyot.zakryt++; },
};
}
describe('shotFileName — имя картинки', () => {
it('П66: имя начинается с порядка двумя цифрами и кончается .png', () => {
const imya = shotFileName('https://localhost:8000/deals', 2);
expect(imya.startsWith('02-')).toBe(true);
expect(imya.endsWith('.png')).toBe(true);
expect(imya.length).toBeGreaterThan('02-.png'.length);
});
it('П67: два разных адреса с одним порядком дают РАЗНЫЕ имена', () => {
// 🔴 Совпади имена — второй снимок затёр бы первый, и владелец утром увидел бы
// один экран вместо двух, не узнав, что второго не было.
const a = shotFileName('https://localhost:8000/deals', 3);
const b = shotFileName('https://localhost:8000/clients', 3);
expect(a).not.toBe(b);
});
it('П68: адрес с косыми, вопросами и русскими буквами даёт годное имя файла', () => {
// 🔴 Windows: запрещены \ / : * ? " < > |. Имя с ними просто не запишется,
// и снимок пропадёт молча — а ночью это единственный глаз владельца.
const imya = shotFileName('https://localhost:8000/сделки?фильтр=да&кто=я', 1);
expect(imya).not.toMatch(/[\\/:*?"<>|]/);
expect(imya.endsWith('.png')).toBe(true);
// И имя не бесконечное: длинный адрес не должен упереться в предел пути Windows.
const dlinnoe = shotFileName('https://localhost:8000/' + 'а'.repeat(500), 1);
expect(dlinnoe.length).toBeLessThanOrEqual(120);
});
});
describe('urlsFromEnv — дело узнаётся только из окружения', () => {
it('П69: список через пробел', () => {
expect(urlsFromEnv({ NIGHT_SHOT_URLS: 'http://a/1 http://b/2' })).toEqual(['http://a/1', 'http://b/2']);
});
it('П70: список через запятую — то же самое', () => {
expect(urlsFromEnv({ NIGHT_SHOT_URLS: 'http://a/1,http://b/2' })).toEqual(['http://a/1', 'http://b/2']);
expect(urlsFromEnv({ NIGHT_SHOT_URLS: 'http://a/1, http://b/2' })).toEqual(['http://a/1', 'http://b/2']);
});
it('П71: переменной нет вовсе — пустой список, и НЕ бросает', () => {
expect(urlsFromEnv({})).toEqual([]);
expect(urlsFromEnv({ NIGHT_SHOT_URLS: '' })).toEqual([]);
expect(urlsFromEnv({ NIGHT_SHOT_URLS: ' ' })).toEqual([]);
expect(urlsFromEnv()).toEqual([]);
});
it('Э1 (сверх плана): боевой адрес в окружение не попадает молча', () => {
// 🔴 Снималка открывает страницы продукта. Боевой liderra.ru — живые клиенты и деньги;
// ночью его открывать нечем и незачем. Дать сюда боевой адрес молча — значит
// однажды пойти по нему семью работниками разом.
expect(() => urlsFromEnv({ NIGHT_SHOT_URLS: 'https://liderra.ru/deals' })).toThrow(/боев/i);
expect(() => urlsFromEnv({ NIGHT_SHOT_URLS: 'http://localhost:8000/a https://liderra.ru/b' })).toThrow(/боев/i);
// А местные адреса проходят.
expect(urlsFromEnv({ NIGHT_SHOT_URLS: 'http://localhost:8000/a' })).toEqual(['http://localhost:8000/a']);
expect(urlsFromEnv({ NIGHT_SHOT_URLS: 'http://127.0.0.1:8000/a' })).toEqual(['http://127.0.0.1:8000/a']);
});
});
describe('shootUrls — снимает и честно говорит, что не снялось', () => {
it('П72: каталог не назван — БРОСАЕТ ошибку', () => {
expect(() => shootUrls({ urls: ['http://localhost/a'], outDir: '', browser: podstavnoyBrauzer() }))
.toThrow();
});
it('П73: список адресов пуст — БРОСАЕТ ошибку', () => {
// 🔴 Пустой список — это не «снимать нечего», а «дело не пришло». Отдай здесь ноль
// снимков молча — и приёмщик сочтёт ночь снятой.
expect(() => shootUrls({ urls: [], outDir, browser: podstavnoyBrauzer() })).toThrow();
});
it('П74: снимок упал на первом адресе — браузер ВСЁ РАВНО закрыт', async () => {
// 🔴 Незакрытый браузер остаётся жить процессом на машине хозяйства до утра,
// а их за ночь семеро. Закрытие обязано случиться и на беде.
const schyot = {};
const b = podstavnoyBrauzer({ padaet_na: ['http://localhost/a'], schyot });
await shootUrls({ urls: ['http://localhost/a'], outDir, browser: b });
expect(schyot.zakryt).toBe(1);
});
it('П75: один снялся, второй упал — в shots один, в failed один', async () => {
const b = podstavnoyBrauzer({ padaet_na: ['http://localhost/b'] });
const r = await shootUrls({ urls: ['http://localhost/a', 'http://localhost/b'], outDir, browser: b });
expect(r.shots).toHaveLength(1);
expect(r.failed).toHaveLength(1);
// 🔴 Молчаливого «всё хорошо» нет: упавший адрес назван поимённо.
expect(JSON.stringify(r.failed)).toContain('http://localhost/b');
});
it('П119: снимком считается то, что ВПРАВДУ есть на диске и не пусто', async () => {
// 🔴 Главная проверка снималки. Браузер отчитывается словом; файл нулевой длины —
// это не снимок, а пустой лист, и владелец утром смотрел бы в пустоту, думая,
// что видел экран. Здесь то же правило, что у всей затеи: слову не верим, меряем.
const b = podstavnoyBrauzer({
pishet: (url) => {
if (url.endsWith('/a')) return 'настоящая картинка';
if (url.endsWith('/b')) return ''; // файл нулевой длины
return null; // не написано ничего
},
});
const r = await shootUrls({
urls: ['http://localhost/a', 'http://localhost/b', 'http://localhost/c'],
outDir, browser: b,
});
expect(r.shots).toHaveLength(1);
expect(r.shots[0]).toMatch(/a/);
expect(r.failed).toHaveLength(2);
const upavshie = JSON.stringify(r.failed);
expect(upavshie).toContain('http://localhost/b');
expect(upavshie).toContain('http://localhost/c');
// И тот единственный снимок вправду лежит на диске непустым.
expect(existsSync(r.shots[0])).toBe(true);
expect(statSync(r.shots[0]).size).toBeGreaterThan(0);
});
it('Э2 (сверх плана): браузер закрыт РОВНО ОДИН раз, и закрытие не роняет снималку', async () => {
// 🔴 Четвёртый вопрос вырезания: сколько разных состояний у закрытия? Их три —
// закрылся, упал при закрытии, не звали вовсе. Падение при закрытии не должно
// отменять уже снятые картинки: они лежат на диске и владельцу нужны.
const schyot = {};
const b = podstavnoyBrauzer({ schyot });
b.close = async () => { schyot.zakryt = (schyot.zakryt ?? 0) + 1; throw new Error('браузер не закрылся'); };
const r = await shootUrls({ urls: ['http://localhost/a'], outDir, browser: b });
expect(r.shots).toHaveLength(1);
expect(schyot.zakryt).toBe(1);
});
it('Э3 (сверх плана): один и тот же адрес дважды не затирает свой же снимок', async () => {
const b = podstavnoyBrauzer();
const r = await shootUrls({ urls: ['http://localhost/a', 'http://localhost/a'], outDir, browser: b });
expect(r.shots).toHaveLength(2);
expect(r.shots[0]).not.toBe(r.shots[1]);
});
});
describe('main(env) — живой запуск снималки отдельным процессом', () => {
it('П76: снимков вышло ноль — выход с НЕНУЛЕВЫМ кодом, и по названной причине', () => {
// 🔴 Живой запуск здесь ПРЕДМЕТ проверки: код возврата процесса подставкой не проверить —
// его выставляет сам Node при выходе, и именно по нему приёмщик понимает, что надо
// сходить второй раз. Ответь тут успехом — второго захода не будет никогда.
// 🪤 Разрешение поимённое: этому файлу сторож разрешил ровно `node` — программу,
// которая запускает наш же собственный файл и денег владельца не стоит.
const put = fileURLToPath(new URL('./shoot.mjs', import.meta.url));
let kod = 0;
let vyvod = '';
try {
vyvod = String(execFileSync(process.execPath, ['--input-type=module', '-e',
`import('${pathToFileURL(put).href}').then(m => m.main(process.env));`], {
env: { ...process.env, NIGHT_SHOT_URLS: 'http://localhost:8000/deals', NIGHT_SHOTS_DIR: outDir },
encoding: 'utf8',
}));
} catch (err) {
kod = err.status ?? 1;
vyvod = String(err.stdout ?? '') + String(err.stderr ?? '');
}
expect(kod).not.toBe(0);
// 🔴 И выход именно ПО ЭТОЙ причине, а не от падения раньше счёта: иначе проверка
// зеленела бы на любой поломке снималки.
expect(vyvod).toMatch(/снято 0 из 1/);
});
});
describe('🛡️ сторожа снималки', () => {
it('П77: браузер зовётся через поиск программы, голого имени в файле нет', () => {
// 🪤 Иглы собраны из кусочков нарочно: набери я их целиком — сторож живого запуска
// нашёл бы их в тексте ЭТОЙ проверки и счёл бы, что она сама запускает программы.
const tekst = readFileSync(new URL('./shoot.mjs', import.meta.url), 'utf8');
expect(tekst).toContain('resolve' + 'Bin');
// Голое имя программы = 334 оплаченных `spawn ENOENT`.
// 🪤 Имена собираются из кусочков нарочно: набери я их целиком — сторож поимённых
// разрешений нашёл бы их в тексте ЭТОЙ проверки и решил бы, что она сама запускает
// браузер. Поймано им же 03.08.2026.
for (const chasti of [['chro', 'me'], ['chro', 'mium'], ['np', 'x'], ['no', 'de']]) {
const goloe = "'" + chasti.join('') + "'";
expect(tekst, `голое имя ${goloe}`).not.toContain(goloe);
}
});
it('П96 (моя половина): имени платного помощника в снималке нет — она не жжёт запас владельца', () => {
const tekst = readFileSync(new URL('./shoot.mjs', import.meta.url), 'utf8');
expect(tekst.toLowerCase()).not.toContain('cla' + 'ude');
});
it('Э4 (сверх плана): в снималке нет ни одного боевого адреса', () => {
// 🔴 Тот же класс, что Э1, но со стороны кода: боевой адрес, зашитый в снималку,
// не виден ни в одной настройке и открылся бы каждую ночь.
const tekst = readFileSync(new URL('./shoot.mjs', import.meta.url), 'utf8');
expect(tekst).not.toContain('liderra.ru');
expect(tekst).not.toContain('crm.lead.store');
});
});