Files
order_system/tools/browser_smoke.py
T

305 lines
19 KiB
Python
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.
"""Headless Edge checks against a temporary local server and temporary data only."""
import argparse
import hashlib
import io
import json
import os
import re
import sys
import tempfile
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'tools' / 'out'
OUT.mkdir(exist_ok=True)
RUN = Path(tempfile.mkdtemp(prefix='ui-check-', dir=OUT))
os.environ['PO_DATA_DIR'] = str(RUN)
sys.path.insert(0, str(ROOT / 'po_app'))
from playwright.sync_api import sync_playwright
import pymupdf
import openpyxl
from datetime import datetime, timedelta
INITIAL = 'UI-test-initial-2026'
PERSONAL = 'UI-test-personal-2026'
def run(executable=None):
if executable:
from packaged_server import PackagedServer
server = PackagedServer(executable, RUN)
base = server.base
else:
from app import app, Session
from waitress.server import create_server
server = create_server(app, host='127.0.0.1', port=0)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
base = 'http://127.0.0.1:%s' % server.effective_port
errors = []
summary = []
archives = []
try:
if executable:
server.start()
with sync_playwright() as p:
browser = p.chromium.launch(channel='msedge', headless=True)
def page(mobile=False):
context = browser.new_context(viewport={'width':390,'height':844} if mobile else {'width':1440,'height':1080},
is_mobile=mobile, has_touch=mobile)
tab = context.new_page()
tab.on('pageerror', lambda e: errors.append(str(e)))
tab.on('dialog', lambda d: d.accept())
return tab
admin = page()
admin.goto(base + '/setup')
admin.locator('[name=setup_token]').fill((RUN / 'instance' / 'setup.token').read_text().strip())
admin.locator('[name=username]').fill('admin')
admin.locator('[name=name]').fill('管理员')
admin.locator('[name=password]').fill(PERSONAL)
admin.get_by_role('button',name='创建管理员').click()
admin.wait_for_url(base + '/')
for username, role in [('maker','maker'),('approver','approver')]:
admin.goto(base + '/users')
admin.locator('[name=username]').fill(username)
admin.locator('[name=name]').fill({'maker':'采购员','approver':'审批员'}[username])
admin.locator('[name=role]').select_option(role)
admin.locator('[name=password]').fill(INITIAL)
admin.get_by_role('button',name='保存用户').click()
admin.wait_for_url(base + '/users')
admin.screenshot(path=str(RUN / 'users.png'),full_page=True)
admin.goto(base + '/suppliers')
supplier_form = admin.locator('.card').first.locator('form')
supplier_form.locator('[name=name]').fill('测试供应商')
supplier_form.locator('[name=short_name]').fill('测试简称')
supplier_form.get_by_role('button', name='新增', exact=True).click()
admin.wait_for_url(base + '/suppliers')
admin.get_by_role('button', name='编辑', exact=True).click()
assert admin.locator('#edit_short_name').input_value() == '测试简称'
admin.locator('#edit_short_name').fill('测试供应商简称')
admin.locator('#editModal').get_by_role('button', name='保存', exact=True).click()
admin.wait_for_url(base + '/suppliers')
admin.screenshot(path=str(RUN / 'supplier-short-name.png'), full_page=True)
def sign_in(username, destination):
tab=page(mobile=username != 'maker'); tab.goto(destination)
assert '/login' in tab.url
if username != 'maker':
tab.screenshot(path=str(RUN / (username+'-mobile-login.png')))
tab.locator('[name=username]').fill(username)
tab.locator('[name=password]').fill(INITIAL)
tab.get_by_role('button',name='登 录').click()
tab.wait_for_url(re.compile(r'/account/password(?:\?|$)'))
tab.locator('[name=old_password]').fill(INITIAL)
tab.locator('[name=password]').fill(PERSONAL)
tab.locator('[name=confirm_password]').fill(PERSONAL)
tab.get_by_role('button',name='更新密码').click()
tab.wait_for_url(destination)
return tab
accounts = {'maker': sign_in('maker', base + '/')}
# The EXE starts a real sender. Keep notifications disabled there.
admin.goto(base + '/settings/notifications')
admin.locator('[name=base_url]').fill('http://192.168.1.10:8080')
for channel in ('approval','result'):
admin.locator('[name='+channel+'_webhook]').fill(
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=TEST-ONLY-NOT-A-REAL-KEY-'+channel)
if not executable:
admin.locator('[name=enabled]').check()
admin.get_by_role('button',name='保存通知配置').click()
admin.wait_for_load_state('networkidle')
assert admin.locator('[name=approval_webhook]').input_value() == ''
if not executable:
admin.get_by_role('button',name='测试审批通知群',exact=True).click()
admin.wait_for_load_state('networkidle')
else:
assert not admin.locator('[name=enabled]').is_checked()
admin.screenshot(path=str(RUN/'notifications.png'), full_page=True)
for path, marker in [('/static/money.js', b'lineCents'),
('/static/style.css', b'seal-company'),
('/materials/import_template.xlsx', b'PK'),
('/suppliers/import_template.xlsx', b'PK'),
('/import_template.xlsx', b'PK')]:
response = admin.request.get(base + path)
assert response.status == 200 and marker in response.body(), path
maker=accounts['maker']
maker.goto(base + '/suppliers?kw=测试供应商简称')
assert maker.locator('table.grid tbody tr').count() >= 1
maker.goto(base + '/orders/new')
maker.locator('#supplierSearch').fill('测试供应商简称')
assert maker.locator('#supplierPickNew option').count() == 2
maker.locator('#supplierPickNew').select_option(index=1)
assert maker.locator('[name=supplier_id]').input_value() != ''
for kind,count in [('purchase',2),('outsourcing',30)]:
maker.goto(base + '/orders/new')
maker.locator('[name=doc_type]').select_option(kind)
maker.get_by_role('button',name='创建并填写明细').click()
maker.wait_for_url('**/edit')
edit_url=maker.url
maker.locator('[name=supplier_name]').fill('测试供应商')
maker.locator('[name=payment_terms]').fill('月结30天')
for i in range(count):
maker.get_by_role('button',name='+ 添加一行').click()
maker.evaluate('''count => {
document.querySelectorAll('#itemTable tr').forEach((tr,i) => {
if (!i) return;
const vals={code:'BROWSER-'+i,spec:'测试物料 A&B <规格>',unit:'件',qty:'1',price:'0.334',date:'2026-10-01',remark:''};
for (const [k,v] of Object.entries(vals)) tr.querySelector('[name=item_'+k+']').value=v;
});
calc();
}''',count)
assert maker.locator('#totalAmt').inner_text() == ('0.66' if count==2 else '9.90')
maker.screenshot(path=str(RUN / (kind+'-edit.png')),full_page=True)
maker.get_by_role('button',name='保存并提交审批').click()
maker.wait_for_url('**/view')
view_url=maker.url
assert maker.locator('h1').inner_text().endswith('待审批')
if 'approver' not in accounts:
accounts['approver'] = sign_in('approver', view_url)
approver=accounts['approver'];approver.goto(view_url)
assert approver.evaluate('document.documentElement.scrollWidth <= innerWidth')
approver.get_by_role('button',name='加盖采购专用章',exact=True).click()
approver.locator('#apprConfirmBtn').scroll_into_view_if_needed()
approver.screenshot(path=str(RUN / (kind+'-mobile-approve.png')))
approver.get_by_role('button',name='确认审批通过',exact=True).click()
approver.wait_for_load_state('networkidle')
assert '审批通过' in approver.locator('h1').inner_text()
approver.screenshot(path=str(RUN / (kind+'-approved.png')),full_page=True)
response=approver.request.get(view_url.replace('/view','/pdf'))
assert response.status==200
from werkzeug.http import parse_options_header
filename = parse_options_header(response.headers['content-disposition'])[1]['filename']
assert filename.endswith('_测试供应商简称.pdf')
data=response.body();(RUN/(kind+'.pdf')).write_bytes(data)
archives.append((view_url.replace('/view','/pdf'), hashlib.sha256(data).hexdigest()))
document=pymupdf.open(stream=data,filetype='pdf')
text='\n'.join(p.get_text() for p in document)
assert '0.334' in text
assert '待审批' not in text
assert text.count('采购专用章') == 1
assert '审核专用章' not in text and '批准专用章' not in text
for i,pdf_page in enumerate(document):
pdf_page.get_pixmap(dpi=110).save(RUN/('%s-%s.png'%(kind,i+1)))
for width in (320,390,768):
approver.set_viewport_size({'width':width, 'height':844})
assert approver.evaluate('document.documentElement.scrollWidth <= innerWidth'), width
for route in ('/orders', '/'):
approver.goto(base + route)
assert approver.evaluate('document.documentElement.scrollWidth <= innerWidth'), (width, route)
approver.goto(view_url)
approver.set_viewport_size({'width':390,'height':844})
summary.append({'kind':kind,'rows':count,'pdf_pages':len(document),'mobile_approval':True})
document.close()
# Follow-up is independent of approval and the frozen PDF.
maker.goto(view_url)
assert maker.locator('#payment-form').count() == 0
maker.locator('[name=needs_prepayment]').select_option('1' if kind == 'purchase' else '0')
maker.locator('[name=invoice_status]').select_option('received')
maker.locator('[name=delivery_status]').select_option('partial' if kind == 'purchase' else 'pending')
maker.locator('[name=fulfillment_note]').fill('已收票;首批到货,跟进剩余交期' if kind == 'purchase' else '供应商备货中')
maker.get_by_role('button', name='保存采购跟进', exact=True).click()
maker.wait_for_load_state('networkidle')
approver.goto(view_url)
assert approver.locator('#follow-up-form').count() == 0
approver.locator('[name=payment_status]').select_option('partial' if kind == 'purchase' else 'paid')
approver.locator('[name=payment_date]').fill(datetime.now().strftime('%Y-%m-%d'))
approver.locator('[name=payment_note]').fill('已付预付款' if kind == 'purchase' else '款项全部付清')
approver.get_by_role('button', name='保存付款状态', exact=True).click()
approver.wait_for_load_state('networkidle')
approver.locator('#order-tracking').scroll_into_view_if_needed()
approver.screenshot(path=str(RUN / (kind+'-mobile-tracking.png')))
assert hashlib.sha256(approver.request.get(view_url.replace('/view','/pdf')).body()).hexdigest() == archives[-1][1]
admin.goto(base + '/')
assert admin.locator('[data-stage="work_undelivered"] span').inner_text() == '2 单 →'
assert admin.locator('[data-stage="work_completed"] span').inner_text() == '0 单 →'
for key in ('prepayment', 'invoice_due', 'paid_undelivered'):
assert admin.locator('[data-tracking="'+key+'"] .v').inner_text() == '1', key
assert admin.locator('#board-filter option[value="partial_delivery"]').inner_text() == '部分交货(1)'
admin.screenshot(path=str(RUN / 'tracking-dashboard.png'), full_page=True)
admin.locator('[data-tracking="paid_undelivered"]').click()
admin.wait_for_load_state('networkidle')
assert admin.locator('.tracking-table tbody tr').count() == 1
assert 'WO' in admin.locator('.tracking-table tbody tr').first.inner_text()
admin.screenshot(path=str(RUN / 'tracking-filter.png'), full_page=True)
maker.goto(base + '/orders?material_kw=BROWSER-2')
assert maker.locator('.orders-table tbody tr').count() == 2
assert all('BROWSER-2' in text for text in maker.locator('.matched-materials').all_inner_texts())
maker.screenshot(path=str(RUN/'material-order-search.png'), full_page=True)
maker.goto(base + '/materials?kw=BROWSER-2')
assert 'BROWSER-2' in maker.locator('table.grid').inner_text()
assert '0.3340' in maker.locator('table.grid').inner_text()
for width in (320, 390, 768):
approver.set_viewport_size({'width':width, 'height':844})
for path in (base + '/', view_url + '#order-tracking'):
approver.goto(path)
assert approver.evaluate('document.documentElement.scrollWidth <= innerWidth'), (width, path)
approver.set_viewport_size({'width':390,'height':844})
approver.goto(base + '/')
approver.screenshot(path=str(RUN / 'tracking-dashboard-mobile.png'), full_page=True)
# Native Excel date is visible in the browser date input after upload.
maker.goto(base + '/orders/new');maker.get_by_role('button',name='创建并填写明细').click()
maker.wait_for_url('**/edit')
workbook=openpyxl.Workbook();workbook.active.append(['物料代码','物料规格','单位','数量','单价','交期','备注'])
workbook.active.append(['DATE','日期测试','件',1,2,datetime(2026,10,1),''])
out=io.BytesIO();workbook.save(out)
maker.locator('[name=file]').set_input_files({'name':'采购明细.xlsx','mimeType':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','buffer':out.getvalue()})
maker.get_by_role('button',name='导入',exact=True).click()
maker.wait_for_load_state('networkidle')
assert maker.locator('[name=item_date]').input_value()=='2026-10-01'
first_row = maker.locator('#itemTable tr').nth(1)
original_options = first_row.locator('.matpick option').count()
first_row.locator('.matsearch').fill('BROWSER-1')
assert 1 < first_row.locator('.matpick option').count() < original_options
target = first_row.locator('.matpick option[data-code="BROWSER-1"]').get_attribute('value')
first_row.locator('.matpick').select_option(target)
from decimal import Decimal
assert Decimal(first_row.locator('.price').input_value()) == Decimal('0.3340')
maker.screenshot(path=str(RUN/'material-picker-search.png'), full_page=True)
response = admin.request.get(base + '/export/ledger')
assert response.status == 200
ledger = openpyxl.load_workbook(io.BytesIO(response.body()))
assert ledger.active.max_row == 4
ledger.close()
if executable:
import sqlite3
server.restart()
for url, expected in archives:
response = maker.request.get(url)
assert response.status == 200
assert hashlib.sha256(response.body()).hexdigest() == expected
with sqlite3.connect(RUN / 'instance' / 'po.db') as db:
assert db.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
assert db.execute('PRAGMA user_version').fetchone()[0] == 5
assert db.execute('SELECT count(*) FROM orders WHERE status = ?', ('approved',)).fetchone()[0] == 2
assert db.execute('SELECT count(*) FROM notifications').fetchone()[0] == 0
assert db.execute("SELECT count(*) FROM orders WHERE payment_status='paid' AND delivery_status='pending'").fetchone()[0] == 1
assert not (server.cwd / 'instance').exists()
else:
from notifications import dispatch_once
from models import Notification
delivered=[]
for i in range(15):
dispatch_once(Session, lambda *args: delivered.append(args), datetime.utcnow()+timedelta(seconds=i*5))
with Session() as db:
assert db.query(Notification).filter_by(status='sent').count()==3 # Test + two results.
assert db.query(Notification).filter_by(status='cancelled').count()==2 # Superseded tasks.
assert len(delivered)==3
assert not errors, errors
browser.close()
(RUN/'summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2),encoding='utf-8')
result = {'result':'passed','scenarios':summary,'output':str(RUN),
'runtime': 'exe' if executable else 'source', 'tracking_dashboard': True}
if executable:
result.update(exe_sha256=hashlib.sha256(Path(executable).read_bytes()).hexdigest(),
restart_preserves_pdf=True, python_on_path=False, notifications_sent=0)
(RUN/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps(result,ensure_ascii=False))
finally:
server.close()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--exe', type=Path, help='Test an isolated copy of this single-file EXE')
run(parser.parse_args().exe)