mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-23 05:41:20 -08:00
fix(ads): [PKG-ADS-05] preserve ownership and verify native browser lifecycle
This commit is contained in:
1 parent
fb1cfa701d
commit
29692d2815
22 files changed
+1060
-29
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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() })
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user