feat: команда setup-event — создание и настройка события VK через браузер - #15
feat: команда setup-event — создание и настройка события VK через браузер#15djachenko wants to merge 35 commits into
Conversation
# Conflicts: # pyproject.toml # src/justin/shared/helpers/utils.py # src/justin/typer/setup_event_command.py # src/justin/typer/upload_command.py
The id of the event integration tests run against does not belong in the repository: they overwrite its settings.
Live and performance tests are deselected in addopts: the former need a browser with a VK session, the latter measure wall time and only add noise on a shared runner. pytest-timeout guards against a hung browser test.
dump_page.py replaces the throwaway exploration scripts: testids, inputs and buttons of any page, with an optional click before the dump.
The API has no such call, so it goes through the browser. Lifetime and use limit are picked by the value VK puts into the option id rather than by the label: labels are localised. The url is read from the list item's input — the same one the copy button reads — and the new link is found by diffing against a snapshot taken before creation.
VK re-laid out several pages and the code silently stopped applying settings: - messages moved to React — new selectors, and the choice is made by the hidden select's value instead of English labels; - the save button lost its group_save class — it is now matched by label (ru/en); - the date field became readonly — it is filled through the datepicker, the year stepped with arrows and the month picked by index; - access is submitted from the widget, not from the hidden field it mirrors: writing the field was silently dropped, so the option is clicked by position; - the city turned out to be a selector rather than a text field; - an unknown switch state is no longer read as enabled, and after a click the state is checked to have actually changed. The missing sections (services, chats, clips, articles, moments, products) and the main block are covered too — the latter is set by dragging a section to the top and confirming the dialog that follows.
Before dropping the dead create_event branch, what the schema lacked was moved out of it: waiting for a captcha (VK answers event creation with one every now and then), a page dump on timeout instead of a bare TimeoutException, jittered pauses with a global PACE multiplier, and event id parsing with a clear error. Step 3 is split into submit and event_id — the pair the tests expected.
_USE_SCHEMA and _USE_CREATION_SCHEMA were constants, so the legacy wizard and the legacy section setup never ran. Their helpers and the duplicates that had already moved into schemas go with them. set_sections now delegates to apply_settings, leaving a single implementation of sections. 399 lines -> 138.
Parity with the old SetupEventAction: event type, organiser, access, all 13 sections and the main block, collected in _default_settings.
The contract suite checks that the selectors the schemas rely on still exist on live pages, and fails naming the one that disappeared. Requests to VK are cut down: the cartesian product of parameters is replaced by each-choice coverage (56 tests -> 17), and a loaded page is reused between tests through the reading/editing/modal context managers — cleanliness is dropped on entry and restored only on a successful exit, so a failing test cannot hand the next one a dirty page. The test event id is read by a fixture: at module level it broke collection wherever .env is absent. Offline tests cover switch state, killing orphaned Chrome, the captcha and each-choice coverage; startup speed is measured separately.
Radio and Dropdown declared value: object and silenced it with a type: ignore that named the wrong error code and therefore did nothing. Both are now parametrised by an enum, and every schema declares which enum its control takes. get_attribute returns str | None: value is read through _value_of, which raises the same meaningful error on a missing attribute as on a wrong value — otherwise the diagnostics would be a TypeError.
Resolving patterns, building the photoset, running migrations and skipping non-photosets is the base class's job — five unchecked Photoset | None dereferences left along with the hand-rolled code. The manual mode stays a separate branch of run(). The folder is a positional argument now, as in the other pattern commands, instead of --folder.
requires-python promised 3.8 while the code needs 3.12 (PEP 695 generics in section_schema, X | None without the future import all over). Ruff read that floor and called our own syntax a parse error. Classifiers follow. Ruff is pinned to 0.15: 0.16 widened the default rule set, so an unpinned version turns CI red on code nobody touched — 532 errors against 15, 412 of them in files this branch never opened.
ruff --fix.
The private method was called from nowhere. Its mechanic — click a control and diff the DOM before and after, i.e. work out what the control does on its own — is written down in the auto-explorer backlog entry; the code stays in history.
5901cc3 to
6c53041
Compare
Eighteen flat modules are laid out in packages: the schema and the settings of
one feature sit together because they change together — VK re-lays out a page,
and the pair is edited, not every schema at once.
event_creation/ schema + settings
event_settings/ schema + settings
event_setup/ schema + settings
sections/ schema + settings
invite_link/ schema + settings
explorers/ page, settings, event_edit
shared/ custom_select, save_button, pacing, waiting
testpaths is limited to tests/, otherwise pytest picks up stray test_*.py from
the repository root.
A file name should be unique across the repository even when packages keep them apart: five identical schema.py are impossible to find by name and collide as soon as a file moves between packages.
CLAUDE.md still described the pre-src layout, listed shared/filesystem.py that moved to justin_utils, and knew nothing about browser/.
3cfe4a3 to
0cf24af
Compare
The floor said 0.0.1 while the code uses justin_utils.filesystem, .exif, .sources and .pylinq — the same kind of lie requires-python told.
Under a debugger the browser window is visible and a timed-out wait dumps the page; a plain run gets neither — headless, and no dump landing in the operator's lap mid-shoot. create_event stays visible either way: a captcha needs a human. Pauses come from per-purpose generators (BETWEEN_FIELDS, AFTER_ACTION, PAGE_SETTLE) instead of bare numbers, so equally meaningful delays still differ in length. The numbers themselves are empirical and now say so. The captcha wait moved to shared/ and takes the url tail that means the action did not go through, so it is no longer about event creation only.
Module-level generators were shared mutable state for no gain. varied() returns a value near the given one, pause() sleeps for it; the named constants stay plain numbers.
Every wait in the schemas was a bare time.sleep with a number nobody could explain. They now go through pause() with names that say what is being waited for; the drag-and-drop timings keep their own names because they are about how VKUI recognises a grab, not about the page. Layout follows the wizard schema: one argument per line, chained calls broken up, blank lines around the steps, guard clauses instead of nested bodies.
A fixed ratio range made every pause equally uncertain regardless of length. The deviation is now a share of the value passed in: 0.4 varies within 0.2..0.6, 2.0 within 1.0..3.0.
Shared pieces move out: page readiness, the save button, locator and wait sugar so that present(wait, by_testid(x)) replaces the three-level tuple. Sections go through one table instead of a chain of ifs; schemas are built once. Helpers sit above their callers. The drag is verified right after it happens, not only after the reload, and its steps vary in length and pace. Comparisons assigned to a name get parentheses; the ternary is gone; cta is spelled out.
The React select workaround is its own module with the reason written down; the event id comes out of the url path instead of a regex; the two fillable steps share a WizardStep base and run in one loop.
present(wait, by_testid(x)) replaces the three-level tuple across every schema and the contract tests; locators are values, not strings. The page dump on timeout lives in a WebDriverWait subclass that new_wait() hands out, so the sugar gets it without a wrapper at each call site. Helpers sit above their callers, no ternaries remain, and the invite-link constants are locators too.
shared/ grew from four modules to nine; the debug/production split is worth knowing before the first run; Folder lives in justin_utils since the src move.
| def set_value(self, value: str, driver: WebDriver, wait: WebDriverWait) -> None: | ||
| el = wait_or_explore(driver, wait, EC.element_to_be_clickable( | ||
| (By.CSS_SELECTOR, f'[data-testid="{self.test_id}"]'))) | ||
| ActionChains(driver).click(el).send_keys(value).perform() |
There was a problem hiding this comment.
А что, если это многострочником сделать?
There was a problem hiding this comment.
Сделано: цепочка ActionChains разбита построчно с \.
| el = wait_or_explore(driver, wait, EC.element_to_be_clickable( | ||
| (By.CSS_SELECTOR, f'[data-testid="{self.test_id}"]'))) | ||
| ActionChains(driver).click(el).send_keys(value).perform() | ||
| pause(0.5) |
There was a problem hiding this comment.
0.5 -- это эмпирическое?
There was a problem hiding this comment.
Да, подобрано вручную. Теперь это именованная константа AFTER_ACTION = 0.5 в shared/pacing.py, там же BETWEEN_FIELDS, AFTER_SCROLL, PAGE_SETTLE.
| if not value: | ||
| return | ||
| select_el = driver.find_element(By.CSS_SELECTOR, '[name="access"]') | ||
| driver.execute_script("arguments[0].click()", select_el) |
There was a problem hiding this comment.
А почему не select_el.click()?
There was a problem hiding this comment.
Обычный click в этих местах перехватывал оверлей визарда — Selenium кидал ElementClickIntercepted. JS-клик обходит это, но и события не эмулирует по-человечески. Переезд на настоящие клики — отдельная задача в бэклоге (26.08.31.refactor_browser_actions, 19 мест), делать после живой проверки.
| for aria_label, val in parts: | ||
| spinbuttons = driver.find_elements(By.CSS_SELECTOR, f'[aria-label="{aria_label}"]') | ||
| spinbutton = spinbuttons[self.n] | ||
| driver.execute_script("arguments[0].focus()", spinbutton) |
There was a problem hiding this comment.
Вообще, зачем все через arguments[0].action_name()?
There was a problem hiding this comment.
См. выше: там, где живой клик перехватывается, оставлен JS; остальное в бэклоге на переезд к настоящим действиям. У focus() та же причина — spinbutton в iframe не берёт фокус кликом.
| (By.XPATH, "//*[contains(text(), 'Enter end date')]") | ||
| )) | ||
| driver.execute_script("arguments[0].click()", end_date_btn) | ||
| pause(0.5) |
There was a problem hiding this comment.
Вертикальные отступы бы...
There was a problem hiding this comment.
Сделано: пустые строки между логическими блоками во всех схемах, не только тут.
| if config.posts is not None: | ||
| print(f" [posts] {config.posts}") | ||
| PostsSchema("wall")(config.posts, driver, wait) | ||
| if config.photos is not None: |
There was a problem hiding this comment.
Может, тут отступы вставить?
| PostsSchema("wall")(config.posts, driver, wait) | ||
| if config.photos is not None: | ||
| print(f" [photos] {config.photos}") | ||
| PhotosSchema("photos")(config.photos, driver, wait) |
There was a problem hiding this comment.
Я вот не знаю: насколько ок создавать схему, вызывать и сразу же отбрасывать?
There was a problem hiding this comment.
Схемы — frozen dataclass без состояния, инстанс ничего не стоит. Но сам список «создать-вызвать-выбросить» ушёл: секции теперь в таблицах _MODAL_SECTIONS/_LIST_SECTIONS на уровне модуля, цикл идёт по ним.
| print(f" [services] {config.services}") | ||
| ServicesSchema("services")(config.services, driver, wait) | ||
|
|
||
| for name, test_id in [("chats", "chats"), ("clips", "short_videos"), |
There was a problem hiding this comment.
Этот список просит константу.
There was a problem hiding this comment.
Сделано: _LIST_SECTIONS, рядом _MODAL_SECTIONS.
| # ── cta ───────────────────────────────────────────────────── | ||
|
|
||
| @staticmethod | ||
| def _apply_cta(event_id: int, s: CtaSettings, driver: WebDriver, wait: WebDriverWait) -> None: |
There was a problem hiding this comment.
Call-to-action — кнопка на шапке сообщества («Написать», «Перейти на сайт» и т.п.). Написано в докстринге _apply_cta.
| driver.get(f"https://vk.com/event{event_id}/settings/messages") | ||
| _wait_for_content(driver, wait) | ||
| _set_custom_select(driver, wait, _MESSAGES_SELECT, | ||
| _MESSAGES_ON if s.enabled else _MESSAGES_OFF) |
There was a problem hiding this comment.
Убран, if/else со значением.
Команда
setup-event: создание события VK и его настройка через браузер.Ветка пролежала с июня и не запускалась: отстала от master на 89 коммитов (master
переехал на
src/-layout), а VK за это время переверстал часть страниц настроек, икод молча их не применял.
Что чинилось в разметке VK
selectвместо английских подписей.group_save— ищется по подписи, обеим (ru и en).месяц берётся по индексу (названия локализованы).
чтение после перезагрузки этого не показывало, потому что значение и так было нужным —
дефект виден только переключением в противоположную сторону.
проверяется, что состояние действительно сменилось.
Что добавилось
Паритет со старым
SetupEventAction: тип события, организатор, доступ, все 13 секций иглавный блок (задаётся перетаскиванием секции наверх с подтверждением в диалоге).
Инвайт-ссылки — фичи нет в API, только через браузер:
VKBrowser.create_invite_link.Чистка
vk_browser.py— 399 строк → 138. Флаги_USE_SCHEMAи_USE_CREATION_SCHEMAбыликонстантами, поэтому легаси-визард и легаси-настройка секций не исполнялись никогда.
Перед сносом из них вынесено то, чего в схемах не было: ожидание капчи, дамп страницы
при таймауте, паузы с джиттером, разбор id с внятной ошибкой.
setup-eventпереехала наPatternCommand— разбор паттернов, построение фотосета иотсев не-фотосетов делает базовый класс. Папка задаётся позиционным аргументом вместо
--folder.Тесты и CI
Контракт селекторов: проверяет, что элементы, на которые опираются схемы, ещё есть на
живых страницах, и падает с именем пропавшего.
Запросы к VK ужаты примерно вчетверо: декартово произведение параметров заменено методом
уникальных значений (56 тестов → 17), страница переиспользуется между тестами.
Живые и перф-тесты отобраны маркерами в
addopts, id тестового события читаетсяфикстурой — на уровне модуля это роняло сборку тестов там, где нет
.env.requires-pythonподнят до честных 3.12 (PEP 695-дженерики,X | Noneбезfuture-импорта);
ruffзапинен на 0.15, как в остальных проектах экосистемы.Все три проверки зелёные:
pytest91 passed,ruff checkчисто,mypy src/— 135 файловбез ошибок.
Чего в этой ветке нет
интенсивных прогонов и отдаёт страницы с заголовком
Error. Не проверены закрытиемодалок по Escape, город через список, делегирование
set_sections.execute_script. Переход на настоящие — отдельная задача: онипроверяют видимость и перекрытие, то есть падают там, где JS молча делал вид, и каждый
из 19 нужно проверить на живом VK.
🤖 Generated with Claude Code