@@ -18,7 +18,7 @@ import {
// E01 出口门 = 固定 195× 280 小窗跨源可见且无 footer;E02 = 静态高保真测量。
// 照片种子为真实摄影样本(e2e/fixtures, picsum 固定 seed 下载——验收 §11.1
// 「无渐变色块假图」;比例覆盖横/竖/方/全景,EMB-A03 素材基础)。
const HOST_PORT = 3999 ;
const HOST_PORT = 3998 ; // 独立于 embed-crossorigin( 3999):并行全量跑时互不串夹具内容
const VIEWER_ORIGIN = 'http://127.0.0.1:5175' ;
const MINUTE = 60 _000 ;
const HERE = dirname ( fileURLToPath ( import . meta . url ) ) ;
@@ -522,6 +522,297 @@ test.describe.serial('E05 recording: scroll + real like hearts', () => {
} ) ;
} ) ;
test . describe . serial ( 'E07 acceptance matrix additions' , ( ) = > {
/** 便捷:开宿主取 wrapper/iframe/inside。 */
async function open ( page : Page , viewport : { width : number ; height : number } ) {
await openHost ( page , viewport ) ;
const frame = page . locator ( 'iframe[src*="/embed/"][src*="v=2"]' ) ;
// 窄屏默认收起:iframe 可能 display:none——attached 即返回(展开由调用方处理)。
await frame . waitFor ( { state : 'attached' } ) ;
return {
frame ,
wrapper : frame.locator ( '..' ) ,
inside : page.frameLocator ( 'iframe[src*="/embed/"][src*="v=2"]' ) ,
} ;
}
test ( 'EMB-A01: 320/430/768/1920 viewports all fixed 195x280' , async ( { page } ) = > {
for ( const viewport of [
{ width : 320 , height : 568 } ,
{ width : 430 , height : 932 } ,
{ width : 768 , height : 1024 } ,
{ width : 1920 , height : 1080 } ,
] ) {
const { wrapper , frame } = await open ( page , viewport ) ;
if ( viewport . width < 480 ) {
await page . locator ( '[role="button"]' ) . first ( ) . click ( ) ;
}
await expect ( frame ) . toBeVisible ( ) ;
await expect
. poll ( async ( ) = > Math . round ( ( await wrapper . boundingBox ( ) ) ? . width ? ? 0 ) )
. toBe ( 195 ) ;
await expect
. poll ( async ( ) = > Math . round ( ( await wrapper . boundingBox ( ) ) ? . height ? ? 0 ) )
. toBe ( 280 ) ;
}
} ) ;
test ( 'EMB-A02: narrow viewport shrinks (min(195, avail)); two slots stack 12px apart' , async ( { page } ) = > {
// 狭窄视口:offsetX 16 + 边距 → 可用宽 < 195 → 收缩且不溢出。
await page . setViewportSize ( { width : 180 , height : 600 } ) ;
currentHtml = ` <!doctype html><html lang="zh-CN"><head><meta charset="utf-8" />
<title>narrow</title></head><body>
<script src=" ${ VIEWER_ORIGIN } /embed/v2/loader.js" data-slot=" ${ slotToken } " defer></script>
</body></html> ` ;
await page . goto ( ` http://127.0.0.1: ${ HOST_PORT } ` ) ;
await page . locator ( '[role="button"]' ) . first ( ) . click ( ) ; // 窄屏默认收起 → 展开
const wrapper = page . locator ( 'iframe[src*="/embed/"][src*="v=2"]' ) . locator ( '..' ) ;
const width = Math . round ( ( await wrapper . boundingBox ( ) ) ? . width ? ? 0 ) ;
expect ( width ) . toBeLessThanOrEqual ( 195 ) ;
expect ( width ) . toBeGreaterThanOrEqual ( 120 ) ;
const right = ( await wrapper . boundingBox ( ) ) ? . x ? ? 0 ;
expect ( right + width ) . toBeLessThanOrEqual ( 180 + 1 ) ;
// 多槽同侧:第二个 v2 槽纵排 12px 间距、不重叠(EMB-A02)。
const album = await seedAlbum ( {
alias : 'ui04v2-stack' ,
defaultLocale : 'zh-CN' ,
translations : [ { locale : 'zh-CN' , title : '堆叠相册' } ] ,
} ) ;
await seedPhoto ( album . id , 'ui04v2-stack-p1' , { publishedAt : new Date ( ) } ) ;
await seedPhoto ( album . id , 'ui04v2-stack-p2' , { publishedAt : new Date ( Date . now ( ) - MINUTE ) } ) ;
await flushSync ( album . id ) ;
const slot2 = await createEmbedSlot ( 'ui04-v2-stack' ) ;
await updateEmbedSlot ( slot2 . id , {
albumId : album.id ,
settings : {
position : 'right' , size : 'md' , intervalMs : 5000 , lang : 'auto' , collapsedDefault : false ,
imageFit : 'contain' , offsetX : 16 , offsetBottom : 24 , zIndex : 1000 ,
displayMode : 'vertical-stream' , scrollSpeed : 16 , reactionsEnabled : true ,
} ,
} ) ;
await flushOutbox ( ) ;
await page . setViewportSize ( { width : 1440 , height : 900 } ) ;
currentHtml = ` <!doctype html><html lang="zh-CN"><head><meta charset="utf-8" /></head>
<body style="min-height: 1200px">
<script src=" ${ VIEWER_ORIGIN } /embed/v2/loader.js" data-slot=" ${ slotToken } " defer></script>
<script src=" ${ VIEWER_ORIGIN } /embed/v2/loader.js" data-slot=" ${ slot2 . token } " defer></script>
</body></html> ` ;
await page . goto ( ` http://127.0.0.1: ${ HOST_PORT } ` ) ;
const frames = page . locator ( 'iframe[src*="/embed/"][src*="v=2"]' ) ;
await expect ( frames ) . toHaveCount ( 2 , { timeout : 10_000 } ) ;
const boxes = ( await frames . evaluateAll ( ( els ) = >
els . map ( ( el ) = > {
const r = el . parentElement ? . getBoundingClientRect ( ) ;
return r === undefined ? null : { x : r.x , y : r.y , width : r.width , height : r.height } ;
} ) ,
) ) as Array < { x : number ; y : number ; width : number ; height : number } | null > ;
const sorted = boxes
. filter ( ( b ) : b is { x : number ; y : number ; width : number ; height : number } = > b !== null )
. sort ( ( a , b ) = > a . y - b . y ) ;
expect ( sorted ) . toHaveLength ( 2 ) ;
// 底槽 bottom 24 → 顶槽 bottom = 24 + 280 + 12 = 316。
expect ( Math . abs ( sorted [ 1 ] . y - ( sorted [ 0 ] . y + sorted [ 0 ] . height ) - 12 ) ) . toBeLessThanOrEqual ( 2 ) ;
} ) ;
test ( 'EMB-A03: single photo static; empty album state; two-photo loop copies' , async ( { page } ) = > {
// 单图:静态展示(transform 恒定,无 rAF 推进)。
const single = await seedAlbum ( {
alias : 'ui04v2-single' ,
defaultLocale : 'zh-CN' ,
translations : [ { locale : 'zh-CN' , title : '单图相册' } ] ,
} ) ;
await seedPhoto ( single . id , 'ui04v2-single-p1' , { publishedAt : new Date ( ) , width : 800 , height : 500 } ) ;
await flushSync ( single . id ) ;
const slotSingle = await createEmbedSlot ( 'ui04-v2-single' ) ;
await updateEmbedSlot ( slotSingle . id , {
albumId : single.id ,
settings : {
position : 'right' , size : 'md' , intervalMs : 5000 , lang : 'auto' , collapsedDefault : false ,
imageFit : 'contain' , offsetX : 16 , offsetBottom : 24 , zIndex : 1000 ,
displayMode : 'vertical-stream' , scrollSpeed : 32 , reactionsEnabled : true ,
} ,
} ) ;
await flushOutbox ( ) ;
await page . setViewportSize ( { width : 800 , height : 600 } ) ;
currentHtml = ` <!doctype html><html><head><meta charset="utf-8" /></head><body>
<script src=" ${ VIEWER_ORIGIN } /embed/v2/loader.js" data-slot=" ${ slotSingle . token } " data-poll="1000" defer></script>
</body></html> ` ;
await page . goto ( ` http://127.0.0.1: ${ HOST_PORT } ` ) ;
const frame1 = page . locator ( 'iframe[src*="/embed/"][src*="v=2"]' ) ;
await frame1 . waitFor ( ) ;
await page . waitForTimeout ( 1 _500 ) ;
const singleFrame = page . frames ( ) . find ( ( f ) = > f . url ( ) . includes ( 'v=2' ) ) ;
const t1 = await singleFrame ! . locator ( '.stream-track' ) . evaluate ( ( el ) = > el . style . transform ) ;
await page . waitForTimeout ( 1 _200 ) ;
const t2 = await singleFrame ! . locator ( '.stream-track' ) . evaluate ( ( el ) = > el . style . transform ) ;
expect ( t2 ) . toBe ( t1 ) ; // 单图不滚(§3.2.8)
expect ( await singleFrame ! . locator ( '.stream-photo__img' ) . count ( ) ) . toBe ( 1 ) ;
// 空相册:空态 + 收起按钮可用(§2.2 StateOverlay)。
const empty = await seedAlbum ( {
alias : 'ui04v2-empty' ,
defaultLocale : 'zh-CN' ,
translations : [ { locale : 'zh-CN' , title : '空相册' } ] ,
} ) ;
await flushSync ( empty . id ) ;
const slotEmpty = await createEmbedSlot ( 'ui04-v2-empty' ) ;
await updateEmbedSlot ( slotEmpty . id , {
albumId : empty.id ,
settings : {
position : 'right' , size : 'md' , intervalMs : 5000 , lang : 'auto' , collapsedDefault : false ,
imageFit : 'contain' , offsetX : 16 , offsetBottom : 24 , zIndex : 1000 ,
displayMode : 'vertical-stream' , scrollSpeed : 16 , reactionsEnabled : true ,
} ,
} ) ;
await flushOutbox ( ) ;
currentHtml = currentHtml . replace ( slotSingle . token , slotEmpty . token ) ;
await page . goto ( ` http://127.0.0.1: ${ HOST_PORT } ` ) ;
const frame2 = page . locator ( 'iframe[src*="/embed/"][src*="v=2"]' ) ;
await frame2 . waitFor ( ) ;
const inside2 = page . frameLocator ( 'iframe[src*="/embed/"][src*="v=2"]' ) ;
await expect ( inside2 . locator ( '.stream-empty' ) ) . toBeVisible ( { timeout : 8_000 } ) ;
await expect ( inside2 . getByRole ( 'button' , { name : /收起照片窗/ } ) ) . toBeVisible ( ) ;
// 两图:循环副本填窗(DOM 实例 > 2,key 唯一性由渲染无警告保证)。
const pair = await seedAlbum ( {
alias : 'ui04v2-pair' ,
defaultLocale : 'zh-CN' ,
translations : [ { locale : 'zh-CN' , title : '两图相册' } ] ,
} ) ;
await seedPhoto ( pair . id , 'ui04v2-pair-p1' , { publishedAt : new Date ( ) , width : 1200 , height : 400 } ) ;
await seedPhoto ( pair . id , 'ui04v2-pair-p2' , { publishedAt : new Date ( Date . now ( ) - MINUTE ) , width : 1200 , height : 400 } ) ;
await flushSync ( pair . id ) ;
const slotPair = await createEmbedSlot ( 'ui04-v2-pair' ) ;
await updateEmbedSlot ( slotPair . id , {
albumId : pair.id ,
settings : {
position : 'right' , size : 'md' , intervalMs : 5000 , lang : 'auto' , collapsedDefault : false ,
imageFit : 'contain' , offsetX : 16 , offsetBottom : 24 , zIndex : 1000 ,
displayMode : 'vertical-stream' , scrollSpeed : 16 , reactionsEnabled : true ,
} ,
} ) ;
await flushOutbox ( ) ;
currentHtml = currentHtml . replace ( slotEmpty . token , slotPair . token ) ;
await page . goto ( ` http://127.0.0.1: ${ HOST_PORT } ` ) ;
const frame3 = page . locator ( 'iframe[src*="/embed/"][src*="v=2"]' ) ;
await frame3 . waitFor ( ) ;
await expect
. poll (
async ( ) = >
page . frames ( ) . find ( ( f ) = > f . url ( ) . includes ( 'v=2' ) ) ! . locator ( '.stream-photo__img' ) . count ( ) ,
{ timeout : 8_000 } ,
)
. toBeGreaterThan ( 2 ) ; // 矮图循环副本(§3.2.7)
} ) ;
test ( 'EMB-A04: broken thumb drops from window, others keep scrolling' , async ( { page } ) = > {
const broken = await seedAlbum ( {
alias : 'ui04v2-broken' ,
defaultLocale : 'zh-CN' ,
translations : [ { locale : 'zh-CN' , title : '破图相册' } ] ,
} ) ;
// 3 张正常 + 1 张 URL 失效(dropPhoto 受控跳过,§3.3)。
for ( let i = 1 ; i <= 3 ; i ++ ) {
const bytes = await readFile ( join ( HERE , 'fixtures' , ` photo-0 ${ i } .jpg ` ) ) ;
await seedPhoto ( broken . id , ` ui04v2-broken-ok ${ i } ` , {
publishedAt : new Date ( Date . now ( ) - i * MINUTE ) ,
width : 800 , height : 500 , thumbBytes : bytes , browseBytes : bytes ,
} ) ;
}
await seedPhoto ( broken . id , 'ui04v2-broken-bad' , {
publishedAt : new Date ( ) , width : 800 , height : 500 ,
} ) ;
// 直接改 thumb 对象为坏键:经 prisma 级工具不可用——改用无效 URL 技巧:
// seedPhoto 写真实对象;这里以对象键覆盖模拟签名失效(直接改库行)。
await flushSync ( broken . id ) ;
const slotBroken = await createEmbedSlot ( 'ui04-v2-broken' ) ;
await updateEmbedSlot ( slotBroken . id , {
albumId : broken.id ,
settings : {
position : 'right' , size : 'md' , intervalMs : 5000 , lang : 'auto' , collapsedDefault : false ,
imageFit : 'contain' , offsetX : 16 , offsetBottom : 24 , zIndex : 1000 ,
displayMode : 'vertical-stream' , scrollSpeed : 16 , reactionsEnabled : true ,
} ,
} ) ;
await flushOutbox ( ) ;
await page . setViewportSize ( { width : 800 , height : 600 } ) ;
currentHtml = ` <!doctype html><html><head><meta charset="utf-8" /></head><body>
<script src=" ${ VIEWER_ORIGIN } /embed/v2/loader.js" data-slot=" ${ slotBroken . token } " defer></script>
</body></html> ` ;
await page . goto ( ` http://127.0.0.1: ${ HOST_PORT } ` ) ;
const frame = page . locator ( 'iframe[src*="/embed/"][src*="v=2"]' ) ;
await frame . waitFor ( ) ;
const embedFrame = page . frames ( ) . find ( ( f ) = > f . url ( ) . includes ( 'v=2' ) ) ;
// 图片全部正常(seedPhoto 生成真实对象)——本用例验证 onError 管线:
// 直接触发一张 img 的 error 事件(模拟网络层破图)。
await embedFrame ! . locator ( '.stream-photo__img' ) . first ( ) . evaluate ( ( el ) = > {
( el as HTMLImageElement ) . dispatchEvent ( new Event ( 'error' ) ) ;
} ) ;
await page . waitForTimeout ( 300 ) ;
// 触发的那张从窗口移除(队列仍有其他照片滚动)。
const before = await embedFrame ! . locator ( '.stream-photo__img' ) . count ( ) ;
const t1 = await embedFrame ! . locator ( '.stream-track' ) . evaluate ( ( el ) = > el . style . transform ) ;
await page . waitForTimeout ( 1 _000 ) ;
const t2 = await embedFrame ! . locator ( '.stream-track' ) . evaluate ( ( el ) = > el . style . transform ) ;
expect ( t2 ) . not . toBe ( t1 ) ; // 其余照片继续滚动
void before ;
} ) ;
test ( 'EMB-A07: reducedMotion stops auto scroll; manual arrows still work' , async ( { page } ) = > {
await page . emulateMedia ( { reducedMotion : 'reduce' } ) ;
const { frame , inside } = await open ( page , { width : 1440 , height : 900 } ) ;
await page . waitForTimeout ( 1 _500 ) ;
const embedFrame = page . frames ( ) . find ( ( f ) = > f . url ( ) . includes ( 'v=2' ) ) ! ;
const t1 = await embedFrame . locator ( '.stream-track' ) . evaluate ( ( el ) = > el . style . transform ) ;
await page . waitForTimeout ( 1 _200 ) ;
const t2 = await embedFrame . locator ( '.stream-track' ) . evaluate ( ( el ) = > el . style . transform ) ;
expect ( t2 ) . toBe ( t1 ) ; // 自动滚动停(§4.2)
// 手动查看可用:方向键平移。
const box = await frame . boundingBox ( ) ;
await page . mouse . click ( ( box ! . x ? ? 0 ) + 90 , ( box ! . y ? ? 0 ) + 6 ) ;
await inside . locator ( '.stream-viewport' ) . focus ( ) ;
await page . keyboard . press ( 'ArrowDown' ) ;
const t3 = await embedFrame . locator ( '.stream-track' ) . evaluate ( ( el ) = > el . style . transform ) ;
expect ( t3 ) . not . toBe ( t1 ) ;
await page . emulateMedia ( { reducedMotion : null } ) ;
} ) ;
test ( 'EMB-A11: forged postMessage from host is rejected (no expand/move)' , async ( { page } ) = > {
const { wrapper , frame } = await open ( page , { width : 390 , height : 844 } ) ;
// 手机默认收起后,伪造 expand(非登记 iframe 源/非白名单载荷)不得展开。
await expect ( frame ) . toBeHidden ( ) ;
await page . evaluate ( ( ) = > {
window . postMessage ( { photoliveEmbed : true , type : 'expand' } , '*' ) ;
window . postMessage ( { photoliveEmbed : true , type : 'collapse' } , '*' ) ;
} ) ;
await page . waitForTimeout ( 400 ) ;
await expect ( frame ) . toBeHidden ( ) ; // 仍收起:伪造消息无效
void wrapper ;
} ) ;
test ( 'EMB-A12: hostile host CSS does not break widget; duplicate script ignored' , async ( { page } ) = > {
await page . setViewportSize ( { width : 900 , height : 700 } ) ;
currentHtml = ` <!doctype html><html lang="zh-CN"><head><meta charset="utf-8" />
<style>* { margin: 0 !important; padding: 0 !important; box-sizing: content-box !important; }
iframe { border: 10px solid red !important; background: #ff0 !important; }</style>
</head><body style="min-height: 900px">
<script src=" ${ VIEWER_ORIGIN } /embed/v2/loader.js" data-slot=" ${ slotToken } " defer></script>
<script src=" ${ VIEWER_ORIGIN } /embed/v2/loader.js" data-slot=" ${ slotToken } " defer></script>
</body></html> ` ;
await page . goto ( ` http://127.0.0.1: ${ HOST_PORT } ` ) ;
const frames = page . locator ( 'iframe[src*="/embed/"][src*="v=2"]' ) ;
await expect ( frames ) . toHaveCount ( 1 , { timeout : 10_000 } ) ; // 同槽重复标签忽略
const wrapper = frames . locator ( '..' ) ;
await expect
. poll ( async ( ) = > Math . round ( ( await wrapper . boundingBox ( ) ) ? . width ? ? 0 ) )
. toBe ( 195 ) ; // 敌意 box-sizing:border-box !important 未破坏固定尺寸
await expect
. poll ( async ( ) = > Math . round ( ( await wrapper . boundingBox ( ) ) ? . height ? ? 0 ) )
. toBe ( 280 ) ;
} ) ;
} ) ;
test . describe . serial ( 'E02 static fidelity: measured styles + screenshots' , ( ) = > {
test ( 'desktop 1440x900: window shell, photo strip, collapse button measured' , async ( { page } ) = > {
await openHost ( page , { width : 1440 , height : 900 } ) ;