昨天把 Demo App 準備好了,也讓大家看到她的樣子了。今天來讓 Playwright 操作它。
今天的目標其實很簡單,就是讓 Playwright 幫我們截一張圖片就好了,並且是 Electron 和 Web 各一張。
通常只要維護一種就好了,但這邊為了 Demo 就都展示一下。
Electron 跟 Web 的啟動方式在底層完全不同,但對 manifest(用來描述一本手冊長什麼樣的設定檔)來說不應該需要知道這個差別。換句話說,一份設定檔寫完,換一個模式就能同時對兩種形態產出手冊。
所以,「啟動待測物」這件事需要抽成一層介面,包含四個方法:
interface AppDriver {
launch(): Promise<Page>
setStorage(kv: Record<string, string>): Promise<void>
resize(width: number, height: number): Promise<void>
close(): Promise<void>
}
launch:把應用程式啟動起來並回傳可操作的 Page
setStorage:狀態注入的底層機制,目前只要管「怎麼把值送進去」,至於要送什麼 (或是其他還沒想到的),之後再討論resize:統一視窗尺寸,確保截圖尺寸一致close:收尾上層只認得這四個方法,底下實際是 WebDriver 還是 ElectronDriver,由 createDriver(config) 依設定檔裡的 app.mode 決定。
設定檔這邊順帶一提:config.json 是一人一份、不進版控的,找不到就退回 config.example.json,所以 clone 下來不必先做任何設定就能跑。
Web 這邊相對單純,就是標準的 Playwright 流程:
啟動瀏覽器 -> 開 context(尺寸在這一步就決定)-> 取得 page -> 跳轉到 URL
const browser = await chromium.launch()
const context = await browser.newContext({
viewport: { width: 1600, height: 900 },
deviceScaleFactor: 2,
})
const page = await context.newPage()
await page.goto(url, { waitUntil: 'domcontentloaded' })
viewport 決定畫面尺寸,deviceScaleFactor 決定截圖的解析度倍率。片段裡寫死的數字只是為了好讀,實際的值是從 config.viewport 讀出來的,deviceScaleFactor 沒指定就是 2。
這兩個數字怎麼選其實牽涉到列印需求 (截圖印在 A4 上夠不夠清晰),現在先用一個夠用的數值就好,之後要列印時再評估是否要調整。
Electron 這邊細節就多了,連順序都跟 Web 不一樣:
啟動 App -> 取得 page(視窗開起來時頁面已經載好了,不用跳轉)-> 改尺寸 (得另外走主行程)
const app = await _electron.launch({
executablePath: require('electron'),
args: [projectDir, '--force-device-scale-factor=1'],
cwd: projectDir,
})
const page = await app.firstWindow()
_electron.launch的完整參數可以參考 Playwright 官方文件,這邊只挑跟截圖一致性有關的幾個講。
executablePath 指的是 electron 執行檔,不是你的 App。 在 Node 裡 require('electron') 回傳的是 binary 的路徑字串(在 Electron 裡面才是 API 物件);真正指向 App 的是 args 的第一個元素,那個目錄裡的 package.json 的 main 才是主行程進入點。如果 App 已經打包成 .exe,就直接指向那個 exe。
--force-device-scale-factor=1 是為了讓截圖尺寸可重現。 不同作業系統、不同螢幕的系統縮放比例不一樣,不釘死的話,同一份程式碼在不同機器上截出來的圖尺寸就會不一致。而且這件事還牽涉到,後續要在截圖上畫框跟標號時,位置都是靠 boundingBox() 算出來的,座標系一旦跟著系統縮放漂移,標註就會整片對不準。
page 只碰得到渲染行程。 Electron 有主行程(Node.js 環境,能碰作業系統層級的 API)跟渲染行程(瀏覽器環境,一般網頁操作的範圍)兩種執行環境,Playwright 的 page 操作的是後者。所以「視窗尺寸」這種屬於視窗、不屬於網頁的東西,只能用 app.evaluate() 跑到主行程那一側去改:
await app.evaluate(async ({ BrowserWindow }, size) => {
const win = BrowserWindow.getAllWindows()[0]
win.setContentSize(size.width, size.height)
win.setAlwaysOnTop(false)
}, { width, height })
用 setContentSize 而不是 setSize,是因為 App 開了 useContentSize: true,尺寸指的是內容區。
今天的目標是「讓 Playwright 截一張圖」,所以驗證的方式就照著目標走一遍:把兩個 driver 兜起來,跑 「啟動 -> 注入狀態 -> 統一尺寸 -> 等畫面就緒 -> 截圖 -> 收尾」 這條路,看 Electron 跟 Web 是不是都真的能落地拿到一張圖。
const driver = createDriver(config, mode)
// launch 之前注入 —— 這是首選路徑,App 讀 localStorage 時值已經在了。
await driver.setStorage({ locale: 'en', role: 'admin' })
const page = await driver.launch()
await driver.resize(config.viewport.width, config.viewport.height)
// 骨架屏消失、清單出現才按快門,不要插入固定延遲。
await page.waitForSelector('[data-testid="camera-list"]', { state: 'visible' })
await page.screenshot({ path: shot })
await driver.close()
各位要測試的話可以下這兩個指令:
npx tsx runner/drivers/smoke.ts --mode electron
npx tsx runner/drivers/smoke.ts --mode web
截圖的畫面長這樣:

(其實就跟昨天文章附的圖片差不多)
「等畫面就緒」那一步等的是 [data-testid="camera-list"] 出現,而不是 sleep(2000) 這種寫死的等待時間。至於為什麼元件要靠 data-testid 來認,明天就會說明,今天先跳過XD
兩邊都注入 locale=en 跟 role=admin,截出來的圖是英文介面、右上角掛著 Administrator 徽章,Electron / Web 的模式徽章也各自正確——表示狀態注入跟 preload 都有生效。
回頭看,今天的目標「Electron 跟 Web 各截一張圖」兩條路線都做到了,順利地完成:
「啟動 -> 注入狀態 -> 統一尺寸 -> 等畫面就緒 -> 截圖 -> 收尾」
雖然連一次點擊都還沒有,但至少證明 Playwright 能驅動這兩種形態,接下來後面所有的操作才有基礎可以延伸討論。
順帶一提,這支腳本之所以叫
smoke.ts,就是因為它做的正是 smoke test 這件事,它不驗證功能對不對,只驗證東西有沒有冒煙、這條路通不通。
到目前為止,已經把基本的內容都介紹得差不多了,接下來就會開始補充這條路線的細節,包含狀態注入、截圖時機等。不過,明天會先從最重要的一件事開始:怎麼讓程式穩定地找到畫面上的元件。
大家明天見!