captureStableWithBox.mjs

import captureStable from './captureStable.mjs'
import maskRegions from './maskRegions.mjs'
import composeBox from './composeBox.mjs'


/**
 * 全頁穩定截圖並以紅框標注本張之觀看或操作區(截圖後以sharp合成,不注入DOM),可另遮黑非決定性區域
 *
 * target:CSS選擇器字串、Playwright Locator、視窗座標矩形{x,y,width,height}、量測型目標{scroll,measure,label},或以上混合之陣列(多個取聯集畫「一個」框)。
 * 量測型目標:measure(page)回傳視窗矩形、矩形陣列或null,於「捲入與等待之後」呼叫(故量到的是截圖當下之版面);scroll為捲入用之選擇器或Locator(可省略);
 * probe為被蓋住檢查用之選擇器或Locator(可省略,省略者不做被蓋住檢查)。
 * 框對象須由頁面內容決定(例如表格之標頭與可見資料列、清單之可見項目)時用之,見gridContentBox、itemsUnionBox。
 * 流程:第一個非矩形目標先捲入視窗(逾時不中斷) → 等待preWaitMs並把滑鼠移到(0,0) → 量各目標與遮罩之視窗矩形及捲動量(皆在截圖「之前」量) →
 * capture(預設captureStable,其餘opt原樣透傳,例如strict、settle) → 遮罩(先) → 紅框(後,框永遠可見)。
 * mask:選擇器字串,或{sel,fixedWidth}(自元素右緣往左取固定寬,數字位數變動時黑塊邊界不浮動)。
 * clampTo:'buffer'(框夾在整張截圖內)或'viewport'(框夾在截圖當下之視窗內,全頁截圖時加捲動量)
 * 靜默無框防護(2026-09-28):目標全數找不到或量得尺寸為 0 時於截圖前拋錯;框夾邊後過小(composeBox 將不畫)時於合成後拋錯——
 * 標準圖每張皆須有框(技能 §7.1),原「找不到即回無框圖」會產出永遠比對通過之無框標準圖(SSO stainfor E2E-002 殷鑑)。
 * 確屬設計如此者(極少)須顯式傳 allowNoBox:true。
 * 被蓋住檢查(2026-09-28):選擇器/Locator 目標(與量測型目標之 probe)之中心點(夾在視窗內)以 elementFromPoint 取最上層元素,
 * 若非目標本身、其子孫或祖先(例如彈窗遮罩、蓋在上面之面板)即視為「框了被蓋住的東西」——技能 §7.3-4 規定改框蓋在上面者。
 * coverCheck:'throw'(預設,可由環境變數 E2E_COVER_CHECK 覆寫)、'warn'(印 [box-covered] 告警,供全量盤查)、'off'(確屬設計者)。
 *
 * @param {Page} page 輸入Playwright之Page
 * @param {String|Object|Array} target 輸入框選目標
 * @param {Object} [opt={}] 輸入設定物件,預設{},除下列鍵外皆透傳給capture
 * @param {Array} [opt.mask=[]] 輸入遮罩目標陣列,預設[]
 * @param {String} [opt.clampTo='buffer'] 輸入紅框夾邊範圍字串,'buffer'或'viewport',預設'buffer'
 * @param {Boolean} [opt.guardSmall=true] 輸入框過小時是否不畫布林值,預設true
 * @param {Boolean} [opt.allowNoBox=false] 輸入是否容許無框(目標找不到或框過小時回傳無框圖而不拋錯)布林值,預設false
 * @param {String} [opt.coverCheck] 輸入被蓋住檢查模式字串,'throw'、'warn'或'off',預設讀環境變數E2E_COVER_CHECK,未設為'throw'
 * @param {Object} [opt.boxOpts={}] 輸入透傳給composeBox之樣式參數物件(pad、margin、strokeWidth、color、radius),預設{}
 * @param {Number} [opt.scrollTimeoutMs=8000] 輸入捲入視窗之逾時毫秒數,預設8000
 * @param {Boolean} [opt.scrollTall=true] 輸入目標比其可視區大且已部分入鏡時是否仍捲入布林值,預設true(向後相容,並印[box-scroll]告警);false則不捲、框其可見部分
 * @param {Number} [opt.preWaitMs=300] 輸入捲入後之等待毫秒數,預設300
 * @param {Function} [opt.capture=captureStable] 輸入截圖函數(page,opt)=>Promise<Buffer>,預設captureStable
 * @returns {Promise} 回傳Promise,resolve回傳PNG之Buffer
 * @example
 *
 * import captureStableWithBox from 'w-package-tools-e2e/src/captureStableWithBox.mjs'
 *
 * let buf = await captureStableWithBox(page, '.ag-root-wrapper')
 * let buf2 = await captureStableWithBox(page, [page.locator('.ag-row').filter({ hasText: '帳號' })], { mask: [{ sel: '.clock', fixedWidth: 80 }] })
 *
 */
async function captureStableWithBox(page, target, opt = {}) {
    let {
        mask = [],
        clampTo = 'buffer',
        guardSmall = true,
        allowNoBox = false,
        coverCheck = (process.env.E2E_COVER_CHECK || 'throw'),
        boxOpts = {},
        scrollTimeoutMs = 8000,
        scrollTall = true,
        preWaitMs = 300,
        capture = captureStable,
        ...captureOpts
    } = opt
    let items = Array.isArray(target) ? target : [target]
    let isLoc = (x) => x && typeof x === 'object' && typeof x.boundingBox === 'function'
    let isMeasure = (x) => x && typeof x === 'object' && !isLoc(x) && typeof x.measure === 'function'
    let isRect = (x) => x && typeof x === 'object' && !isLoc(x) && !isMeasure(x) && ['x', 'y', 'width', 'height'].every((k) => typeof x[k] === 'number')

    //先把第一個目標捲進視窗(同組目標應在同一捲動位置); 矩形為已量得之視窗座標, 不捲動; 量測型目標捲其 scroll(未給則不捲)
    //目標比其可視區(視窗與各層捲動/裁切容器之交集)大且已部分入鏡者(例:點分頁後整個檢視面板),捲入只會改變版面而仍無法完整入鏡——
    //使用者不會捲動,此為測試手段造成之假畫面(w-web-api display E2E-007 殷鑑:分頁列被捲出畫面、捲動並延續到下一張)。
    //scrollTall:false 時不捲;預設沿用捲入(向後相容)並印 [box-scroll] 告警,由呼叫端逐案判斷使用者此刻會不會捲動:
    //切分頁後之整個檢視面板不會(改傳 itemsUnionBox(loc, { scroll: null }) 或 scrollTall:false);送出後出現在下方之回應卡會(照常捲入)
    if (items.length > 0 && !isRect(items[0])) {
        let first = isMeasure(items[0]) ? items[0].scroll : items[0]
        if (first) {
            let firstLoc = isLoc(first) ? first.first() : page.locator(first).first()
            let tall = (await firstLoc.count()) > 0 && await firstLoc.evaluate((el) => {
                let r = el.getBoundingClientRect()
                let vis = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }
                for (let p = el.parentElement; p && p !== document.body && p !== document.documentElement; p = p.parentElement) {
                    let cs = window.getComputedStyle(p)
                    if (/(auto|scroll|hidden)/.test(`${cs.overflowX} ${cs.overflowY}`)) {
                        let pr = p.getBoundingClientRect()
                        vis = { left: Math.max(vis.left, pr.left), top: Math.max(vis.top, pr.top), right: Math.min(vis.right, pr.right), bottom: Math.min(vis.bottom, pr.bottom) }
                    }
                }
                let inView = r.right > vis.left && r.left < vis.right && r.bottom > vis.top && r.top < vis.bottom
                let tooBig = r.height > (vis.bottom - vis.top) + 1 || r.width > (vis.right - vis.left) + 1
                return inView && tooBig
            }, null, { timeout: scrollTimeoutMs }).catch(() => false)
            if (tall && scrollTall === false) {
                //不捲動:框其可見部分
            }
            else {
                if (tall) {
                    console.log(`[box-scroll] 目標比其可視區大且已部分入鏡,捲入會改變版面(假畫面);框整個檢視區宜傳 itemsUnionBox(loc, { scroll: null }) 或 scrollTall:false`)
                }
                await firstLoc.scrollIntoViewIfNeeded({ timeout: scrollTimeoutMs }).catch(() => {})
            }
        }
    }
    await page.waitForTimeout(preWaitMs)
    await page.mouse.move(0, 0)

    //各目標之視窗矩形
    let rects = []
    for (let it of items) {
        if (isRect(it)) {
            rects.push(it)
        }
        else if (isMeasure(it)) {
            let m = await it.measure(page)
            let ms = Array.isArray(m) ? m : [m]
            for (let r of ms) {
                if (r && ['x', 'y', 'width', 'height'].every((k) => typeof r[k] === 'number')) {
                    rects.push(r)
                }
            }
        }
        else if (isLoc(it)) {
            let bb = await it.first().boundingBox()
            if (bb) {
                rects.push(bb)
            }
        }
        else {
            let r = await page.evaluate((s) => {
                let e = document.querySelector(s)
                if (!e) {
                    return null
                }
                let rc = e.getBoundingClientRect()
                return { x: rc.left, y: rc.top, width: rc.width, height: rc.height }
            }, it)
            if (r) {
                rects.push(r)
            }
        }
    }

    //被蓋住檢查: 選擇器/Locator 目標之中心點最上層元素須為目標本身、其子孫或祖先; 否則目標被他物(彈窗遮罩等)蓋住
    if (coverCheck !== 'off') {
        let hitTest = (el) => {
            let r = el.getBoundingClientRect()
            if (r.width <= 0 || r.height <= 0) {
                return null
            }
            let cx = Math.min(Math.max(r.left + r.width / 2, 0), window.innerWidth - 1)
            let cy = Math.min(Math.max(r.top + r.height / 2, 0), window.innerHeight - 1)
            if (cx < r.left || cx > r.right || cy < r.top || cy > r.bottom) {
                return null
            }
            let h = document.elementFromPoint(cx, cy)
            if (!h || el.contains(h) || h.contains(el)) {
                return null
            }
            let cls = (typeof h.className === 'string' && h.className.trim()) ? `.${h.className.trim().split(/\s+/).slice(0, 2).join('.')}` : ''
            let shield = (h.closest('[style*="overscroll-behavior"]') ? '(彈窗遮罩層內)' : '')
            let txt = (h.innerText || '').trim().replace(/\s+/g, ' ').slice(0, 30)
            return `${h.tagName.toLowerCase()}${cls}${shield}${txt ? `「${txt}」` : ''}`
        }
        let covered = []
        for (let t of items) {
            //量測型目標帶 probe(Locator 或選擇器)者照常檢查(例:itemsUnionBox 包住單一 Locator); 其餘量測型與矩形不檢查
            let it = isMeasure(t) ? (t.probe || null) : t
            if (!it || isRect(it)) {
                continue
            }
            let hit = null
            if (isLoc(it)) {
                hit = await it.first().evaluate(hitTest, null, { timeout: 2000 }).catch(() => null)
            }
            else {
                let h = await page.$(it).catch(() => null)
                hit = h ? await h.evaluate(hitTest).catch(() => null) : null
            }
            if (hit) {
                covered.push(`${isLoc(it) ? 'Locator' : String(it)} 之中心被 ${hit} 蓋住`)
            }
        }
        if (covered.length > 0) {
            let msg = `captureStableWithBox: 紅框目標被其他元素蓋住(技能 §7.3-4 被蓋住就框蓋在上面的): ${covered.join('; ')}`
            if (coverCheck === 'warn') {
                console.warn(`[box-covered] ${msg}`)
            }
            else {
                throw new Error(`${msg}; 確屬設計者請傳 coverCheck:'off'`)
            }
        }
    }

    //無框防護: 目標皆找不到或尺寸皆為 0 → 截圖前即拋錯(不產出無框圖); 聯集框仍以原 rects 計算(不改既有框幾何)
    if (rects.filter((r) => r.width > 0 && r.height > 0).length === 0 && !allowNoBox) {
        let desc = items.map((it) => (isRect(it) ? `rect(${it.x},${it.y},${it.width}x${it.height})` : isLoc(it) ? 'Locator' : isMeasure(it) ? (it.label || 'measure') : String(it))).join(' | ')
        throw new Error(`captureStableWithBox: 紅框目標皆找不到或尺寸為 0 (${desc}); 標準圖每張皆須有框(技能 §7.1), 確屬設計者請傳 allowNoBox:true`)
    }

    //遮罩區之視窗矩形
    let maskRects = await page.evaluate((ms) => {
        let out = []
        ms.forEach((s) => {
            let sel = (typeof s === 'string') ? s : s.sel
            let e = document.querySelector(sel)
            if (!e) {
                return
            }
            let r = e.getBoundingClientRect()
            let left = r.left
            let width = r.width
            if (typeof s === 'object' && s.fixedWidth) {
                width = s.fixedWidth
                left = (r.left + r.width) - width
            }
            out.push({ x: left, y: r.top, w: width, h: r.height })
        })
        return out
    }, mask)

    //全頁截圖座標 = 視窗座標 + 捲動量
    let env = await page.evaluate(() => ({ vw: window.innerWidth, vh: window.innerHeight, sx: window.scrollX, sy: window.scrollY }))

    let buf = await capture(page, captureOpts)
    if (maskRects.length > 0) {
        buf = await maskRegions(buf, maskRects.map((r) => ({ x: r.x + env.sx, y: r.y + env.sy, w: r.w, h: r.h })))
    }
    if (rects.length > 0) {
        let bounds = clampTo === 'viewport' ? { left: env.sx, top: env.sy, right: env.sx + env.vw, bottom: env.sy + env.vh } : undefined
        let skipped = null
        buf = await composeBox(buf, {
            left: Math.min(...rects.map((r) => r.x)) + env.sx,
            top: Math.min(...rects.map((r) => r.y)) + env.sy,
            right: Math.max(...rects.map((r) => r.x + r.width)) + env.sx,
            bottom: Math.max(...rects.map((r) => r.y + r.height)) + env.sy,
        }, {
            ...boxOpts,
            bounds,
            guardSmall,
            onSkip: (reason) => {
                skipped = reason
            },
        })
        if (skipped && !allowNoBox) {
            throw new Error(`captureStableWithBox: 紅框未畫出——${skipped}; 標準圖每張皆須有框(技能 §7.1), 目標須先捲入可視範圍(技能 §7.3-5), 確屬設計者請傳 allowNoBox:true`)
        }
    }
    return buf
}


export default captureStableWithBox