Files
portal/bots/yandex-creatives/test/runner.test.js
T
Дмитрий 3fdcd88ad3 feat реклама за показы: правда про документ, экран «ждёт разбора» и разбор своих ошибок
Задача 16 закрыта решением владельца. Дороги «робот везёт документ в кабинет Яндекса»
не существует — доказано двумя нарочными отказами, обычной тематикой и лицензируемой:
в окне отказа ноль полей для файла, документы Яндекс принимает только снаружи кабинета.

Приём документа оставлен, но портал больше не молчит: клиент приложил файл — в ленту сразу
ложится отметка «документ у нас, передать его Яндексу автоматически нельзя, при
необходимости отнесём сами и напишем здесь», а владельцу уходит письмо на адрес алертов.
Без письма обещание было бы пустым: файл просто лёг бы на диск. Сам файл письмом
не отправляем — это чужие бумаги. Обычный ответ без файла ни отметки, ни письма не даёт.

Экран «ждёт разбора» в админке: ручка была, экрана не было. Третья карточка на странице
«Реклама» — клиент, кампания, что робот делал человеческими словами, номер объявления
и на чём споткнулся. В подписи прямо сказано, чего там НЕ будет: обычных отказов,
их клиент разбирает сам.

Дальше — разбор собственной работы этого дня. Найдено четыре ошибки, все исправлены.

1. ТЯЖЁЛАЯ. Доклад разведки на бою уронил бы очередь заданий целиком. Робот пишет в ленту
   под служебной ролью, а у неё на этой таблице было только чтение. Отказ по правам,
   500 роботу, три повтора — и задание навсегда «в работе». Пока хоть одно задание
   в работе, выдача отвечает «работы нет» ВСЕМ клиентам. Лечение — запись схемы v9.17:
   право на запись плюс нумератор. В плане про это было написано прямым текстом,
   я прошёл мимо. Тесты поймать не могли: ходят суперпользователем.
2. Признак «набор создан» я выдумал: взял метку, которая в нашей же разметке описана
   как СКРЫТАЯ галочка. Проверка «видно ли её» не сработала бы никогда. Признак с экрана
   убран совсем: успех определяет портал слепком креативов, а «окно не закрылось» —
   это норма, так и есть живьём.
3. Сломал ленту для повторного отказа. Поменял защиту от дублей на «такой текст уже
   когда-либо был» — и клиент, починивший рекламу и получивший тот же отказ, не увидел бы
   ничего. Вернул сравнение с последним сообщением, а заглушку «причину выясняем» держит
   теперь сам джоб: показываем один раз, пока сказать нечего.
4. Мой собственный тест оказался пустышкой: оставался зелёным при вырезанной защите.
   В нём отклонялись ВСЕ объявления, а тогда кампания уходит в «отклонена» и обход её
   больше не берёт. Сценарий существует только при частичном отказе — тест переписан
   на два объявления и теперь вырезание защиты его роняет.

Портал 391/391, админские экраны 9/9, фронт на затронутых наборах 52/52, робот 74/74,
мест снятия заморозки денег по-прежнему четыре.
На боевой не выкатывалось, рубильник Директа выключен, живьём разведка не гонялась.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:10:18 +03:00

458 lines
21 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.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, utimesSync, writeFileSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runOnce } from '../src/runner.js';
const JOB = {
id: 7,
campaign_id: 42,
banners: [
{ banner_id: 1, width: 300, height: 250, file_url: 'https://lk.liderra.ru/f/1' },
{ banner_id: 2, width: 728, height: 90, file_url: 'https://lk.liderra.ru/f/2' },
],
};
async function withWorkDir(fn) {
const dir = await mkdtemp(join(tmpdir(), 'yc-runner-'));
try {
return await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function stubs({
job = null,
uploadThrows = null,
uploadResult = { modalClosed: false, cabinetSaid: '' },
loggedIn = true,
inspectThrows = null,
inspectResult = { report: 'Нет предупреждения: финансовые услуги', screenshotPath: '/tmp/shot.png' },
} = {}) {
const sent = [];
const reports = [];
const downloaded = [];
const uploads = [];
const inspected = [];
let opened = 0;
let closed = 0;
return {
sent,
reports,
downloaded,
uploads,
inspected,
opened: () => opened,
closed: () => closed,
portal: {
takeJob: async () => job,
downloadBanner: async (banner, path) => { downloaded.push(path); return path; },
reportDone: async (id) => reports.push({ ok: true, id }),
reportFailure: async (id, reason) => reports.push({ ok: false, id, reason }),
reportInspection: async (id, report, shot) => reports.push({ inspection: true, id, report, shot }),
},
browser: {
open: async () => {
opened += 1;
return { context: { close: async () => { closed += 1; } }, page: { screenshot: async () => {} } };
},
isLoggedIn: async () => loggedIn,
uploadCreatives: async (page, config, files) => {
uploads.push(files);
if (uploadThrows) throw new Error(uploadThrows);
return uploadResult;
},
readRejection: async (page, config, j, shotPath) => {
inspected.push({ adId: j.yandex_ad_id, shotPath });
if (inspectThrows) throw new Error(inspectThrows);
return inspectResult;
},
},
mailer: {
alarm: async (m) => sent.push({ kind: 'alarm', ...m }),
report: async (m) => sent.push({ kind: 'report', ...m }),
},
};
}
const opts = (workDir) => ({ timestamp: 't', workDir });
// Без пауз: в проверках ждать нечего, а повтор доклада порталу иначе тянул бы секунды.
const FAST_CONFIG = { humanDelayMs: 0, reportRetryDelayMs: 0 };
test('без работы ничего не делает и браузер не открывает', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: null });
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(res.idle, true);
assert.equal(s.opened(), 0);
assert.equal(s.reports.length, 0);
});
});
test('успешная загрузка отчитывается «готово» и шлёт письмо', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: JOB });
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(res.ok, true);
assert.deepEqual(s.reports, [{ ok: true, id: 7 }]);
assert.equal(s.sent[0].kind, 'report');
assert.equal(s.sent[0].count, 2);
});
});
test('в кабинет уходят ПУТИ к файлам, а не описания баннеров', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: JOB });
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
const given = s.uploads[0];
assert.equal(given.length, 2);
assert.ok(given.every((f) => typeof f === 'string'), `в кабинет ушло не то: ${JSON.stringify(given)}`);
assert.deepEqual(given, s.downloaded);
assert.ok(given[0].includes('300x250'));
});
});
test('файлы клиента живут в переданной рабочей папке', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: JOB });
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.ok(s.downloaded.every((p) => p.startsWith(workDir)), `файлы легли мимо: ${s.downloaded[0]}`);
});
});
/**
* 🪤 Прежний тест на это был ПУСТЫШКОЙ: он всегда передавал рабочую папку явно, поэтому
* значение по умолчанию не проверялось вообще. Проверено вырезанием: подмена умолчания
* на `process.cwd()` оставляла все тесты зелёными.
*
* А умолчание тут и есть вся суть: робота запускает расписание, каталог запуска у него
* может быть любым. Считай мы папки от него — картинки клиента и снимки экрана
* с логином и остатком счёта раскидывало бы по случайным местам сервера.
*
* Поэтому запускаем ИЗ ЧУЖОГО каталога и без явной рабочей папки.
*/
test('без явной рабочей папки файлы всё равно ложатся к роботу, а не в каталог запуска', async () => {
const alien = await mkdtemp(join(tmpdir(), 'yc-alien-'));
const wasCwd = process.cwd();
const robotRoot = fileURLToPath(new URL('..', import.meta.url));
const s = stubs({ job: JOB });
try {
process.chdir(alien);
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, { timestamp: 't' });
assert.ok(s.downloaded.length > 0, 'файлы должны были скачаться');
assert.ok(
s.downloaded.every((p) => p.startsWith(robotRoot)),
`файлы легли мимо папки робота: ${s.downloaded[0]}`,
);
assert.ok(
s.downloaded.every((p) => !p.startsWith(alien)),
`файлы легли в каталог запуска: ${s.downloaded[0]}`,
);
} finally {
process.chdir(wasCwd);
// Робот чистит downloads сам, а вот папку снимков экрана он создаёт и оставляет.
await rm(join(robotRoot, 'downloads'), { recursive: true, force: true });
await rm(join(robotRoot, 'screenshots'), { recursive: true, force: true });
await rm(alien, { recursive: true, force: true });
}
});
/**
* Снимки экрана копились навсегда. Это не просто мусор на диске: на снимке видна боковая
* панель кабинета — логин и остаток счёта. Чем дольше они лежат, тем больше на сервере
* копится того, что там лежать не должно. Свежие нужны — по ним разбирают вчерашний сбой,
* старые не нужны никому.
*/
test('старые снимки экрана убираются, свежие остаются', async () => {
await withWorkDir(async (workDir) => {
const shots = join(workDir, 'screenshots');
mkdirSync(shots, { recursive: true });
const old = join(shots, 'job-1-старый.png');
const fresh = join(shots, 'job-2-свежий.png');
writeFileSync(old, 'снимок');
writeFileSync(fresh, 'снимок');
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
utimesSync(old, monthAgo, monthAgo);
const s = stubs({ job: JOB });
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(existsSync(old), false, 'месячной давности снимок должен был уйти');
assert.equal(existsSync(fresh), true, 'свежий снимок нужен для разбора сбоя');
});
});
test('файлы клиента удаляются при любом исходе — это чужие картинки, копиться им нельзя', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: JOB, uploadThrows: 'кабинет упал' });
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(existsSync(join(workDir, 'downloads', 'job-7')), false);
assert.equal(s.closed(), 1, 'браузер должен закрыться даже при сбое');
});
});
test('сбой загрузки отчитывается сбоем и шлёт алярм с причиной', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: JOB, uploadThrows: 'кнопка не найдена' });
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(res.ok, false);
assert.equal(s.reports[0].ok, false);
assert.match(s.reports[0].reason, /кнопка не найдена/);
assert.match(s.sent[0].reason, /кнопка не найдена/);
});
});
test('слетевший вход не пытается грузить и отчитывается сбоем', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: JOB, loggedIn: false });
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(res.ok, false);
assert.equal(s.uploads.length, 0);
assert.match(s.reports[0].reason, /вход/i);
});
});
/**
* 🔴 Живьём окно загрузки НЕ закрывается: после «Создать» оно переключается на вкладку
* «Мои креативы» (cabinet-flow.md §5.2). Пока роботу это казалось бедой, он слал
* письмо-алярм на КАЖДОЙ удачной загрузке — а письмо, которое приходит всегда,
* перестают читать вовсе.
*
* 🔴 Настоящий сбой это не прячет: если креатив не появился, портал ловит это слепком
* creatives.get и закрывает задание сбоем — оно видно в админке «ждёт разбора».
*/
test('обычная удачная загрузка человека письмом НЕ дёргает', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({
job: JOB,
uploadResult: { modalClosed: false, cabinetSaid: 'Мои креативы 300×250' },
});
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(res.ok, true);
assert.deepEqual(s.reports, [{ ok: true, id: 7 }]);
assert.equal(
s.sent.some((m) => m.kind === 'alarm'),
false,
`на удачной загрузке ушло письмо-алярм: ${JSON.stringify(s.sent)}`,
);
});
});
/**
* Слова кабинета не теряем: они уезжают в результат прохода и попадают в журнал робота.
* Но письмом по ним человека НЕ зовём — приговор об успехе выносит портал по слепку
* креативов, а не робот по экрану.
*/
test('слова кабинета уносим в результат прохода, но письмом по ним не зовём', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({
job: JOB,
uploadResult: { modalClosed: false, cabinetSaid: 'Файл слишком большой' },
});
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(res.ok, true);
assert.equal(res.cabinetSaid, 'Файл слишком большой');
assert.deepEqual(s.reports, [{ ok: true, id: 7 }]);
assert.equal(s.sent.some((m) => m.kind === 'alarm'), false);
});
});
test('обрыв на докладе «готово» НЕ превращается в отчёт о сбое — креативы уже в кабинете', async () => {
await withWorkDir(async (workDir) => {
// Приговор об успехе выносит портал по слепку креативов. Если доклад «готово» не дошёл
// (502 от nginx, обрыв, таймаут), робот раньше падал в общий обработчик сбоя и слал
// «сбой» — на СВОЮ УДАЧНУЮ работу. Портал помечал задание сбойным, а креативы уже лежали
// в кабинете. Хуже того: если портал успел принять «готово», а ответ потерялся, «сбой»
// затирал правильный результат с уже проставленными номерами.
const s = stubs({ job: JOB });
s.portal.reportDone = async () => { throw new Error('Портал ответил 502 на /done'); };
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(
s.reports.filter((r) => r.ok === false).length,
0,
`доложен сбой об удачной работе: ${JSON.stringify(s.reports)}`,
);
const alarm = s.sent.find((m) => m.kind === 'alarm');
assert.ok(alarm, 'человека надо позвать письмом');
assert.match(alarm.reason, /502/);
assert.equal(res.ok, true, 'работа сделана: креативы в кабинете');
});
});
test('доклад «готово» повторяется, если с первого раза не прошёл', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: JOB });
let tries = 0;
s.portal.reportDone = async (id) => {
tries += 1;
if (tries < 3) throw new Error('обрыв связи');
s.reports.push({ ok: true, id });
};
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(tries, 3, `доклад пробовали ${tries} раз(а)`);
assert.deepEqual(s.reports, [{ ok: true, id: 7 }]);
assert.equal(res.ok, true);
});
});
// Письмо обязано честно сказать, что в кабинете уже лежат креативы. Пока оно было одно
// на все случаи и всегда утверждало «робот ничего не менял», человек читал письмо
// «окно загрузки не закрылось» и в кабинет не шёл — а идти надо: повторная заливка
// оставит дубли, вычистить которые можно только руками.
test('письма после заливки помечены «креативы уже в кабинете»', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: JOB, uploadResult: { modalClosed: false, cabinetSaid: 'что-то не так' } });
s.portal.reportDone = async () => { throw new Error('Портал ответил 502 на /done'); };
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
const alarms = s.sent.filter((m) => m.kind === 'alarm');
assert.ok(alarms.length > 0, 'человека надо позвать письмом');
assert.ok(alarms.every((m) => m.uploaded === true), 'письма после заливки обязаны быть помечены');
});
});
test('письмо о сбое ДО заливки не помечено «креативы уже в кабинете»', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: JOB, uploadThrows: 'кабинет упал' });
await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
const alarm = s.sent.find((m) => m.kind === 'alarm');
assert.ok(alarm);
assert.notEqual(alarm.uploaded, true, 'работа не сделана — врать в обратную сторону тоже нельзя');
});
});
test('портал не принял отчёт о сбое — человек узнаёт об этом из письма', async () => {
await withWorkDir(async (workDir) => {
// Раньше провал отчёта глотался молча. А это самый опасный исход: задание остаётся
// «в работе» навсегда, очередь встаёт колом для ВСЕХ клиентов, и никто об этом
// не знает — в письме написана только исходная причина сбоя.
const s = stubs({ job: JOB, uploadThrows: 'кнопка не найдена' });
s.portal.reportFailure = async () => { throw new Error('Портал ответил 422 на /done'); };
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(res.ok, false);
const alarm = s.sent.find((m) => m.kind === 'alarm');
assert.ok(alarm, 'письмо-алярм должно уйти');
assert.match(alarm.reason, /кнопка не найдена/, 'исходная причина должна остаться');
assert.match(alarm.reason, /422/, 'провал отчёта порталу должен быть виден человеку');
});
});
test('письмо не ушло — задание всё равно закрыто, робот не зависает', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: JOB, uploadThrows: 'кабинет упал' });
s.mailer.alarm = async () => { throw new Error('почта недоступна'); };
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(res.ok, false);
assert.equal(s.reports[0].ok, false);
});
});
// ── Разведка ──────────────────────────────────────────────────────────────────────────
const INSPECT_JOB = {
id: 9,
campaign_id: 42,
kind: 'inspect',
yandex_campaign_id: 713110757,
yandex_ad_id: 17787055204,
banners: [],
};
/**
* 🔑 Разведка — единственный способ узнать, за что Яндекс отклонил рекламу: машине он этого
* не говорит. Робот читает экран кабинета и приносит текст порталу, а тот кладёт его
* клиенту в ленту.
*/
test('разведка приносит порталу причину и снимок, а картинки не возит', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: INSPECT_JOB });
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(res.ok, true);
assert.equal(s.inspected.length, 1);
assert.equal(s.inspected[0].adId, 17787055204);
assert.equal(s.uploads.length, 0, 'разведка ничего не грузит');
assert.equal(s.downloaded.length, 0, 'разведке картинки клиента не нужны');
assert.equal(s.reports.length, 1);
assert.equal(s.reports[0].inspection, true);
assert.match(s.reports[0].report, /Нет предупреждения/);
});
});
/**
* 🔴 Робот не понял, что видит — задание сбойное, человека зовём письмом, а клиенту
* В ЛЕНТУ НИЧЕГО НЕ СОЧИНЯЕМ. Придуманная причина хуже молчания: по ней клиент пойдёт
* переделывать не то.
*/
test('разведка растерялась — сбой порталу и письмо, доклада в ленту нет', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: INSPECT_JOB, inspectThrows: 'окна причины нет' });
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(res.ok, false);
assert.equal(s.reports.length, 1);
assert.equal(s.reports[0].ok, false);
assert.match(s.reports[0].reason, /окна причины нет/);
assert.equal(s.reports.some((r) => r.inspection), false, 'доклад в ленту уходить не должен');
assert.ok(s.sent.some((m) => m.kind === 'alarm'), 'владельца надо позвать письмом');
});
});
/** Вход в кабинет слетел — в кабинет не лезем и ничего не докладываем как причину. */
test('разведка при слетевшем входе не читает экран', async () => {
await withWorkDir(async (workDir) => {
const s = stubs({ job: INSPECT_JOB, loggedIn: false });
const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir));
assert.equal(res.ok, false);
assert.equal(s.inspected.length, 0);
assert.equal(s.reports[0].ok, false);
});
});