upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)

Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.

New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.

compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).

Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-23 12:31:03 +08:00
parent 257c89e159
commit 601451f22b
218 changed files with 10441 additions and 628 deletions
+69
View File
@@ -0,0 +1,69 @@
"""Optional actual Chromium smoke on disposable site. Never contacts production."""
import json, os, socket, subprocess, sys, tempfile, time
from pathlib import Path
from urllib.request import urlopen
import httpx
from playwright.sync_api import sync_playwright
ROOT=Path(__file__).resolve().parent.parent
OUT=ROOT/'reports'/'studio-ux'
OUT.mkdir(parents=True,exist_ok=True)
def main():
with tempfile.TemporaryDirectory(prefix='sf-studio-ui-') as folder:
with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
base=f'http://127.0.0.1:{port}'
env={**os.environ,'SITEFORGE_ADMIN_PASSWORD':'studio-smoke-only-password','SITEFORGE_SESSION_SECRET':'studio-smoke-only-secret-very-long','SITEFORGE_DATA_DIR':folder,'SITEFORGE_DB_FILE':str(Path(folder)/'siteforge.sqlite3'),'SITEFORGE_DATABASE_URL':'','SITEFORGE_REDIS_URL':'','SITEFORGE_COOKIE_SECURE':'false','NO_PROXY':'127.0.0.1,localhost','no_proxy':'127.0.0.1,localhost'}
proc=subprocess.Popen([sys.executable,'-m','uvicorn','app.main:app','--host','127.0.0.1','--port',str(port)],cwd=ROOT,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
try:
for i in range(120):
if proc.poll() is not None:raise RuntimeError('server exited: '+proc.stderr.read().decode()[-1200:])
try:
with urlopen(base+'/api/ready',timeout=0.4) as response:
if response.status==200:break
except Exception:time.sleep(.1)
else:raise RuntimeError('readiness timed out')
with httpx.Client(base_url=base,trust_env=False) as client:
login=client.post('/api/auth/login',json={'username':'admin','password':env['SITEFORGE_ADMIN_PASSWORD']});login.raise_for_status()
csrf=client.get('/api/auth/me').json()['csrf']
site=client.post('/api/sites',json={'name':'V8.21 Studio UI 验收站点','theme':'corporate'},headers={'X-SiteForge-CSRF':csrf});site.raise_for_status();sid=site.json()['id']
entries=client.get(f'/api/sites/{sid}/entries').json();page_id=next(x['id'] for x in entries if x['route']=='/')
cookie_names={c.name:c.value for c in client.cookies.jar}
with sync_playwright() as play:
browser=play.chromium.launch(executable_path='/usr/bin/chromium',headless=True,args=['--no-sandbox','--disable-dev-shm-usage','--no-proxy-server','--disable-gpu'])
context=browser.new_context(viewport={'width':1600,'height':950},device_scale_factor=1)
context.add_cookies([{'name':k,'value':v,'url':base} for k,v in cookie_names.items()])
page=context.new_page()
errors=[];page.on('pageerror',lambda e:errors.append(str(e)))
page.goto(base+f'/editor/{sid}/{page_id}',wait_until='domcontentloaded',timeout=20000)
page.locator('#blockList .block-row').first.wait_for(timeout=12000)
page.screenshot(path=str(OUT/'editor-desktop.png'),full_page=True)
page.locator('#tabResponsive').click()
assert page.locator('#tabResponsive').get_attribute('aria-selected')=='true'
assert not page.locator('[data-inspector-section="responsive"]').is_hidden()
page.locator('#tabContent').click()
total=page.locator('.block-row').count()
page.locator('#blockSearch').fill('not-find-anything')
assert page.locator('.block-row').count()==0
page.locator('#blockSearch').fill('')
assert page.locator('.block-row').count()==total
page.locator('.block-row').first.locator('button[title="隐藏"]').click()
assert page.locator('#saveStatus').inner_text().startswith('未保存')
page.locator('#saveDraftButton').click()
page.wait_for_function("document.querySelector('#saveStatus').textContent.startsWith('草稿')",timeout=12000)
page.locator('#devicePhone').click()
page.screenshot(path=str(OUT/'editor-phone-preview.png'),full_page=True)
assert not errors,errors
mobile=browser.new_context(viewport={'width':390,'height':844},device_scale_factor=1)
mobile.add_cookies([{'name':k,'value':v,'url':base} for k,v in cookie_names.items()])
mp=mobile.new_page();mp.goto(base+f'/editor/{sid}/{page_id}',wait_until='domcontentloaded',timeout=20000)
mp.locator('#blockCount').wait_for(timeout=12000)
mp.locator('.pane-switcher button').first.click()
assert 'show-structure' in mp.locator('body').get_attribute('class')
mp.screenshot(path=str(OUT/'editor-mobile-ui.png'),full_page=True)
mobile.close();browser.close()
print(json.dumps({'result':'PASS','desktop_blocks':total,'browser_errors':errors,'screenshots':[str(x) for x in OUT.glob('*.png')]},ensure_ascii=False))
finally:
proc.terminate()
try:proc.wait(timeout=5)
except subprocess.TimeoutExpired:proc.kill();proc.wait()
if __name__=='__main__':main()