355 lines
24 KiB
Python
355 lines
24 KiB
Python
"""HEL-783 foreground Chromium checks (chat parts moved to the HEL-821 stage/reader). Only temporary SQLite + controlled LLM.
|
|
|
|
PYTHONPATH=src <python with playwright> web/tests/rework_browser_check.py
|
|
No deployed service or user data is read or written. Every server/context closes.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from playwright.sync_api import expect, sync_playwright
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT / 'tests'))
|
|
from test_api import ControlledLLM, live_server, make_app # noqa: E402
|
|
from cybergril.contracts import LLMDelta # noqa: E402
|
|
|
|
OUT = ROOT / 'docs/rework'
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
LONG = '开头:每一段都应该完整留下。\n\n' + '\n\n'.join(
|
|
f'第{i}段。' + '窗外的灯慢慢亮起来,我们可以把话说完整,再决定下一步。' * 10
|
|
for i in range(1, 6)) + '\n\n最后:故事没有被截掉,前文和末尾都能读到。'
|
|
USER = '长句开头。' + '这是完整保留的用户长句。' * 15 + '用户长句末尾。'
|
|
|
|
|
|
class ReworkLLM(ControlledLLM):
|
|
async def stream(self, messages, **kwargs):
|
|
if self.mode != 'long':
|
|
async for delta in super().stream(messages, **kwargs):
|
|
yield delta
|
|
return
|
|
self.calls.append(messages)
|
|
try:
|
|
for index in range(0, len(LONG), 40):
|
|
yield LLMDelta(text=LONG[index:index + 40])
|
|
await asyncio.sleep(.025)
|
|
yield LLMDelta(done=True)
|
|
finally:
|
|
self.closed += 1
|
|
|
|
|
|
report = {'checks': [], 'viewports': [], 'motion': {}, 'console_errors': [],
|
|
'limits': ['Chromium only; physical phone keyboard, Safari and screen reader not tested.',
|
|
'LLM is controlled for deterministic long-stream/error/cancel checks, not a live gateway.']}
|
|
|
|
|
|
def passed(text):
|
|
report['checks'].append(text)
|
|
print('PASS', text, flush=True)
|
|
|
|
|
|
def click(page, label):
|
|
(page.locator('.pane-manage .back') if label == '设置' else page.get_by_role('button', name=label, exact=True)).click()
|
|
|
|
|
|
def nav(page, label):
|
|
page.get_by_role('navigation', name='主导航').get_by_role('button', name=label, exact=True).click()
|
|
if label == '设置' and page.viewport_size['width'] <= 720 and page.locator('.pane-manage.has-detail').is_visible(): page.locator('.pane-manage .back').click()
|
|
|
|
|
|
def shot(page, name):
|
|
page.evaluate("""async () => { await Promise.all([...document.querySelectorAll('.wstep,.pane,.pane .detail,.portal,.tabs button')].flatMap(el=>el.getAnimations()).filter(a=>a.effect.getTiming().iterations !== Infinity).map(a=>a.finished.catch(()=>{}))); }""")
|
|
page.screenshot(path=str(OUT / (name + '.png')))
|
|
|
|
|
|
def send(page, text):
|
|
page.get_by_role('textbox', name='想和她说的话').fill(text)
|
|
click(page, '发送')
|
|
|
|
|
|
# HEL-821 stage: lines are keyed by turn/position. A shown line must never change its text, and her ink-in plays
|
|
# at most once per line (re-presenting, status changes and the growing stream never replay it).
|
|
TRACK = """() => { const seen = window.lineSeen = {}, inked = window.lineInked = {}; window.lineChanged = [];
|
|
const note = n => { if (!(n instanceof HTMLElement) || !n.matches('.line')) return; const k = n.dataset.key, t = n.querySelector('p').textContent;
|
|
if (k in seen && seen[k] !== t) window.lineChanged.push([k, seen[k], t]); seen[k] = t;
|
|
if (n.querySelector('p.ink')) inked[k] = (inked[k] || 0) + 1; };
|
|
document.querySelectorAll('.stage .line').forEach(note);
|
|
new MutationObserver(list => list.forEach(r => r.addedNodes.forEach(note))).observe(document.querySelector('.stage'), {childList: true}); }"""
|
|
STILL = "() => [...document.querySelectorAll('.stage .line:not(.leaving)')].every(n => n.getAnimations().length === 0)"
|
|
CURRENT = '.stage .line.is-current:not(.leaving)'
|
|
# Her words only: the line also carries her name and state ("星河" / "还在说"), so exact text targets the <p>.
|
|
HER_WORDS = CURRENT + '[data-role="companion"] p'
|
|
# HEL-824: one real press while the input has focus. Records where the dialogue sat at press and at release and
|
|
# which button the browser delivered the click to; nothing is blurred or dispatched by the script.
|
|
TAP = """() => { const c = document.querySelector('.conversation'), top = () => Math.round(c.getBoundingClientRect().top);
|
|
window.tap = null; let down = null;
|
|
addEventListener('pointerdown', () => { down = top(); }, {capture: true, once: true});
|
|
addEventListener('click', e => { const b = e.target.closest('button');
|
|
window.tap = {down, up: top(), typing: document.documentElement.classList.contains('typing'), target: b ? (b.getAttribute('aria-label') || b.textContent.trim()) : e.target.tagName}; }, {capture: true, once: true}); }"""
|
|
# Busy frames (software rendering, a heavy crossfade) arrive far apart; the reading clock must still keep real time.
|
|
SLOW_FRAMES = "window.requestAnimationFrame = cb => setTimeout(() => cb(performance.now()), 700); window.cancelAnimationFrame = id => clearTimeout(id);"
|
|
|
|
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix='cg-rework-') as directory, sync_playwright() as pw:
|
|
provider = ReworkLLM()
|
|
app, _ = make_app(Path(directory) / 'isolated.db', provider)
|
|
with live_server(app) as url:
|
|
browser = pw.chromium.launch(headless=True, args=['--no-sandbox'])
|
|
context = browser.new_context(viewport={'width': 1440, 'height': 900})
|
|
page = context.new_page()
|
|
page.on('pageerror', lambda e: report['console_errors'].append(str(e)))
|
|
page.add_init_script("""
|
|
const get = Storage.prototype.getItem;
|
|
Storage.prototype.getItem = function(key) {
|
|
if(key === 'cybergril.demo.v1' && !location.search.includes('demo=1')) throw new Error('real read demo');
|
|
return get.call(this, key);
|
|
};
|
|
"""
|
|
)
|
|
page.goto(url)
|
|
page.get_by_label('她的名字', exact=True).fill('星河')
|
|
page.get_by_label('出生日期(公历)', exact=True).fill('2001-05-23')
|
|
click(page, '下一步')
|
|
expect(page.locator('.calendar')).to_contain_text('闰四月初一')
|
|
expect(page.locator('.color-sections section')).to_have_count(6)
|
|
assert app.state.store.query_one('SELECT count(*) n FROM companion')['n'] == 0
|
|
shot(page, 'birth-preview-moon')
|
|
page.locator('.birth-help summary').click()
|
|
shot(page, 'birth-calendar-help-moon')
|
|
click(page, '下一步'); click(page, '朋友'); click(page, '下一步')
|
|
expect(page.locator('.calendar')).to_contain_text('闰四月初一')
|
|
page.get_by_role('checkbox').check(); click(page, '确认创建'); click(page, '去看看她')
|
|
passed('Real creation: lunar leap month, all six explanations, same final confirmation; preview writes no rows')
|
|
|
|
nav(page, '交谈')
|
|
provider.mode = 'long'
|
|
page.evaluate(TRACK)
|
|
send(page, USER)
|
|
# The turn starts at your line, then her reply flows on by itself one or two lines at a time.
|
|
expect(page.locator(CURRENT)).to_have_attribute('data-role', 'user')
|
|
expect(page.locator(CURRENT + '[data-role="companion"]')).to_contain_text('开头:', timeout=60000)
|
|
expect(page.get_by_role('button', name='停止回复')).not_to_be_visible(timeout=15000)
|
|
# Pause, then review: keyboard lands at once (no slide, no replayed ink), "back to where I was" returns.
|
|
click(page, '暂停')
|
|
page.evaluate('document.activeElement.blur()')
|
|
before = page.locator(CURRENT).get_attribute('data-key')
|
|
page.keyboard.press('ArrowLeft')
|
|
expect(page.locator(CURRENT)).not_to_have_attribute('data-key', before)
|
|
assert page.evaluate(STILL)
|
|
page.keyboard.press('ArrowRight')
|
|
expect(page.locator(CURRENT)).to_have_attribute('data-key', before)
|
|
assert page.evaluate(STILL)
|
|
click(page, '上一句')
|
|
page.locator('.reader-bar .latest').click()
|
|
expect(page.locator(CURRENT)).to_have_attribute('data-key', before)
|
|
assert page.evaluate('window.lineChanged') == [], page.evaluate('window.lineChanged')
|
|
assert max(page.evaluate('Object.values(window.lineInked)') or [1]) == 1
|
|
report['motion']['inked_lines'] = page.evaluate('window.lineInked')
|
|
click(page, '完整记录')
|
|
for part in ('长句开头', '用户长句末尾', '开头:', '第5段。', '故事没有被截掉'):
|
|
expect(page.locator('.log-panel')).to_contain_text(part)
|
|
click(page, '关闭记录')
|
|
if page.get_by_role('button', name='继续', exact=True).count(): click(page, '继续')
|
|
passed('Long Chinese stream: shown lines never change or re-key, ink plays once per line; pause/keyboard review is instant; record keeps everything')
|
|
|
|
click(page, '暂停')
|
|
for width, height in [(1440, 900), (360, 640), (390, 700), (390, 844), (390, 420), (844, 390)]:
|
|
page.set_viewport_size({'width': width, 'height': height})
|
|
for theme, label in [('moon', '靛蓝月光'), ('fog', '蓝紫雾光')]:
|
|
click(page, label)
|
|
expect(page.locator('html')).to_have_attribute('data-theme', theme)
|
|
page.get_by_role('textbox', name='想和她说的话').focus()
|
|
page.wait_for_timeout(600) # compact typing slides the nav away over 420ms; measure after it settles
|
|
metrics = page.evaluate("""() => {
|
|
const box=el=>{const b=el.getBoundingClientRect();return {top:b.top,bottom:b.bottom,left:b.left,right:b.right,height:b.height}};
|
|
const q=s=>box(document.querySelector(s));
|
|
return {stage:q('.stage'),reader:q('.reader-bar'),composer:q('.composer'),nav:q('.nav'),
|
|
lines:[...document.querySelectorAll('.stage .line:not(.leaving) p')].filter(p=>p.offsetParent).map(box),
|
|
width:innerWidth,height:innerHeight,overflow:document.documentElement.scrollWidth>innerWidth};
|
|
}""")
|
|
assert not metrics['overflow'], metrics
|
|
assert metrics['lines'], metrics
|
|
for line in metrics['lines']:
|
|
assert line['left'] >= 0 and line['right'] <= metrics['width'] + .5, metrics
|
|
assert line['bottom'] <= metrics['reader']['top'] + 1, metrics
|
|
assert metrics['stage']['bottom'] <= metrics['reader']['top'] + 1, metrics
|
|
assert metrics['reader']['bottom'] <= metrics['composer']['top'] + 1, metrics
|
|
assert metrics['composer']['bottom'] < metrics['nav']['top'], metrics
|
|
report['viewports'].append({'width': width, 'height': height, 'theme': theme, **metrics})
|
|
if width in (360, 390) and height in (640, 420, 844): shot(page, f'long-line-{width}-{height}-{theme}')
|
|
if page.evaluate("document.documentElement.classList.contains('micro')"): continue # micro (<420px) intentionally hides 全文/status; the design protects the dialogue area
|
|
click(page, '全文')
|
|
expect(page.locator('.record.full')).to_be_visible()
|
|
page.keyboard.press('Escape')
|
|
expect(page.locator('.record.full')).to_have_count(0)
|
|
expect(page.get_by_role('button', name='全文', exact=True)).to_be_focused()
|
|
click(page, '完整记录')
|
|
expect(page.locator('.log-panel')).to_contain_text('长句开头')
|
|
expect(page.locator('.log-panel')).to_contain_text('故事没有被截掉')
|
|
if width == 390 and height == 844: shot(page, f'full-history-{theme}')
|
|
click(page, '关闭记录')
|
|
if page.get_by_role('button', name='继续', exact=True).count(): click(page, '继续')
|
|
passed('12 viewport/theme combinations: lines stay inside the stage, stage/reader/composer/navigation never overlap; Esc closes full text with focus back; full history reachable')
|
|
|
|
# HEL-824 defect 1: keyboard-height screens, input focused, then a direct press on send / full text /
|
|
# pause / review. The press blurs the input; the dialogue must not move between press and release.
|
|
provider.mode = 'normal'
|
|
box, flow = page.get_by_role('textbox', name='想和她说的话'), page.locator('.reader-bar .flow')
|
|
report['compact_first_press'] = []
|
|
|
|
def first_press(button, label):
|
|
box.focus()
|
|
assert page.evaluate("document.documentElement.classList.contains('typing')")
|
|
page.evaluate(TAP)
|
|
button.click()
|
|
tap = page.evaluate('window.tap')
|
|
assert tap and tap['target'] == label and tap['down'] == tap['up'] and tap['typing'] is False, tap
|
|
report['compact_first_press'].append({'size': f'{width}x{height}', 'theme': theme, **tap})
|
|
|
|
for width, height in [(390, 420), (390, 430)]:
|
|
page.set_viewport_size({'width': width, 'height': height})
|
|
for theme, label in [('moon', '靛蓝月光'), ('fog', '蓝紫雾光')]:
|
|
click(page, label)
|
|
expect(page.locator('html')).to_have_class(re.compile(r'\bcompact\b'))
|
|
words = f'小屏首击{width}{theme}'
|
|
count = app.state.store.query_one('SELECT count(*) n FROM chat_message')['n']
|
|
box.fill(words)
|
|
first_press(page.get_by_role('button', name='发送', exact=True), '发送')
|
|
expect(box).to_have_value('')
|
|
expect(page.get_by_role('button', name='停止回复')).not_to_be_visible()
|
|
expect(page.locator(HER_WORDS)).to_have_text('答复', timeout=8000)
|
|
assert app.state.store.query_one('SELECT count(*) n FROM chat_message')['n'] == count + 2
|
|
first_press(page.get_by_role('button', name='上一句', exact=True), '上一句')
|
|
expect(page.locator(CURRENT)).to_have_attribute('data-role', 'user')
|
|
expect(page.locator(CURRENT + ' p')).to_have_text(words)
|
|
page.locator('.reader-bar .latest').click()
|
|
expect(page.locator(HER_WORDS)).to_have_text('答复')
|
|
was = flow.get_attribute('aria-pressed')
|
|
first_press(flow, flow.inner_text().strip())
|
|
expect(flow).not_to_have_attribute('aria-pressed', was)
|
|
first_press(flow, flow.inner_text().strip())
|
|
expect(flow).to_have_attribute('aria-pressed', was)
|
|
first_press(page.get_by_role('button', name='全文', exact=True), '全文')
|
|
expect(page.locator('.record.full')).to_be_visible()
|
|
expect(page.locator('.record.full')).to_contain_text('答复')
|
|
page.keyboard.press('Escape')
|
|
expect(page.locator('.record.full')).to_have_count(0)
|
|
if theme == 'moon': shot(page, f'compact-first-press-{width}-{height}-moon')
|
|
passed('390x420 and 390x430, both themes: with the input focused, the first press on send, review, pause/continue and full text acts; the dialogue does not move between press and release')
|
|
|
|
page.set_viewport_size({'width': 390, 'height': 700})
|
|
provider.mode = 'slow'; send(page, '取消并重发')
|
|
expect(page.get_by_role('button', name='停止回复')).to_be_visible()
|
|
click(page, '停止回复')
|
|
provider.mode = 'normal'; send(page, '新轮')
|
|
expect(page.get_by_role('button', name='停止回复')).not_to_be_visible()
|
|
expect(page.locator(HER_WORDS)).to_have_text('答复', timeout=8000)
|
|
provider.mode = 'error'; send(page, '失败再试')
|
|
expect(page.locator('.talk-status .retry')).to_be_visible()
|
|
expect(page.get_by_role('textbox', name='想和她说的话')).to_have_value('失败再试')
|
|
count = app.state.store.query_one('SELECT count(*) n FROM chat_message')['n']
|
|
provider.mode = 'normal'; page.locator('.talk-status .retry').click()
|
|
expect(page.locator('.talk-status .retry')).not_to_be_visible()
|
|
expect(page.get_by_role('button', name='停止回复')).not_to_be_visible()
|
|
assert app.state.store.query_one('SELECT count(*) n FROM chat_message')['n'] == count + 1
|
|
passed('Cancel then immediate new turn, model error and same-turn retry preserve roles and avoid duplicate user messages')
|
|
|
|
# HEL-824 defect 2: the same stop -> immediate new turn with frames 700 ms apart. Your line keeps its
|
|
# designed 1.4 s, then her reply is on stage; before the fix each frame counted 100 ms (about 10 s).
|
|
slow = browser.new_context(viewport={'width': 390, 'height': 700})
|
|
slow.add_init_script(SLOW_FRAMES)
|
|
sp = slow.new_page(); sp.on('pageerror', lambda e: report['console_errors'].append(str(e)))
|
|
sp.goto(url); nav(sp, '交谈')
|
|
provider.mode = 'slow'; send(sp, '慢帧下取消')
|
|
expect(sp.get_by_role('button', name='停止回复')).to_be_visible()
|
|
click(sp, '停止回复')
|
|
provider.mode = 'normal'; send(sp, '慢帧下新轮')
|
|
sent = time.monotonic()
|
|
expect(sp.locator(CURRENT)).to_have_attribute('data-role', 'user')
|
|
expect(sp.locator(HER_WORDS)).to_have_text('答复', timeout=8000)
|
|
report['motion']['slow_frames_new_turn_to_reply_s'] = round(time.monotonic() - sent, 2)
|
|
assert sp.locator(CURRENT + ' .state').inner_text() == ''
|
|
slow.close()
|
|
passed('Stop then new turn with busy (700 ms) frames: your line keeps its own time and her reply follows; the reading clock counts real time')
|
|
|
|
nav(page, '设置'); click(page, '她')
|
|
expect(page.locator('.color-sections section')).to_have_count(6)
|
|
expect(page.locator('.calendar')).to_contain_text('闰四月初一')
|
|
shot(page, 'existing-birth-fog')
|
|
page.locator('.color-sections section').last.scroll_into_view_if_needed()
|
|
shot(page, 'existing-growth-fog')
|
|
click(page, '设置')
|
|
for label in ('城市', '外观素材', '备份', '外接服务'):
|
|
page.get_by_role('navigation', name='设置分区').get_by_role('button', name=label, exact=True).click()
|
|
shot(page, {'城市':'city','外观素材':'appearance','备份':'backup','外接服务':'services'}[label] + '-fog')
|
|
if label == '城市':
|
|
page.get_by_label('搜索城市').fill('不存在')
|
|
expect(page.get_by_text('当前目录没有匹配的城市。')).to_be_visible()
|
|
if label == '外接服务':
|
|
click(page, '重新检查连接')
|
|
expect(page.get_by_role('status')).to_contain_text('文字服务可达')
|
|
click(page, '设置')
|
|
click(page, '关系')
|
|
page.get_by_label('相处边界').fill('未保存的边界')
|
|
nav(page, '日子')
|
|
expect(page.get_by_role('dialog')).to_contain_text('有未保存的修改')
|
|
click(page, '取消')
|
|
expect(page.locator('.pane-manage')).to_be_visible()
|
|
nav(page, '日子'); click(page, '放弃修改并离开')
|
|
expect(page.get_by_text('还没有可展示的生活记录')).to_be_visible()
|
|
shot(page, 'life-empty-fog')
|
|
page.get_by_role('tab', name='我们的回忆').click()
|
|
expect(page.locator('.pane .list .grp')).to_have_count(4)
|
|
shot(page, 'memory-groups-empty-fog')
|
|
assert app.state.store.query_one('SELECT count(*) n FROM life_event')['n'] == 0
|
|
passed('Restored real pages: city search/empty, appearance/check/compare, backup/recovery, four services/live health, life/memory groups; unsaved close guard')
|
|
|
|
nav(page, '交谈'); page.keyboard.press('Escape')
|
|
expect(page.get_by_role('navigation', name='主导航').get_by_role('button', name='此刻', exact=True)).to_have_attribute('aria-current', 'page')
|
|
nav(page, '设置'); click(page, '显示')
|
|
page.get_by_role('checkbox').check(); click(page, '保存')
|
|
expect(page.get_by_role('status')).to_contain_text('此浏览器')
|
|
page.reload(); nav(page, '交谈')
|
|
assert page.locator('html').evaluate("el=>el.classList.contains('reduced')")
|
|
provider.mode = 'normal'; send(page, '减少动效检查')
|
|
expect(page.get_by_role('button', name='停止回复')).not_to_be_visible()
|
|
expect(page.locator(HER_WORDS)).to_have_text('答复', timeout=8000)
|
|
assert page.evaluate(STILL) and page.locator('.stage p.ink').count() == 0
|
|
reduced = browser.new_context(viewport={'width': 390, 'height': 700}, reduced_motion='reduce')
|
|
rp = reduced.new_page(); rp.goto(url); nav(rp, '交谈'); send(rp, '系统减少动效')
|
|
expect(rp.get_by_role('button', name='停止回复')).not_to_be_visible()
|
|
expect(rp.locator(HER_WORDS)).to_have_text('答复', timeout=8000)
|
|
assert rp.evaluate(STILL) and rp.locator('.stage p.ink').count() == 0
|
|
reduced.close()
|
|
passed('Escape navigation; browser display preference survives reload; settings/system reduced motion uses gentle opacity')
|
|
|
|
# Demonstration mode must never request APIs or borrow a real profile.
|
|
demo = browser.new_context(viewport={'width': 390, 'height': 844})
|
|
dp = demo.new_page(); requests = []
|
|
dp.on('request', lambda r: requests.append(r.url) if '/api/' in r.url else None)
|
|
dp.goto(url + '/?demo=1')
|
|
expect(dp.get_by_text('1 / 4 · 出生资料')).to_be_visible()
|
|
dp.get_by_label('她的名字', exact=True).fill('小满')
|
|
click(dp, '下一步'); click(dp, '下一步'); click(dp, '朋友'); click(dp, '下一步')
|
|
dp.get_by_role('checkbox').check(); click(dp, '确认创建'); click(dp, '去看看她')
|
|
nav(dp, '交谈'); click(dp, '开始模拟语音'); click(dp, '开始模拟')
|
|
expect(dp.get_by_role('button', name='结束语音')).to_be_visible()
|
|
assert dp.locator('.voice-row').bounding_box()['y'] + dp.locator('.voice-row').bounding_box()['height'] <= dp.locator('.composer').bounding_box()['y'] + 1
|
|
shot(dp, 'demo-voice-390')
|
|
click(dp, '结束语音')
|
|
dp.locator('.demo > button').click(); click(dp, '载入三天记录')
|
|
nav(dp, '日子'); dp.locator('.ev').first.click(); click(dp, '在交谈里看这段')
|
|
expect(dp.locator('.log-panel')).to_be_visible(); click(dp, '关闭记录'); click(dp, '结束交谈 · 回到日子')
|
|
expect(dp.locator('.pane[aria-label="日子"]')).to_have_class('pane glass read open has-detail')
|
|
assert not requests
|
|
passed('Demo has its own profile, voice simulation, life/detail/chat/return path and zero API requests')
|
|
demo.close(); browser.close()
|
|
assert not report['console_errors'], report['console_errors']
|
|
finally:
|
|
(OUT / 'browser-results.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
|