fix(ads): [PKG-ADS-05] preserve ownership and verify native browser lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 08:52:51 +08:00
1 parent fb1cfa701d
commit 29692d2815
22 files changed
+1060 -29

No files matched your search

+11
View File
@@ -10,6 +10,17 @@ const skips = host => host.calls.filter(call => call[0] === 'emit' && call[1] ==
const contentPlays = host => host.calls.filter(call => call[0] === 'content.play')
for (const { name, factory } of implementations) {
test(`${name}: destroy before initialization preserves caller-owned undefined template property`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
Object.defineProperty(host.art.template, '$ads', { configurable: true, enumerable: true, writable: true, value: undefined })
const before = Object.getOwnPropertyDescriptor(host.art.template, '$ads')
factory({ html: 'ad' })(host.art)
host.art.destroy()
assert.deepEqual(Object.getOwnPropertyDescriptor(host.art.template, '$ads'), before)
assert.equal(env.timers.size, 0)
})
test(`${name}: repeated play owns one timer and pause clears all countdown work`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
+136
View File
@@ -0,0 +1,136 @@
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let candidate
test.beforeAll(async () => {
candidate = process.env.ARTPLAYER_ADS_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_ADS_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-ads', 'umd')
})
async function setup(page, core, testInfo, option = {}) {
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: candidate })
await testInfo.attach('ads-ui-inputs', { contentType: 'application/json', body: JSON.stringify({ core, artifact: process.env.ARTPLAYER_ADS_ARTIFACT || 'workspace source build', sha256: hash(candidate) }) })
await page.evaluate((option) => {
window.adsEvents = []
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, mutex: false, plugins: [window.artplayerPluginAds({ html: 'ad', totalDuration: 60, playDuration: 0, ...option })] })
window.art.on('artplayerPluginAds:skip', () => window.adsEvents.push('skip'))
document.querySelector('#play').onclick = () => window.art.play()
}, option)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
test.afterEach(async ({ page }) => {
await page.evaluate(() => {
for (const art of [window.art, window.otherArt]) {
if (art && !art.isDestroy)
art.destroy()
}
})
})
async function fullscreenState(page, active) {
await expect.poll(() => page.evaluate(() => Boolean(document.fullscreenElement || document.webkitFullscreenElement))).toBe(active)
await expect.poll(() => page.evaluate(() => window.art.fullscreen)).toBe(active)
const icons = page.locator('.artplayer-plugin-ads-fullscreen > *')
await expect(icons).toHaveCount(2)
await expect(icons.nth(active ? 1 : 0)).toBeVisible()
await expect(icons.nth(active ? 0 : 1)).toBeHidden()
}
for (const core of ['published-4.5.5', 'published', 'candidate']) {
test(`${core}: Ads initializes inside native fullscreen and follows external exit and its own button`, async ({ page }, testInfo) => {
await setup(page, core, testInfo)
const native = await page.evaluate(() => Boolean(document.fullscreenEnabled || document.webkitFullscreenEnabled))
await testInfo.attach('ads-native-capability', { contentType: 'application/json', body: JSON.stringify({ native }) })
expect(native, 'Native desktop fullscreen must be exercised, not simulated').toBe(true)
await page.evaluate(() => {
document.querySelector('#play').onclick = () => {
window.art.fullscreen = true
}
})
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => (document.fullscreenElement || document.webkitFullscreenElement) === window.art.template.$player)).toBe(true)
await page.evaluate(() => window.art.play())
await fullscreenState(page, true)
await expect(page.locator('.artplayer-plugin-ads')).toBeVisible()
expect(await page.evaluate(() => (document.fullscreenElement || document.webkitFullscreenElement).contains(window.art.template.$ads))).toBe(true)
await page.evaluate(() => {
window.art.fullscreen = false
})
await fullscreenState(page, false)
await page.locator('.artplayer-plugin-ads-fullscreen').click()
await fullscreenState(page, true)
await page.locator('.artplayer-plugin-ads-fullscreen').click()
await fullscreenState(page, false)
await testInfo.attach('ads-fullscreen-return', { contentType: 'image/png', body: await page.screenshot() })
await page.locator('.artplayer-plugin-ads-close').click()
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.2)
expect(await page.evaluate(() => window.adsEvents)).toEqual(['skip'])
})
test(`${core}: two Ads players own independent decoded videos, mute state and destruction`, async ({ page }, testInfo) => {
await setup(page, core, testInfo, { video: '/test/pattern.mp4', muted: true })
await page.evaluate(() => {
const container = document.createElement('div')
container.className = 'other-player'
container.style.cssText = 'width:320px;height:180px'
document.body.append(container)
window.otherArt = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, mutex: false, plugins: [window.artplayerPluginAds({ video: '/test/pattern.mp4', muted: true, totalDuration: 60, playDuration: 0 })] })
document.querySelector('#play').onclick = () => Promise.all([window.art.play(), window.otherArt.play()])
})
await expect.poll(() => page.evaluate(() => window.otherArt.isReady)).toBe(true)
await page.locator('#play').click()
for (const selector of ['.player', '.other-player']) {
await expect.poll(() => page.locator(`${selector} .artplayer-plugin-ads-video`).evaluate(video => video.currentTime)).toBeGreaterThan(0.2)
expect(await page.locator(`${selector} .artplayer-plugin-ads-video`).evaluate(video => video.readyState >= 2 && video.videoHeight > 0)).toBe(true)
}
await page.locator('.player .artplayer-plugin-ads-muted').click()
expect(await page.locator('.player .artplayer-plugin-ads-video').evaluate(video => video.muted)).toBe(false)
expect(await page.locator('.other-player .artplayer-plugin-ads-video').evaluate(video => video.muted)).toBe(true)
const otherTime = await page.locator('.other-player .artplayer-plugin-ads-video').evaluate(video => video.currentTime)
await page.evaluate(() => {
window.retainedAd = window.art.template.$ads.querySelector('video')
window.art.destroy(false)
})
await expect(page.locator('.player .artplayer-plugin-ads')).toHaveCount(0)
expect(await page.evaluate(() => ({ src: window.retainedAd.getAttribute('src'), paused: window.retainedAd.paused }))).toEqual({ src: null, paused: true })
await expect.poll(() => page.locator('.other-player .artplayer-plugin-ads-video').evaluate(video => video.currentTime)).toBeGreaterThan(otherTime + 0.2)
await page.locator('.other-player .artplayer-plugin-ads-close').click()
await expect.poll(() => page.evaluate(() => window.otherArt.currentTime)).toBeGreaterThan(0.2)
expect(await page.evaluate(() => window.adsEvents)).toEqual([])
})
test(`${core}: Ads details use real popup navigation and translated controls stay within a narrow player`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
await setup(page, core, testInfo, { url: '/test/player.html?ad-details=true', i18n: { close: 'Close ad', countdown: '%s seconds', detail: 'Details', canBeClosed: 'Wait %s seconds' } })
await page.locator('.player').evaluate((element) => {
element.style.width = '360px'
})
await page.evaluate(() => window.art.on('artplayerPluginAds:click', value => window.adsEvents.push(value.url)))
await page.locator('#play').click()
await expect(page.locator('.artplayer-plugin-ads-close')).toHaveText('Close ad')
await expect(page.locator('.artplayer-plugin-ads-detail')).toHaveText('Details')
const popupPromise = page.waitForEvent('popup')
await page.locator('.artplayer-plugin-ads-detail').click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded')
expect(new URL(popup.url()).searchParams.get('ad-details')).toBe('true')
await popup.close()
expect(await page.evaluate(() => window.adsEvents)).toEqual(['/test/player.html?ad-details=true'])
const bounds = await page.locator('.artplayer-plugin-ads').evaluate((root) => {
const parent = root.getBoundingClientRect()
return [...root.querySelectorAll('.artplayer-plugin-ads-timer, .artplayer-plugin-ads-control')].every((element) => {
const box = element.getBoundingClientRect()
return box.left >= parent.left && box.right <= parent.right && box.top >= parent.top && box.bottom <= parent.bottom
})
})
expect(bounds).toBe(true)
await testInfo.attach('ads-narrow-layout', { contentType: 'image/png', body: await page.screenshot() })
})
}
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { setTimeout as delay } from 'node:timers/promises'
import { chromium } from '@playwright/test'
// Use only an owned fresh profile. Playwright's normal launch forces document visibility.
export async function nativeChrome() {
const executable = process.env.ARTPLAYER_NATIVE_CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe'
assert(fs.existsSync(executable), 'Set ARTPLAYER_NATIVE_CHROME to an installed Chrome executable')
const cache = fs.realpathSync('refactor/.cache')
const profile = fs.mkdtempSync(path.join(cache, 'native-chrome-'))
// Isolate native tab visibility from unrelated desktop windows covering this test window.
const child = spawn(executable, ['--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', '--disable-backgrounding-occluded-windows', 'about:blank'], { windowsHide: true, stdio: 'ignore' })
let launchError
child.on('error', (error) => {
launchError = error
})
let browser
async function close() {
if (browser?.isConnected()) {
const session = await browser.newBrowserCDPSession()
await session.send('Browser.close').catch(() => {})
await browser.close()
}
if (child.exitCode === null && !child.killed)
child.kill()
assert.equal(path.dirname(fs.realpathSync(profile)), cache)
assert(path.basename(profile).startsWith('native-chrome-'))
// Chrome may still be releasing files; keep the isolated profile on cleanup failure.
try {
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
}
catch (error) { console.warn(`Isolated Chrome profile retained: ${profile}`, error.message) }
}
try {
const activePort = path.join(profile, 'DevToolsActivePort')
let port
for (let attempt = 0; !port && attempt < 100; attempt++) {
if (launchError)
throw launchError
assert.equal(child.exitCode, null, 'Isolated Chrome exited before exposing its endpoint')
try {
const content = fs.readFileSync(activePort, 'utf8')
const match = /^(\d+)\r?\n\/devtools\/browser\/[^\r\n]+/.exec(content)
if (match && Number(match[1]) > 0 && Number(match[1]) < 65536)
port = match[1]
}
catch (error) {
if (!['ENOENT', 'EBUSY'].includes(error.code))
throw error
}
if (!port)
await delay(100)
}
assert(port, 'Isolated Chrome did not expose a complete endpoint')
browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { noDefaults: true })
return { browser, context: browser.contexts()[0], executable, close }
}
catch (error) {
await close()
throw error
}
}