305 lines
17 KiB
Python
305 lines
17 KiB
Python
import os
|
|
import sys
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
import requests
|
|
import pytest
|
|
from playwright.sync_api import sync_playwright,expect
|
|
|
|
pytestmark=pytest.mark.skipif(os.environ.get('LAB_BROWSER_TESTS')!='1',reason='Enable LAB_BROWSER_TESTS=1 for real browser tests')
|
|
ROOT=Path(__file__).resolve().parents[1]
|
|
|
|
@pytest.fixture(scope='module')
|
|
def live(tmp_path_factory):
|
|
folder=tmp_path_factory.mktemp('live-server')
|
|
with socket.socket() as sock:
|
|
sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
|
|
env=dict(os.environ,LAB_TEST_PORT=str(port),LAB_TEST_DATA=str(folder/'data'),PYTHONPATH=str(ROOT/'src'),PYTHONIOENCODING='utf-8')
|
|
with (folder/'server.log').open('w',encoding='utf-8') as log:
|
|
process=subprocess.Popen([sys.executable,str(ROOT/'tests/browser_server.py')],env=env,stdout=log,stderr=log)
|
|
base=f'http://127.0.0.1:{port}'
|
|
try:
|
|
deadline=time.monotonic()+25
|
|
while time.monotonic()<deadline:
|
|
try:
|
|
if requests.get(base+'/healthz',timeout=.5).ok: break
|
|
except requests.RequestException: pass
|
|
if process.poll() is not None: raise RuntimeError((folder/'server.log').read_text('utf-8'))
|
|
time.sleep(.1)
|
|
else: raise RuntimeError('Server startup timeout')
|
|
yield base,folder
|
|
finally:
|
|
process.terminate()
|
|
try: process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired: process.kill();process.wait()
|
|
|
|
@pytest.fixture
|
|
def page():
|
|
with sync_playwright() as p:
|
|
channel=os.environ.get('PLAYWRIGHT_CHANNEL')
|
|
browser=p.chromium.launch(headless=True,executable_path=os.environ.get('PLAYWRIGHT_EXECUTABLE_PATH'),**({'channel':channel} if channel else {}))
|
|
yield browser.new_page(viewport={'width':1440,'height':1000})
|
|
browser.close()
|
|
|
|
def press(page,name,target):
|
|
page.get_by_role('button',name=name,exact=True).click()
|
|
expect(target).to_be_visible()
|
|
|
|
|
|
def test_teacher_import_and_publish(page,live):
|
|
base,folder=live
|
|
page.goto(base+'/login')
|
|
page.get_by_label('Логин',exact=True).fill('teacher');page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
|
page.get_by_role('button',name='Войти в кабинет').click()
|
|
expect(page.get_by_text('Коллекции данных',exact=True)).to_be_visible()
|
|
page.get_by_role('link',name='Загрузить данные',exact=True).click()
|
|
page.locator('input[type=file]').set_input_files({'name':'browser.csv','mimeType':'text/csv','buffer':b'id,answer\n001,7\n002,5'})
|
|
page.get_by_role('button',name='Проверить файл',exact=True).click()
|
|
expect(page.locator('#import-save')).to_be_enabled()
|
|
press(page,'Сохранить и настроить →',page.get_by_text('Включить это представление',exact=True))
|
|
page.locator('input[type=checkbox]').first.check()
|
|
expect(page.get_by_text('Поля таблицы / списка',exact=True)).to_be_visible()
|
|
press(page,'Сохранить черновик',page.get_by_text('Черновик сохранён',exact=True))
|
|
with page.expect_popup() as opened:
|
|
page.get_by_role('link',name='Предпросмотр ↗',exact=True).click()
|
|
expect(opened.value.locator('.record')).to_have_count(2)
|
|
opened.value.close()
|
|
press(page,'Опубликовать новую версию',page.get_by_text('Версия 1 · Доступна',exact=True))
|
|
url=page.get_by_role('link',name='HTML · BeautifulSoup ↗').first.get_attribute('href')
|
|
page.goto(base+url)
|
|
expect(page.locator('.record')).to_have_count(2)
|
|
assert page.locator('.field').all_text_contents()==['001','7','002','5']
|
|
|
|
@pytest.mark.parametrize('kind',['table','cards','messages','questionnaire'])
|
|
def test_views_filters_mobile_and_scrapers(page,live,kind):
|
|
base,folder=live
|
|
sys.path.insert(0,str(ROOT/'examples/parsers'))
|
|
from scrape_html import scrape as html_scrape
|
|
from common import extract
|
|
expected=html_scrape(f'{base}/html/browser-study/v/1/{kind}')
|
|
assert len(expected)==51 and expected[0]['fields'][0]['text']=='Person 001'
|
|
assert 'DO-NOT-PUBLISH' not in str(expected)
|
|
page.goto(f'{base}/interactive/browser-study/v/1/{kind}')
|
|
expect(page.locator('.record')).to_have_count(25)
|
|
for count in (50,51):
|
|
for attempt in range(3):
|
|
page.get_by_role('button',name='Загрузить ещё',exact=True).click()
|
|
try: expect(page.locator('.record')).to_have_count(count,timeout=1800);break
|
|
except AssertionError:
|
|
if attempt==2: raise
|
|
assert extract(page.content())==expected
|
|
page.get_by_label('Поиск в коллекции',exact=True).fill('WALK')
|
|
expect(page.locator('.record')).to_have_count(17)
|
|
page.set_viewport_size({'width':390,'height':844})
|
|
page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1')
|
|
page.screenshot(path=str(folder/f'{kind}-mobile.png'),full_page=True)
|
|
assert 'DO-NOT-PUBLISH' not in page.content()
|
|
|
|
def test_playwright_example(live):
|
|
base,_=live
|
|
sys.path.insert(0,str(ROOT/'examples/parsers'))
|
|
from scrape_interactive import scrape
|
|
records=scrape(f'{base}/interactive/browser-study/v/1/table',os.environ.get('PLAYWRIGHT_CHANNEL'))
|
|
assert len(records)==51 and [r['id'] for r in records]==[str(n) for n in range(1,52)]
|
|
|
|
|
|
def test_live_editor_and_mobile_catalog(page,live):
|
|
base,folder=live
|
|
page.goto(base+'/login')
|
|
page.get_by_label('Логин',exact=True).fill('teacher')
|
|
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
|
page.get_by_role('button',name='Войти в кабинет').click()
|
|
expect(page.locator('.catalog-title').filter(has_text='Browser study')).to_be_visible()
|
|
page.screenshot(path=str(folder/'catalog-desktop.png'),full_page=True)
|
|
page.get_by_role('link',name='Открыть Browser study',exact=True).click()
|
|
preview=page.locator('.editor-preview')
|
|
expect(preview.locator('.record')).to_have_count(5)
|
|
label=page.get_by_label('Подпись поля',exact=True).first
|
|
label.fill('Имя участника')
|
|
label.press('Tab')
|
|
expect(preview.locator('th').filter(has_text='Имя участника')).to_be_visible()
|
|
assert preview.evaluate('(e)=>e.getBoundingClientRect().right <= e.closest(".editor-layout").getBoundingClientRect().right + 1')
|
|
page.screenshot(path=str(folder/'editor-desktop.png'),full_page=True)
|
|
# Live edits have not changed the public snapshot.
|
|
response=page.request.get(base+'/html/browser-study/v/1/table')
|
|
assert response.ok and 'Имя участника' not in response.text()
|
|
page.set_viewport_size({'width':390,'height':844})
|
|
page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1',timeout=5000)
|
|
page.screenshot(path=str(folder/'editor-mobile.png'),full_page=True)
|
|
page.once('dialog',lambda dialog:dialog.accept())
|
|
page.get_by_role('link',name='Мои коллекции',exact=True).click()
|
|
expect(page.get_by_text('Коллекции данных',exact=True)).to_be_visible()
|
|
page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1')
|
|
page.screenshot(path=str(folder/'catalog-mobile.png'),full_page=True)
|
|
|
|
|
|
def test_collection_home_link_uses_http_page(page,live):
|
|
base,_=live
|
|
page.goto(base+'/interactive/browser-study/v/1/table')
|
|
expect(page.locator('.record')).to_have_count(25)
|
|
page.get_by_role('link',name='Parser Lab / Коллекции',exact=True).click()
|
|
expect(page.get_by_role('heading',name='Учебные коллекции данных',exact=True)).to_be_visible()
|
|
assert page.url==base+'/'
|
|
page.go_back()
|
|
expect(page.locator('.record')).to_have_count(25)
|
|
|
|
|
|
def test_logout_opens_http_login_page(page,live):
|
|
base,_=live
|
|
page.goto(base+'/login')
|
|
page.get_by_label('Логин',exact=True).fill('teacher')
|
|
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
|
page.get_by_role('button',name='Войти в кабинет').click()
|
|
expect(page.get_by_text('Коллекции данных',exact=True)).to_be_visible()
|
|
press(page,'Выйти',page.get_by_label('Пароль',exact=True))
|
|
assert page.url==base+'/login'
|
|
page.goto(base+'/admin')
|
|
expect(page.get_by_label('Пароль',exact=True)).to_be_visible()
|
|
|
|
|
|
@pytest.mark.parametrize('width',[1440,390])
|
|
def test_html_interactive_visual_parity(page,live,width):
|
|
base,folder=live
|
|
page.set_viewport_size({'width':width,'height':1000})
|
|
selectors=['.topbar','.brand','.public-main','.page-heading h1','.version-badge','.collection-meta','.filters','.filters input','.filters select','.record:first-child']
|
|
def metrics():
|
|
return page.evaluate('''selectors=>Object.fromEntries(selectors.map(selector=>{
|
|
const e=document.querySelector(selector);if(!e)return [selector,null];
|
|
const r=e.getBoundingClientRect(),s=getComputedStyle(e);
|
|
return [selector,{x:r.x,y:r.y,width:r.width,height:r.height,font:s.fontFamily,size:s.fontSize,color:s.color,background:s.backgroundColor}];
|
|
}))''',selectors)
|
|
page.goto(base+'/html/browser-study/v/1/table')
|
|
page.evaluate('document.fonts.ready')
|
|
expected=metrics()
|
|
page.screenshot(path=str(folder/f'html-parity-{width}.png'))
|
|
page.goto(base+'/interactive/browser-study/v/1/table')
|
|
expect(page.locator('.record')).to_have_count(25)
|
|
page.evaluate('document.fonts.ready')
|
|
actual=metrics()
|
|
page.screenshot(path=str(folder/f'interactive-parity-{width}.png'))
|
|
for selector in selectors:
|
|
assert actual[selector] is not None,selector
|
|
for key in (('x','y','width') if selector=='.public-main' else ('x','y','width','height')):
|
|
assert abs(actual[selector][key]-expected[selector][key])<=2,(selector,key,actual[selector],expected[selector])
|
|
for key in ('font','size','color','background'):
|
|
assert actual[selector][key]==expected[selector][key],(selector,key)
|
|
page.get_by_label('Поиск в коллекции',exact=True).fill('walk')
|
|
expect(page.locator('.record')).to_have_count(17)
|
|
page.get_by_role('link',name='Сбросить',exact=True).click()
|
|
expect(page.locator('.record')).to_have_count(25)
|
|
page.get_by_label('Категория',exact=True).select_option(label='Rest')
|
|
expect(page.locator('.collection-meta b')).to_have_text('34')
|
|
expect(page.locator('.record')).to_have_count(25)
|
|
page.get_by_role('button',name='Загрузить ещё',exact=True).click()
|
|
expect(page.locator('.record')).to_have_count(34)
|
|
|
|
|
|
def test_json_pointer_and_stale_import_preview(page,live):
|
|
base,_=live
|
|
page.goto(base+'/login')
|
|
page.get_by_label('Логин',exact=True).fill('teacher')
|
|
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
|
page.get_by_role('button',name='Войти в кабинет').click()
|
|
page.get_by_role('link',name='Загрузить данные',exact=True).click()
|
|
upload=page.locator('input[type=file]')
|
|
upload.set_input_files(str(ROOT/'examples/data/questionnaires.json'))
|
|
page.locator('#inspect').click()
|
|
expect(page.locator('#import-save')).to_be_enabled()
|
|
expect(page.get_by_label('Записи внутри JSON',exact=True)).to_have_value('/participants')
|
|
page.locator('#import-save').click()
|
|
expect(page.get_by_label('Представление',exact=True)).to_have_value('questionnaire')
|
|
expect(page.get_by_label('Структура анкеты',exact=True)).to_have_value('pairs')
|
|
expect(page.get_by_label('Массив ответов',exact=True)).to_have_value('/answers')
|
|
expect(page.get_by_label('Поле вопроса внутри ответа',exact=True)).to_have_value('/question')
|
|
expect(page.get_by_label('Поле ответа',exact=True)).to_have_value('/answer')
|
|
expect(page.locator('.editor-preview .record')).to_have_count(2)
|
|
expect(page.locator('.editor-preview .field-pair dd')).to_have_text(['8','Прогулка','true','5','null','false'])
|
|
expect(page.locator('.editor-preview h2')).to_have_text(['A01','A02'])
|
|
press(page,'Сохранить черновик',page.get_by_text('Черновик сохранён',exact=True))
|
|
page.get_by_role('link',name='Импорт файла',exact=True).click()
|
|
upload.set_input_files({'name':'valid.csv','mimeType':'text/csv','buffer':b'id,value\n1,2'})
|
|
page.locator('#inspect').click()
|
|
expect(page.locator('#import-save')).to_be_enabled()
|
|
upload.set_input_files({'name':'broken.json','mimeType':'application/json','buffer':b'{'})
|
|
expect(page.locator('#import-save')).to_be_disabled()
|
|
expect(page.locator('#import-result .record')).to_have_count(0)
|
|
page.locator('#inspect').click()
|
|
expect(page.locator('#status')).to_have_class('error')
|
|
expect(page.locator('#import-save')).to_be_disabled()
|
|
|
|
|
|
def test_dynamic_page_two_starts_from_requested_page(page,live):
|
|
base,_=live
|
|
page.goto(base+'/interactive/browser-study/v/1/table?page=2')
|
|
expect(page.locator('.record').first).to_have_attribute('data-record-id','26')
|
|
page.get_by_role('button',name='Загрузить ещё',exact=True).click()
|
|
expect(page.locator('.record')).to_have_count(26)
|
|
expect(page.locator('.record').last).to_have_attribute('data-record-id','51')
|
|
|
|
|
|
def test_repair_legacy_questionnaire_in_browser(page,live):
|
|
base,_=live
|
|
page.goto(base+'/login')
|
|
page.get_by_label('Логин',exact=True).fill('teacher')
|
|
page.get_by_label('Пароль',exact=True).fill('test-browser-password')
|
|
page.get_by_role('button',name='Войти в кабинет').click()
|
|
csrf=page.locator('meta[name=csrf-token]').get_attribute('content')
|
|
response=page.request.post(base+'/api/admin/import',headers={'origin':base,'x-csrf-token':csrf},multipart={'file':{'name':'questionnaires.json','mimeType':'application/json','buffer':(ROOT/'examples/data/questionnaires.json').read_bytes()},'root_confirmed':'1','title':'Legacy questionnaire'})
|
|
assert response.ok
|
|
page.goto(base+'/admin/datasets/'+response.json()['id'])
|
|
page.get_by_role('button',name='Настроить анкеты автоматически',exact=True).click()
|
|
expect(page.locator('.editor-preview .record')).to_have_count(2)
|
|
expect(page.locator('.editor-preview .field-pair dd')).to_have_text(['8','Прогулка','true','5','null','false'])
|
|
page.reload()
|
|
expect(page.locator('.editor-preview h2')).to_have_text(['A01','A02'])
|
|
page.get_by_role('button',name='Опубликовать новую версию',exact=True).click()
|
|
expect(page.get_by_text('Версия 1 · Доступна',exact=True)).to_be_visible()
|
|
|
|
|
|
def test_independent_detail_fields_and_delete_collection(page, live):
|
|
base, folder = live
|
|
page.goto(base + '/login')
|
|
page.get_by_label('Логин', exact=True).fill('teacher')
|
|
page.get_by_label('Пароль', exact=True).fill('test-browser-password')
|
|
page.get_by_role('button', name='Войти в кабинет').click()
|
|
page.goto(base + '/admin/import')
|
|
page.locator('input[type=file]').set_input_files({
|
|
'name': 'details.csv', 'mimeType': 'text/csv',
|
|
'buffer': b'name,bio,secret\nAlice,BIOGRAPHY,PRIVATE'})
|
|
page.get_by_role('button', name='Проверить файл', exact=True).click()
|
|
expect(page.locator('#import-save')).to_be_enabled()
|
|
page.locator('#import-save').click()
|
|
page.get_by_label('Включить это представление').check()
|
|
page.get_by_label('bio', exact=True).uncheck()
|
|
page.get_by_label('secret', exact=True).uncheck()
|
|
expect(page.locator('#live-preview')).not_to_contain_text('BIOGRAPHY')
|
|
page.get_by_label('Настройка полей').select_option('detail')
|
|
page.get_by_label('Отдельные поля полной записи').check()
|
|
page.get_by_label('bio', exact=True).check()
|
|
expect(page.locator('#live-preview')).to_contain_text('BIOGRAPHY')
|
|
expect(page.locator('#live-preview')).not_to_contain_text('PRIVATE')
|
|
page.get_by_role('button', name='Сохранить черновик', exact=True).click()
|
|
expect(page.locator('#status')).to_have_text('Черновик сохранён')
|
|
page.screenshot(path=str(folder / 'detail-editor.png'), full_page=True)
|
|
editor_url = page.url
|
|
page.reload()
|
|
page.get_by_label('Предпросмотр данных').select_option('detail')
|
|
expect(page.locator('#live-preview')).to_contain_text('BIOGRAPHY')
|
|
page.get_by_role('button', name='Опубликовать новую версию', exact=True).click()
|
|
public_url = page.get_by_role('link', name='HTML · BeautifulSoup ↗').get_attribute('href')
|
|
page.goto(base + public_url)
|
|
expect(page.locator('.lab-records')).not_to_contain_text('BIOGRAPHY')
|
|
page.locator('.detail-link').click()
|
|
expect(page.locator('.lab-records')).to_contain_text('BIOGRAPHY')
|
|
expect(page.locator('.lab-records')).not_to_contain_text('PRIVATE')
|
|
page.goto(editor_url)
|
|
page.once('dialog', lambda dialog: dialog.dismiss())
|
|
page.get_by_role('button', name='Удалить коллекцию', exact=True).click()
|
|
expect(page).to_have_url(editor_url)
|
|
page.once('dialog', lambda dialog: dialog.accept())
|
|
page.get_by_role('button', name='Удалить коллекцию', exact=True).click()
|
|
expect(page).to_have_url(base + '/admin')
|
|
assert page.request.get(base + public_url).status == 404
|