Files
CyberGril/web/tests/browser_check.py
T

205 lines
12 KiB
Python

"""Actual Chromium checks against the production build. Requires Python Playwright.
Starts an ephemeral loopback HTTP server and always closes server/browser before exit.
"""
import functools
import http.server
import json
import sys
from pathlib import Path
import threading
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT.parents[0] / 'docs' / 'frontend' / 'screenshots'
OUT.mkdir(parents=True, exist_ok=True)
results = []
errors = []
requests = []
class Quiet(http.server.SimpleHTTPRequestHandler):
def log_message(self, *_): pass
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), functools.partial(Quiet, directory=str(ROOT / 'dist')))
thread = threading.Thread(target=server.serve_forever)
thread.start()
url = f'http://127.0.0.1:{server.server_port}'
def passed(text):
results.append(text)
print('PASS', text, flush=True)
def click(page, name, exact=True):
(page.locator('.demo > button') if name == '演示场景 · 非真实能力' else page.get_by_role('button', name=name, exact=exact)).click()
def nav(page, name):
page.get_by_role('navigation', name='主导航').get_by_role('button', name=name, exact=True).click()
page.wait_for_timeout(500)
if name == '设置' and page.viewport_size['width'] <= 720 and page.locator('.pane.open.has-detail').count():
page.locator('.pane.open .back').click()
def scenario(page, name):
click(page, '演示场景 · 非真实能力' if page.viewport_size['width'] > 720 else '演示场景 · 非真实能力')
click(page, name)
def shot(page, name):
page.wait_for_timeout(800)
if page.locator('.toast').count():
expect(page.locator('.toast')).not_to_be_visible(timeout=6000)
page.screenshot(path=str(OUT / f'{name}.png'))
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=['--no-sandbox'])
sizes=[(1440,900),(1280,800),(390,844)]
if len(sys.argv)>1: sizes=[size for size in sizes if str(size[0])==sys.argv[1]]
for width, height in sizes:
context = browser.new_context(viewport={'width': width, 'height': height}, reduced_motion='reduce')
page = context.new_page()
page.set_default_timeout(7000)
page.on('pageerror', lambda e: errors.append(str(e)))
page.on('request', lambda r: requests.append(r.url))
page.goto(url + '/?demo=1')
expect(page.get_by_role('button', name='演示场景', exact=False)).to_be_visible()
shot(page, f'{width}-creation-moon')
click(page, '下一步')
expect(page.get_by_role('alert')).to_contain_text('名字')
page.get_by_label('她的名字', exact=True).fill('小满')
page.get_by_label('出生日期(公历)', exact=True).fill('2020-01-01')
click(page, '下一步')
expect(page.get_by_role('alert')).to_contain_text('18')
page.get_by_label('出生日期(公历)', exact=True).fill('2000-10-18')
click(page, '下一步'); click(page, '下一步')
expect(page.get_by_role('button', name='朋友', exact=True)).to_have_attribute('aria-pressed', 'false')
expect(page.get_by_role('button', name='恋人', exact=True)).to_have_attribute('aria-pressed', 'false')
click(page, '下一步')
expect(page.get_by_role('alert')).to_contain_text('选择')
click(page, '朋友'); click(page, '下一步')
expect(page.get_by_role('button', name='确认创建', exact=True)).to_be_disabled()
page.get_by_role('checkbox').check()
scenario(page, '下次操作模拟失败'); click(page, '演示场景 · 非真实能力')
click(page, '确认创建')
expect(page.get_by_role('alert')).to_contain_text('资料都还在')
shot(page, f'{width}-creation-failure')
scenario(page, '关闭操作失败'); click(page, '演示场景 · 非真实能力')
click(page, '再试一次'); click(page, '去看看她')
expect(page.locator('.who .name')).to_have_text('小满')
page.reload()
expect(page.get_by_role('button', name='和她说话', exact=True)).to_be_visible()
expect(page.locator('.create')).to_have_count(0)
passed(f'{width}: ordinary entry, creation validation/failure/retry and reopen')
scenario(page, '载入三天记录'); click(page, '演示场景 · 非真实能力')
for theme, label in [('moon', '靛蓝月光'), ('fog', '蓝紫雾光')]:
click(page, label)
shot(page, f'{width}-home-{theme}')
nav(page, '日子')
if width <= 720 and page.locator('.pane.open.has-detail').count():
page.locator('.pane.open .back').click()
expect(page.locator('body')).not_to_contain_text('秘密散步')
expect(page.locator('body')).not_to_contain_text('私密地点')
click(page, '读到了书里的那片海', exact=False)
expect(page.get_by_role('heading', name='读到了书里的那片海')).to_be_visible()
shot(page, f'{width}-days-{theme}')
click(page, '留一盏小灯', exact=False)
expect(page.get_by_role('heading', name='留一盏小灯')).to_be_visible()
click(page, '读到了书里的那片海', exact=False)
click(page, '在交谈里看这段')
expect(page.locator('.log-panel')).to_be_visible()
shot(page, f'{width}-talk-{theme}')
click(page, '结束交谈 · 回到日子')
expect(page.get_by_role('heading', name='读到了书里的那片海')).to_be_visible()
nav(page, '设置')
page.get_by_role('navigation', name='设置分区').get_by_role('button', name='关系', exact=True).click()
page.get_by_label('她怎么称呼你', exact=True).fill('小伙伴')
click(page, '取消修改')
expect(page.get_by_label('她怎么称呼你', exact=True)).to_have_value('')
page.get_by_role('group', name='你们的关系').get_by_role('button', name='恋人', exact=True).click()
click(page, '保存')
expect(page.get_by_role('dialog')).to_contain_text('记忆与信任都保留')
click(page, '取消')
expect(page.get_by_role('group', name='你们的关系').get_by_role('button', name='恋人', exact=True)).to_have_attribute('aria-pressed', 'true')
click(page, '保存'); page.get_by_role('dialog').get_by_role('button', name='确认', exact=True).click()
expect(page.get_by_role('status').filter(has_text='已保存在本机演示')).to_be_visible()
shot(page, f'{width}-manage-{theme}')
page.get_by_role('group', name='你们的关系').get_by_role('button', name='朋友', exact=True).click()
click(page, '保存'); page.get_by_role('dialog').get_by_role('button', name='确认', exact=True).click()
expect(page.get_by_role('status').filter(has_text='已保存在本机演示')).to_be_visible()
nav(page, '此刻')
page.reload()
expect(page.locator('html')).to_have_attribute('data-theme', 'fog')
passed(f'{width}: both themes, home/talk/days/memory return, relation confirm/cancel/save')
# Direct private event deep link must not reveal anything, including its linked memory.
page.goto(url + '/?demo=1#event=secret'); nav(page, '日子')
expect(page.locator('article.detail')).to_contain_text('这一条现在打不开')
expect(page.locator('body')).not_to_contain_text('秘密散步')
nav(page, '交谈')
composer=page.get_by_role('textbox', name='想和她说的话')
composer.fill('第一轮'); click(page, '发送'); click(page, '停止回复')
page.wait_for_timeout(1700)
click(page, '完整记录')
expect(page.locator('.log-panel')).not_to_contain_text('我听见了。')
click(page, '关闭记录')
composer.fill('切出后旧回复'); click(page, '发送'); nav(page, '此刻')
page.wait_for_timeout(1600); nav(page, '交谈'); click(page, '完整记录')
expect(page.locator('.log-panel')).not_to_contain_text('我听见了。')
click(page, '关闭记录')
composer.fill('中文输入法')
composer.evaluate("el=>el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true,isComposing:true}))")
expect(composer).to_have_value('中文输入法')
composer.press('Shift+Enter'); expect(composer).to_have_value('中文输入法\n')
composer.fill('<img src=x onerror=alert(1)>')
composer.press('Enter'); page.wait_for_timeout(1600)
click(page, '完整记录')
expect(page.locator('.log-panel img')).to_have_count(0)
expect(page.locator('.log-panel')).to_contain_text('<img src=x onerror=alert(1)>')
click(page, '关闭记录')
passed(f'{width}: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text')
scenario(page, '下次操作模拟失败'); click(page, '演示场景 · 非真实能力')
composer.fill('失败重试'); click(page, '发送')
expect(page.locator('.talk-status .retry')).to_be_visible()
shot(page, f'{width}-send-failure')
scenario(page, '关闭操作失败'); click(page, '演示场景 · 非真实能力')
click(page, '重试'); page.wait_for_timeout(1600)
expect(page.locator('.talk-status .retry')).to_have_count(0)
click(page, '开始模拟语音'); click(page, '开始模拟')
expect(page.get_by_role('button', name='结束语音', exact=True)).to_be_in_viewport()
shot(page, f'{width}-voice')
click(page, '结束语音')
expect(page.locator('.voice-row')).to_have_count(0)
click(page, '视觉观察(模拟)'); click(page, '开始模拟')
expect(page.locator('.observe')).to_contain_text('未采集影像')
click(page, '视觉观察(模拟)')
composer.fill('断线保留草稿')
scenario(page, '模拟断线')
expect(composer).to_have_value('断线保留草稿')
expect(page.get_by_role('button', name='发送', exact=True)).to_be_disabled()
scenario(page, '下次操作模拟失败'); click(page, '演示场景 · 非真实能力')
click(page, '再试一次'); page.wait_for_timeout(900)
expect(page.locator('.netbar')).to_be_visible()
shot(page, f'{width}-offline-failure')
scenario(page, '关闭操作失败'); click(page, '演示场景 · 非真实能力')
click(page, '再试一次'); expect(page.locator('.netbar')).to_have_count(0)
expect(composer).to_have_value('断线保留草稿')
nav(page, '设置')
page.get_by_role('navigation', name='设置分区').get_by_role('button', name='城市', exact=True).click()
page.locator('.city-list button').filter(has_text='上海').click()
scenario(page, '下次操作模拟失败'); click(page, '演示场景 · 非真实能力')
click(page, '保存'); expect(page.get_by_role('alert')).to_contain_text('你的修改还在'); expect(page.get_by_role('alert')).to_be_in_viewport()
shot(page, f'{width}-save-failure')
scenario(page, '关闭操作失败'); click(page, '演示场景 · 非真实能力')
click(page, '重试保存'); expect(page.get_by_role('status').filter(has_text='已保存在本机演示')).to_be_visible()
expect(page.locator('.place')).to_contain_text('上海')
passed(f'{width}: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry')
nav(page, '此刻'); scenario(page, '模拟两天后返回')
expect(page.locator('.welcome')).not_to_contain_text('秘密')
shot(page, f'{width}-return')
click(page, '先待一会儿'); scenario(page, '模拟主动消息'); click(page, '回她')
expect(page.locator('.log-panel')).to_contain_text('她主动说的')
passed(f'{width}: privacy-filtered away summary and proactive message')
context.close()
assert not errors, errors
assert not [r for r in requests if not r.startswith(url)], requests
assert not [r for r in requests if '/api/' in r], requests
passed('No uncaught browser errors, API/external requests, microphone or camera requests')
browser.close()
finally:
server.shutdown(); server.server_close(); thread.join()
(ROOT.parents[0] / 'docs' / 'frontend' / (f'browser-results-{sys.argv[1]}.json' if len(sys.argv)>1 else 'browser-results.json')).write_text(json.dumps({'checks':results,'errors':errors,'requests':len(requests)},ensure_ascii=False,indent=2))