runBaselineCase.mjs

import fs from 'fs'
import path from 'path'
import normalizeShots from './normalizeShots.mjs'
import openCasePage from './openCasePage.mjs'
import assertBaselineMatch from './assertBaselineMatch.mjs'


/**
 * 單一案例之標準圖管線:產製端(regen)與比對端(compare)呼叫同一函數,使兩端之瀏覽器取得、每案fresh、前置、執行、語意斷言與順序逐項對稱
 *
 * 順序:prepare(開瀏覽器前,例如資料庫重置) → launch → openPage → beforeRun → run → 正規化截圖 → semantic → verify →
 * 產製端:依gate逐張決定是否寫檔(全部斷言通過後才寫,任何斷言失敗則該案一張都不寫);比對端:逐張assertBaselineMatch →
 * finally關閉瀏覽器(若run中換了瀏覽器,以ctx.browserRef.current為準)並呼叫afterCase。
 * 各掛鉤皆收到ctx物件{mode,lang,name,browser,browserRef,page,shots,result},可依ctx.mode分流(例如只能在測試框架內執行之檢查)。
 * 已知缺陷(createKnownDefect):產製端略過該案不寫圖並回傳status為'knownDefect';比對端呼叫onKnownDefect(例如mocha之this.skip),未給則原樣拋出
 *
 * @param {Object} opt 輸入設定物件
 * @param {String} opt.mode 輸入模式字串,'regen'或'compare'
 * @param {String} opt.lang 輸入語系字串
 * @param {String} opt.name 輸入案例鍵字串
 * @param {Function} opt.launch 輸入開瀏覽器函數,async()=>Browser
 * @param {Function} opt.run 輸入案例執行函數,async(page,lang,ctx)=>Buffer|{圖鍵:Buffer}|[{name,buf}]|{buf|shots,page}|null
 * @param {Function} opt.pathOf 輸入標準圖路徑函數,(lang,圖鍵)=>路徑字串
 * @param {Function} [opt.openPage] 輸入開頁函數,async(browser,ctx)=>Page,預設openCasePage(browser)
 * @param {Function} [opt.prepare] 輸入開瀏覽器前之前置函數,async(ctx)
 * @param {Function} [opt.beforeRun] 輸入開頁後、執行前之前置函數,async(ctx)
 * @param {Function} [opt.semantic] 輸入語意斷言函數,async(ctx),寫檔或比對前必過
 * @param {Function} [opt.verify] 輸入資料庫或端到端不變式檢查函數,async(ctx),寫檔或比對前必過
 * @param {Function} [opt.afterCase] 輸入收尾函數(finally,關瀏覽器之後),async(ctx)
 * @param {Function} [opt.labelOf] 輸入比對失敗證據檔名標籤函數,(lang,圖鍵)=>字串,預設標準圖檔名
 * @param {Object} [opt.gate=null] 輸入createBaselineGate之篩選器,產製端用;null代表全寫至pathOf
 * @param {Array} [opt.stages=null] 輸入宣告之圖鍵陣列,給定時產出之圖鍵集合須與之相同(防宣告、產製、比對三處命名不同步),預設null不檢查
 * @param {Boolean} [opt.compareOnly=false] 輸入是否為只比對之案例(共用他案標準圖)布林值;產製端照常執行並驗語意斷言但一張都不寫(避免共用圖被兩案重寫),回傳status為'compareOnly',預設false
 * @param {Boolean} [opt.allowEmpty=false] 輸入是否允許案例不產生任何截圖布林值,預設false(無截圖即拋錯,防比對端空轉假綠)
 * @param {Boolean} [opt.compareAll=false] 輸入比對端是否比完全部階段再彙總拋錯布林值,預設false(首張不符即拋)
 * @param {Function} [opt.onKnownDefect=null] 輸入比對端遇已知缺陷之處理函數,async(err),預設null代表原樣拋出
 * @param {Function} [opt.match=assertBaselineMatch] 輸入比對函數(buf,baselinePath,label),預設assertBaselineMatch
 * @param {Function} [opt.writeFile] 輸入寫檔函數(path,buf),預設建立上層目錄後fs.writeFileSync
 * @param {Function} [opt.log=console.log] 輸入輸出函數,預設console.log
 * @returns {Promise} 回傳Promise,resolve回傳物件{status,shots,written,kept,skipped},status為'regen'、'compare'、'compareOnly'或'knownDefect'
 * @example
 *
 * import runBaselineCase from 'w-package-tools-e2e/src/runBaselineCase.mjs'
 *
 * //比對端(mocha)
 * it(name, async function() {
 *     await runBaselineCase({ mode: 'compare', lang, name, launch: launchBrowser, run: fn, semantic, pathOf, onKnownDefect: () => this.skip() })
 * })
 * //產製端(直跑 --baseline)
 * await runBaselineCase({ mode: 'regen', lang, name, launch: launchBrowser, run: fn, semantic, pathOf, gate })
 *
 */
async function runBaselineCase(opt = {}) {
    let {
        mode,
        lang,
        name,
        launch,
        run,
        pathOf,
        openPage = (browser) => openCasePage(browser),
        prepare = null,
        beforeRun = null,
        semantic = null,
        verify = null,
        afterCase = null,
        labelOf = (lg, key) => path.basename(pathOf(lg, key), '.png'),
        gate = null,
        stages = null,
        compareOnly = false,
        allowEmpty = false,
        compareAll = false,
        onKnownDefect = null,
        match = assertBaselineMatch,
        writeFile = (p, buf) => {
            fs.mkdirSync(path.dirname(p), { recursive: true })
            fs.writeFileSync(p, buf)
        },
        log = console.log,
    } = opt
    if (mode !== 'regen' && mode !== 'compare') {
        throw new Error(`runBaselineCase: mode 須為 'regen' 或 'compare',實得「${mode}」`)
    }
    if (!lang || !name || typeof launch !== 'function' || typeof run !== 'function' || typeof pathOf !== 'function') {
        throw new Error('runBaselineCase: lang、name、launch、run、pathOf 為必填')
    }
    //告知篩選器標準圖路徑函數, 供其 finalize() 做孤兒檢查(篩選器建立時已給 pathOf 者不覆寫)
    if (gate && typeof gate.usePathOf === 'function') {
        gate.usePathOf(pathOf)
    }

    let ctx = { mode, lang, name, browser: null, browserRef: { current: null }, page: null, shots: [], result: undefined }
    let out = { status: mode, shots: [], written: [], kept: [], skipped: [] }
    try {
        if (prepare) {
            await prepare(ctx)
        }
        ctx.browser = await launch()
        ctx.browserRef.current = ctx.browser
        ctx.page = await openPage(ctx.browser, ctx)
        if (beforeRun) {
            await beforeRun(ctx)
        }
        let result = await run(ctx.page, lang, ctx)
        ctx.result = result
        if (result && typeof result === 'object' && !Buffer.isBuffer(result) && result.page && ('buf' in result || 'shots' in result)) {
            ctx.page = result.page
        }
        ctx.shots = normalizeShots(result, name)
        out.shots = ctx.shots.map((s) => s.key)
        if (ctx.shots.length === 0 && !allowEmpty) {
            throw new Error(`runBaselineCase: ${lang}-${name} 未產生任何截圖 (若屬設計如此請設 allowEmpty)`)
        }
        if (Array.isArray(stages)) {
            let got = new Set(out.shots)
            let want = new Set(stages)
            let extra = out.shots.filter((k) => !want.has(k))
            let lack = stages.filter((k) => !got.has(k))
            if (extra.length > 0 || lack.length > 0) {
                throw new Error(`runBaselineCase: ${lang}-${name} 產出之圖鍵與宣告不符: 多 [${extra.join(', ')}] 少 [${lack.join(', ')}]`)
            }
        }
        //語意斷言與不變式: 全部通過才寫檔 / 比對 (C13: 寫檔前語意斷言先過)
        if (semantic) {
            await semantic(ctx)
        }
        if (verify) {
            await verify(ctx)
        }

        if (mode === 'regen' && compareOnly) {
            out.status = 'compareOnly'
            out.skipped = out.shots.slice()
            log(`  [compare-only] ${lang}-${name}: 共用他案標準圖, 不寫檔`)
            return out
        }
        if (mode === 'regen') {
            for (let s of ctx.shots) {
                if (gate && !gate.shouldWrite(lang, name, s.key)) {
                    out.skipped.push(s.key)
                    log(`  [skip] ${lang}-${s.key}`)
                    continue
                }
                let base = pathOf(lang, s.key)
                let reason = ''
                if (gate) {
                    gate.noteProduced(lang, name, s.key)
                    let d = gate.decideWrite(s.buf, base)
                    if (!d.write) {
                        out.kept.push(s.key)
                        log(`  [keep] ${lang}-${s.key} (${d.reason})`)
                        continue
                    }
                    reason = d.reason
                }
                let target = gate ? gate.outPath(base) : base
                writeFile(target, s.buf)
                out.written.push(s.key)
                //寫出者亦印出(含 decideWrite 之原因, 如 diff=661px), 與 [keep] 對稱; 否則只能以檔案時間回推哪些圖被重產
                log(`  [write] ${lang}-${s.key}${reason ? ` (${reason})` : ''}`)
            }
            return out
        }

        //compare
        let errs = []
        for (let s of ctx.shots) {
            try {
                await match(s.buf, pathOf(lang, s.key), labelOf(lang, s.key))
            }
            catch (err) {
                if (!compareAll) {
                    throw err
                }
                errs.push(err)
            }
        }
        if (errs.length > 0) {
            throw new Error(`${lang}-${name} 共 ${errs.length}/${ctx.shots.length} 張與標準圖不一致:\n${errs.map((e) => e.message).join('\n')}`)
        }
        return out
    }
    catch (err) {
        if (err && err.knownDefect) {
            if (mode === 'regen') {
                log(`  [known-defect] ${lang}-${name}: ${err.message} — 不寫圖`)
                out.status = 'knownDefect'
                return out
            }
            if (typeof onKnownDefect === 'function') {
                await onKnownDefect(err)
            }
        }
        throw err
    }
    finally {
        let b = ctx.browserRef.current || ctx.browser
        if (b) {
            await b.close().catch(() => {})
        }
        if (ctx.browser && ctx.browser !== b) {
            await ctx.browser.close().catch(() => {})
        }
        if (afterCase) {
            await afterCase(ctx)
        }
    }
}


export default runBaselineCase