235 lines
14 KiB
JavaScript
235 lines
14 KiB
JavaScript
// Проверки снималки экранов (кусок 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');
|
||
});
|
||
});
|