mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-11 13:56:15 -08:00
test(ads): [PKG-ADS-02] cover legacy timing and real media contracts
This commit is contained in:
1 parent
c6b82c3392
commit
069ad4aae1
15 files changed
+1519
-17
No files matched your search
@@ -2,6 +2,12 @@
|
||||
|
||||
Use the pinned Node/Yarn toolchain from `../refactor/toolchain-setup.md`.
|
||||
|
||||
`yarn test:ads` runs source, verified npm 1.0.6 and the frozen unpublished 2.1.0
|
||||
Ads bundle against controlled clock/host contracts. It is included in `test:unit`.
|
||||
The helper uses the real option validator but does not simulate media decoding or layout.
|
||||
Historical defect assertions apply only to frozen implementations. See
|
||||
[Ads validation](../refactor/ads-validation.md) for artifact overrides and browser scope.
|
||||
|
||||
| Command | Scope |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `yarn test:unit` | Playback and DASH regressions, public contracts against released/current code, and JS/TS fixture loading |
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Controlled clock/host contracts use Node's runner.
|
||||
import test from 'node:test'
|
||||
import { adsEnvironment, adsImplementations } from './helpers/ads.js'
|
||||
|
||||
const implementations = await adsImplementations()
|
||||
const skips = host => host.calls.filter(call => call[0] === 'emit' && call[1] === 'artplayerPluginAds:skip')
|
||||
const clicks = host => host.calls.filter(call => call[0] === 'emit' && call[1] === 'artplayerPluginAds:click')
|
||||
const contentPlays = host => host.calls.filter(call => call[0] === 'content.play')
|
||||
|
||||
for (const { name, factory } of implementations) {
|
||||
test(`${name}: lazy synchronous factory starts once after ready and either playback event`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
const result = factory({ html: '<b>ad</b>' })(host.art)
|
||||
assert.deepEqual(Object.keys(result), ['name', 'skip', 'pause', 'play'])
|
||||
assert.equal(result.name, 'artplayerPluginAds')
|
||||
assert.equal(host.node(), undefined)
|
||||
assert.equal(env.timers.size, 0)
|
||||
host.art.emit('play')
|
||||
assert.equal(host.node(), undefined)
|
||||
host.art.emit('ready').emit('video:playing')
|
||||
const node = host.node()
|
||||
assert(node)
|
||||
assert.equal(host.art.template.$ads, node)
|
||||
assert.equal(host.node('html').innerHTML, '<b>ad</b>')
|
||||
assert.equal(host.art.playing, false)
|
||||
host.art.emit('play').emit('video:playing')
|
||||
assert.equal(host.node(), node)
|
||||
assert.equal(env.timers.size, 1)
|
||||
assert.equal(host.calls.filter(call => call[0] === 'content.pause').length, 1)
|
||||
})
|
||||
|
||||
test(`${name}: actual validator keeps numeric durations and complete shallow i18n`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
assert.throws(() => factory({ totalDuration: '10' })(env.host().art), /totalDuration/)
|
||||
assert.throws(() => factory({ playDuration: '5' })(env.host().art), /playDuration/)
|
||||
assert.throws(() => factory({ html: 1 })(env.host().art), /html/)
|
||||
assert.throws(() => factory({ i18n: { close: 'close' } })(env.host().art), /i18n/)
|
||||
const host = env.host()
|
||||
factory()(host.art)
|
||||
assert.equal(host.normalized.totalDuration, 10)
|
||||
assert.equal(host.normalized.playDuration, 5)
|
||||
assert.equal(host.normalized.muted, false)
|
||||
assert.equal(host.normalized.html, '')
|
||||
assert.equal(host.normalized.video, '')
|
||||
})
|
||||
|
||||
test(`${name}: video wins over HTML and metadata starts media and countdown`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
factory({ html: '<b>fallback</b>', video: 'ad.mp4', muted: true })(host.art)
|
||||
host.start()
|
||||
assert.equal(host.node('html'), undefined)
|
||||
assert.equal(host.node('video').tagName, 'VIDEO')
|
||||
assert.equal(host.node('video').muted, true)
|
||||
assert.equal(env.timers.size, 0)
|
||||
host.fire('video', 'loadedmetadata')
|
||||
assert.equal(env.timers.size, 1)
|
||||
assert.equal(host.node('video').paused, false)
|
||||
assert.equal(host.node('timer').style.display, 'flex')
|
||||
assert.equal(host.node('control').style.display, 'flex')
|
||||
assert.equal(host.node('loading').style.display, 'none')
|
||||
assert.equal(host.calls.filter(call => call[0] === 'ad.play').length, 1)
|
||||
})
|
||||
|
||||
test(`${name}: each active second counts down and total duration restores content`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
factory({ html: 'ad', playDuration: 2, totalDuration: 3 })(host.art)
|
||||
host.start()
|
||||
assert.equal(host.node('close').innerHTML, '2秒后可关闭广告')
|
||||
assert.equal(host.node('countdown').innerHTML, '3秒')
|
||||
env.tick(999)
|
||||
assert.equal(host.node('countdown').innerHTML, '3秒')
|
||||
env.tick(1)
|
||||
assert.equal(host.node('close').innerHTML, '1秒后可关闭广告')
|
||||
assert.equal(host.node('countdown').innerHTML, '2秒')
|
||||
env.tick(1000)
|
||||
assert.equal(host.node('close').innerHTML, '关闭广告')
|
||||
assert.equal(contentPlays(host).length, 0)
|
||||
env.tick(1000)
|
||||
assert.equal(host.node('countdown').innerHTML, '0秒')
|
||||
assert.equal(host.node().style.display, 'none')
|
||||
assert.equal(skips(host).length, 1)
|
||||
assert.equal(skips(host)[0][2], host.normalized)
|
||||
assert.equal(contentPlays(host).length, 1)
|
||||
assert.equal(env.timers.size, 0)
|
||||
})
|
||||
|
||||
test(`${name}: pause/play control the countdown without changing ad video state`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
const plugin = factory({ video: 'ad.mp4', totalDuration: 4 })(host.art)
|
||||
host.start()
|
||||
host.fire('video', 'loadedmetadata')
|
||||
env.tick(1000)
|
||||
assert.equal(plugin.pause(), undefined)
|
||||
env.tick(5000)
|
||||
assert.equal(host.node('countdown').innerHTML, '3秒')
|
||||
assert.equal(host.node('video').paused, false)
|
||||
assert.equal(plugin.play(), undefined)
|
||||
env.tick(1000)
|
||||
assert.equal(host.node('countdown').innerHTML, '2秒')
|
||||
assert.equal(host.calls.filter(call => call[0] === 'ad.play').length, 1)
|
||||
})
|
||||
|
||||
test(`${name}: button obeys positive threshold while public skip bypasses it`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
const plugin = factory({ html: 'ad', playDuration: 2, totalDuration: 5 })(host.art)
|
||||
host.start()
|
||||
host.fire('close', 'click')
|
||||
env.tick(1000)
|
||||
host.fire('close', 'click')
|
||||
assert.equal(skips(host).length, 0)
|
||||
assert.equal(plugin.skip(), undefined)
|
||||
assert.equal(skips(host).length, 1)
|
||||
const second = env.host()
|
||||
factory({ html: 'second', playDuration: 2, totalDuration: 5 })(second.art)
|
||||
second.start()
|
||||
env.tick(2000)
|
||||
second.fire('close', 'click')
|
||||
assert.equal(skips(second).length, 1)
|
||||
})
|
||||
|
||||
test(`${name}: non-skippable button stays hidden and automatic completion still occurs`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
factory({ html: 'ad', playDuration: 2, totalDuration: 2 })(host.art)
|
||||
host.start()
|
||||
assert.equal(host.node('close').style.display, 'none')
|
||||
env.tick(2000)
|
||||
assert.equal(skips(host).length, 1)
|
||||
})
|
||||
|
||||
test(`${name}: translations preserve first-placeholder substitution and normalized payload`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
const i18n = { close: 'skip', countdown: '%s seconds %s', detail: 'visit', canBeClosed: 'wait %s' }
|
||||
factory({ html: 'ad', playDuration: 1, totalDuration: 3, i18n })(host.art)
|
||||
host.start()
|
||||
assert.equal(host.node('countdown').innerHTML, '3 seconds %s')
|
||||
assert.equal(host.node('close').innerHTML, 'wait 1')
|
||||
env.tick(1000)
|
||||
assert.equal(host.node('close').innerHTML, 'skip')
|
||||
host.fire('close', 'click')
|
||||
assert.equal(skips(host)[0][2].i18n, i18n)
|
||||
})
|
||||
|
||||
test(`${name}: ad/detail clicks preserve URL and option payload; unlinked ad still emits`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
factory({ html: '<img src="poster.png">', url: 'https://example.invalid/ad' })(host.art)
|
||||
host.start()
|
||||
host.fire('html', 'click')
|
||||
host.fire('detail', 'click')
|
||||
assert.deepEqual(env.opened, ['https://example.invalid/ad', 'https://example.invalid/ad'])
|
||||
assert.equal(clicks(host).length, 2)
|
||||
assert(clicks(host).every(call => call[2] === host.normalized))
|
||||
const second = env.host()
|
||||
factory({ html: 'unlinked' })(second.art)
|
||||
second.start()
|
||||
assert.equal(second.node('detail').style.display, 'none')
|
||||
second.fire('html', 'click')
|
||||
assert.equal(clicks(second).length, 1)
|
||||
assert.equal(env.opened.length, 2)
|
||||
})
|
||||
|
||||
test(`${name}: mute applies only to the ad and toggles existing icons`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
factory({ video: 'ad.mp4', muted: true })(host.art)
|
||||
host.start()
|
||||
assert.equal(host.nodes.get('volume').style.display, 'none')
|
||||
assert.equal(host.nodes.get('volumeClose').style.display, 'inline-flex')
|
||||
host.fire('muted', 'click')
|
||||
assert.equal(host.node('video').muted, false)
|
||||
assert.equal(host.nodes.get('volume').style.display, 'inline-flex')
|
||||
assert.equal(host.nodes.get('volumeClose').style.display, 'none')
|
||||
host.fire('muted', 'click')
|
||||
assert.equal(host.node('video').muted, true)
|
||||
})
|
||||
|
||||
test(`${name}: document visibility pauses and resumes only the countdown`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
factory({ html: 'ad', totalDuration: 4 })(host.art)
|
||||
host.start()
|
||||
env.tick(1000)
|
||||
host.visibility(true)
|
||||
env.tick(5000)
|
||||
assert.equal(host.node('countdown').innerHTML, '3秒')
|
||||
host.visibility(false)
|
||||
env.tick(1000)
|
||||
assert.equal(host.node('countdown').innerHTML, '2秒')
|
||||
})
|
||||
|
||||
test(`${name}: media error before metadata restores content and retains skip order`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
factory({ video: 'missing.mp4' })(host.art)
|
||||
host.start()
|
||||
host.calls.length = 0
|
||||
host.fire('video', 'error')
|
||||
assert.deepEqual(host.calls.map(call => call.slice(0, 2)), [
|
||||
['content.play', false],
|
||||
['emit', 'play'],
|
||||
['ad.pause'],
|
||||
['style', 'artplayer-plugin-ads'],
|
||||
['emit', 'artplayerPluginAds:skip'],
|
||||
])
|
||||
assert.equal(host.node().style.display, 'none')
|
||||
assert.equal(env.timers.size, 0)
|
||||
})
|
||||
|
||||
test(`${name}: separate factories keep instance timers and event options independent`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const first = env.host()
|
||||
const second = env.host()
|
||||
const firstPlugin = factory({ html: 'first', totalDuration: 2 })(first.art)
|
||||
factory({ html: 'second', totalDuration: 3 })(second.art)
|
||||
first.start()
|
||||
second.start()
|
||||
firstPlugin.pause()
|
||||
env.tick(3000)
|
||||
assert.equal(skips(first).length, 0)
|
||||
assert.equal(first.node('countdown').innerHTML, '2秒')
|
||||
assert.equal(skips(second).length, 1)
|
||||
assert.equal(skips(second)[0][2], second.normalized)
|
||||
firstPlugin.play()
|
||||
env.tick(2000)
|
||||
assert.equal(skips(first).length, 1)
|
||||
assert.equal(skips(first)[0][2], first.normalized)
|
||||
})
|
||||
|
||||
test(`${name}: initial input is read at attach and completed preroll stays hidden after restart`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
const option = { html: 'before', totalDuration: 1 }
|
||||
const attach = factory(option)
|
||||
option.html = 'at attach'
|
||||
attach(host.art)
|
||||
option.html = 'after attach'
|
||||
host.start()
|
||||
assert.equal(host.node('html').innerHTML, 'at attach')
|
||||
env.tick(1000)
|
||||
const root = host.node()
|
||||
host.art.emit('restart', 'second.mp4').emit('ready').emit('play').emit('video:playing')
|
||||
assert.equal(host.node(), root)
|
||||
assert.equal(root.style.display, 'none')
|
||||
assert.equal(skips(host).length, 1)
|
||||
assert.equal(env.timers.size, 0)
|
||||
})
|
||||
}
|
||||
|
||||
// Freeze legacy defects separately; migrated candidates must not inherit these assertions.
|
||||
for (const { name, factory } of implementations.filter(item => item.historical)) {
|
||||
test(`${name} historical: duplicate play starts parallel countdown chains`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
const plugin = factory({ html: 'ad', totalDuration: 10 })(host.art)
|
||||
host.start()
|
||||
plugin.play()
|
||||
assert.equal(env.timers.size, 2)
|
||||
env.tick(1000)
|
||||
assert.equal(host.node('countdown').innerHTML, '8秒')
|
||||
plugin.pause()
|
||||
assert.equal(env.timers.size, 1)
|
||||
})
|
||||
|
||||
test(`${name} historical: skip leaves a queued callback and emits completion again`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
const plugin = factory({ html: 'ad', totalDuration: 1 })(host.art)
|
||||
host.start()
|
||||
plugin.skip()
|
||||
assert.equal(env.timers.size, 1)
|
||||
env.tick(1000)
|
||||
assert.equal(skips(host).length, 2)
|
||||
plugin.skip()
|
||||
assert.equal(skips(host).length, 3)
|
||||
})
|
||||
|
||||
test(`${name} historical: destroy leaves countdown intent that requests content play`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
factory({ html: 'ad', totalDuration: 1 })(host.art)
|
||||
host.start()
|
||||
host.art.destroy()
|
||||
assert.equal(env.timers.size, 1)
|
||||
env.tick(1000)
|
||||
assert.deepEqual(contentPlays(host), [['content.play', true]])
|
||||
})
|
||||
|
||||
test(`${name} historical: zero wait displays close immediately but rejects the first click`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
factory({ html: 'ad', playDuration: 0, totalDuration: 3 })(host.art)
|
||||
host.start()
|
||||
assert.equal(host.node('close').innerHTML, '关闭广告')
|
||||
host.fire('close', 'click')
|
||||
assert.equal(skips(host).length, 0)
|
||||
env.tick(1000)
|
||||
host.fire('close', 'click')
|
||||
assert.equal(skips(host).length, 1)
|
||||
})
|
||||
|
||||
test(`${name} historical: skip before initialization plays content then throws`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
const plugin = factory({ html: 'ad' })(host.art)
|
||||
assert.throws(() => plugin.skip(), /undefined/)
|
||||
assert.equal(contentPlays(host).length, 1)
|
||||
})
|
||||
|
||||
test(`${name} historical: reusing one factory changes the first instance event payload identity`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const attach = factory({ html: 'shared' })
|
||||
const first = env.host()
|
||||
const firstPlugin = attach(first.art)
|
||||
first.start()
|
||||
const firstOption = first.normalized
|
||||
const second = env.host()
|
||||
attach(second.art)
|
||||
second.start()
|
||||
firstPlugin.skip()
|
||||
assert.notEqual(firstOption, second.normalized)
|
||||
assert.equal(skips(first)[0][2], second.normalized)
|
||||
})
|
||||
|
||||
test(`${name} historical: repeated metadata schedules and starts the ad twice`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
factory({ video: 'ad.mp4' })(host.art)
|
||||
host.start()
|
||||
host.fire('video', 'loadedmetadata')
|
||||
host.fire('video', 'loadedmetadata')
|
||||
assert.equal(env.timers.size, 2)
|
||||
assert.equal(host.calls.filter(call => call[0] === 'ad.play').length, 2)
|
||||
})
|
||||
|
||||
test(`${name} historical: play before initialization schedules a callback against missing DOM`, (t) => {
|
||||
const env = adsEnvironment(t)
|
||||
const host = env.host()
|
||||
const plugin = factory({ html: 'ad' })(host.art)
|
||||
plugin.play()
|
||||
assert.equal(env.timers.size, 1)
|
||||
assert.throws(() => env.tick(1000), /null|undefined/)
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# Browser regression entry
|
||||
|
||||
`yarn test:browser test/browser/ads.spec.js` tests actual Ads 1.0.6 and candidate code
|
||||
with published 5.4.1 and candidate cores using local image/video media. It covers actual
|
||||
decoded pixels, countdown, skipping, content restoration, 404 and source replacement.
|
||||
Published-only rejection cases inject an exact play failure; they do not certify device
|
||||
autoplay policies. See [Ads validation](../../refactor/ads-validation.md).
|
||||
|
||||
Audio Track tests use the source and frozen 1.1.0 plugin on both cores. The local AAC fixture
|
||||
has a pinned hash and generation command in media/audio-tone.json and uses the existing Range
|
||||
server. The suite retains published cleanup/native-pause defects as explicit observations.
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { compilePackage } from '../helpers/load.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
let published
|
||||
let candidate
|
||||
let release
|
||||
|
||||
test.beforeAll(async () => {
|
||||
release = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/ads-release.json', import.meta.url))).release
|
||||
const member = `package/${release.manifest.main}`
|
||||
const bytes = readMember(await ensureArchive(release), member)
|
||||
assert.equal(hash(bytes), release.files[member])
|
||||
published = bytes.toString()
|
||||
candidate = process.env.ARTPLAYER_ADS_ARTIFACT
|
||||
? fs.readFileSync(process.env.ARTPLAYER_ADS_ARTIFACT, 'utf8')
|
||||
: await compilePackage('artplayer-plugin-ads', 'umd')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
const state = await page.evaluate(() => {
|
||||
const ad = document.querySelector('.artplayer-plugin-ads-video')
|
||||
return {
|
||||
events: window.adsEvents,
|
||||
rejected: window.adsRejected,
|
||||
ad: ad && { time: ad.currentTime, paused: ad.paused, width: ad.videoWidth, height: ad.videoHeight, error: ad.error?.code },
|
||||
content: window.art && { time: window.art.currentTime, paused: window.art.video.paused, destroyed: window.art.isDestroy },
|
||||
}
|
||||
})
|
||||
await testInfo.attach('ads-state', { contentType: 'application/json', body: JSON.stringify(state) })
|
||||
await page.evaluate(() => {
|
||||
// Historical skip/destroy do not cancel their countdown. Explicit pause prevents
|
||||
// fixture teardown from masking the behavior asserted by a different test.
|
||||
window.art?.plugins.artplayerPluginAds?.pause()
|
||||
if (window.art && !window.art.isDestroy)
|
||||
window.art.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
async function openAds(page, core, plugin, option, testInfo) {
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
const code = plugin === 'published' ? published : candidate
|
||||
await page.addScriptTag({ content: code })
|
||||
await testInfo.attach('ads-inputs', { contentType: 'application/json', body: JSON.stringify({ core, plugin, release: plugin === 'published' ? release : undefined, sha256: hash(code), artifact: plugin === 'candidate' ? process.env.ARTPLAYER_ADS_ARTIFACT || 'workspace source build' : undefined }) })
|
||||
await page.evaluate((option) => {
|
||||
window.adsEvents = []
|
||||
window.art = new window.Artplayer({
|
||||
container: '.player',
|
||||
url: '/test/pattern.mp4',
|
||||
muted: true,
|
||||
plugins: [window.artplayerPluginAds(option)],
|
||||
})
|
||||
window.art.on('artplayerPluginAds:skip', value => window.adsEvents.push({ name: 'skip', option: value }))
|
||||
window.art.on('artplayerPluginAds:click', value => window.adsEvents.push({ name: 'click', option: value }))
|
||||
document.querySelector('#play').onclick = () => window.art.play()
|
||||
}, option)
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await expect(page.locator('.artplayer-plugin-ads')).toHaveCount(0)
|
||||
await page.locator('#play').click()
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const plugin of ['published', 'candidate']) {
|
||||
const label = `${core} core / ${plugin} Ads`
|
||||
|
||||
test(`${label}: real image HTML, skip threshold and resumed content`, async ({ page }, testInfo) => {
|
||||
await openAds(page, core, plugin, { html: '<img alt="Ad artwork" src="/test/thumbnail-grid.svg">', playDuration: 2, totalDuration: 8 }, testInfo)
|
||||
const root = page.locator('.artplayer-plugin-ads')
|
||||
await expect(root).toBeVisible()
|
||||
await expect.poll(() => page.locator('img[alt="Ad artwork"]').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true)
|
||||
expect(await page.evaluate(() => window.art.video.paused)).toBe(true)
|
||||
await page.locator('.artplayer-plugin-ads-close').click()
|
||||
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(0)
|
||||
await expect(page.locator('.artplayer-plugin-ads-close')).toHaveText('关闭广告')
|
||||
await page.locator('.artplayer-plugin-ads-close').click()
|
||||
await expect(root).toBeHidden()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
|
||||
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
|
||||
await testInfo.attach('ads-image-completed', { contentType: 'image/png', body: await page.screenshot() })
|
||||
})
|
||||
|
||||
test(`${label}: real video decodes, mute toggles and countdown completion restores content`, async ({ page }, testInfo) => {
|
||||
await openAds(page, core, plugin, { video: '/test/pattern.mp4', html: 'unused', muted: true, playDuration: 5, totalDuration: 3 }, testInfo)
|
||||
const ad = page.locator('.artplayer-plugin-ads-video')
|
||||
await expect.poll(() => ad.evaluate(video => video.currentTime)).toBeGreaterThan(0.2)
|
||||
expect(await ad.evaluate(video => ({ paused: video.paused, muted: video.muted }))).toEqual({ paused: false, muted: true })
|
||||
// Windows WebKit reports layout-sized videoWidth; sample actual decoded colors.
|
||||
const pixels = await ad.evaluate((video) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 80
|
||||
canvas.height = 45
|
||||
const context = canvas.getContext('2d')
|
||||
context.drawImage(video, 0, 0, 80, 45)
|
||||
return [30, 45, 75].map(x => [...context.getImageData(x, 2, 1, 1).data])
|
||||
})
|
||||
expect(pixels[0][0]).toBeGreaterThan(180)
|
||||
expect(pixels[0][1]).toBeGreaterThan(180)
|
||||
expect(pixels[0][2]).toBeLessThan(80)
|
||||
expect(pixels[1][0]).toBeLessThan(80)
|
||||
expect(pixels[1][2]).toBeGreaterThan(180)
|
||||
expect(pixels[2][1]).toBeGreaterThan(180)
|
||||
expect(pixels[2][2]).toBeGreaterThan(180)
|
||||
await testInfo.attach('ads-decoded-pixels', { contentType: 'application/json', body: JSON.stringify(pixels) })
|
||||
await expect(page.locator('.artplayer-plugin-ads-html')).toHaveCount(0)
|
||||
await expect(page.locator('.artplayer-plugin-ads-close')).toBeHidden()
|
||||
await page.locator('.artplayer-plugin-ads-muted').click()
|
||||
expect(await ad.evaluate(video => video.muted)).toBe(false)
|
||||
await page.locator('.artplayer-plugin-ads-muted').click()
|
||||
await testInfo.attach('ads-video-playing', { contentType: 'image/png', body: await page.screenshot() })
|
||||
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
|
||||
expect(await ad.evaluate(video => video.paused)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
|
||||
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
|
||||
})
|
||||
|
||||
test(`${label}: actual missing ad media fails open to playable content`, async ({ page }, testInfo) => {
|
||||
await page.route('**/missing-ad.mp4', route => route.fulfill({ status: 404, body: '' }))
|
||||
await openAds(page, core, plugin, { video: '/missing-ad.mp4', muted: true }, testInfo)
|
||||
await expect.poll(() => page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
|
||||
expect(await page.locator('.artplayer-plugin-ads-video').evaluate(video => video.error?.code)).toBe(4)
|
||||
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
|
||||
})
|
||||
|
||||
test(`${label}: countdown pause resumes once and source replacement does not replay ads`, async ({ page }, testInfo) => {
|
||||
await openAds(page, core, plugin, { html: 'one preroll', playDuration: 1, totalDuration: 8 }, testInfo)
|
||||
await expect(page.locator('.artplayer-plugin-ads')).toBeVisible()
|
||||
await page.evaluate(() => window.art.plugins.artplayerPluginAds.pause())
|
||||
const count = await page.locator('.artplayer-plugin-ads-countdown').textContent()
|
||||
// A real clock observation specifically checks that pause stops countdown.
|
||||
await page.waitForTimeout(1200)
|
||||
await expect(page.locator('.artplayer-plugin-ads-countdown')).toHaveText(count)
|
||||
await page.evaluate(() => window.art.plugins.artplayerPluginAds.play())
|
||||
await expect(page.locator('.artplayer-plugin-ads-countdown')).not.toHaveText(count)
|
||||
await page.evaluate(() => {
|
||||
window.art.plugins.artplayerPluginAds.pause()
|
||||
window.art.plugins.artplayerPluginAds.skip()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
|
||||
await page.evaluate(() => window.art.switchUrl('/test/pattern.mp4?replacement=1'))
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
|
||||
await expect(page.locator('.artplayer-plugin-ads')).toHaveCount(1)
|
||||
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
|
||||
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
|
||||
})
|
||||
}
|
||||
|
||||
test(`${core} core / published Ads historical: rejected ad play leaks a rejection while timer continues`, async ({ page }, testInfo) => {
|
||||
await page.addInitScript(() => {
|
||||
window.adsRejected = []
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (event.reason?.message === 'Ads test controlled NotAllowedError') {
|
||||
window.adsRejected.push({ name: event.reason.name, message: event.reason.message })
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
const play = HTMLMediaElement.prototype.play
|
||||
HTMLMediaElement.prototype.play = function () {
|
||||
if (this.classList.contains('artplayer-plugin-ads-video'))
|
||||
return Promise.reject(new DOMException('Ads test controlled NotAllowedError', 'NotAllowedError'))
|
||||
return play.call(this)
|
||||
}
|
||||
})
|
||||
await openAds(page, core, 'published', { video: '/test/pattern.mp4', muted: true, totalDuration: 2 }, testInfo)
|
||||
await expect.poll(() => page.evaluate(() => window.adsRejected)).toEqual([{ name: 'NotAllowedError', message: 'Ads test controlled NotAllowedError' }])
|
||||
expect(await page.locator('.artplayer-plugin-ads-video').evaluate(video => video.paused)).toBe(true)
|
||||
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.3)
|
||||
})
|
||||
|
||||
test(`${core} core / published Ads historical: rejected content restoration hides ad and leaks rejection`, async ({ page }, testInfo) => {
|
||||
await page.addInitScript(() => {
|
||||
window.adsRejected = []
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (event.reason?.message === 'Ads test controlled content rejection') {
|
||||
window.adsRejected.push({ name: event.reason.name, message: event.reason.message })
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
const play = HTMLMediaElement.prototype.play
|
||||
HTMLMediaElement.prototype.play = function () {
|
||||
if (this === window.art?.video && document.querySelector('.artplayer-plugin-ads'))
|
||||
return Promise.reject(new DOMException('Ads test controlled content rejection', 'NotAllowedError'))
|
||||
return play.call(this)
|
||||
}
|
||||
})
|
||||
await openAds(page, core, 'published', { html: 'ad', totalDuration: 1 }, testInfo)
|
||||
await expect.poll(() => page.evaluate(() => window.adsRejected)).toEqual([{ name: 'NotAllowedError', message: 'Ads test controlled content rejection' }])
|
||||
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
|
||||
expect(await page.evaluate(() => window.art.video.paused)).toBe(true)
|
||||
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
|
||||
})
|
||||
|
||||
test(`${core} core / published Ads historical: zero threshold advertises skip before accepting click`, async ({ page }, testInfo) => {
|
||||
await openAds(page, core, 'published', { html: 'ad', playDuration: 0, totalDuration: 5 }, testInfo)
|
||||
const close = page.locator('.artplayer-plugin-ads-close')
|
||||
await expect(close).toBeVisible()
|
||||
// Freeze only the countdown after real initialization; this is a button contract.
|
||||
await page.evaluate(() => window.art.plugins.artplayerPluginAds.pause())
|
||||
await expect(close).toHaveText('关闭广告')
|
||||
await close.click()
|
||||
await expect(page.locator('.artplayer-plugin-ads')).toBeVisible()
|
||||
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(0)
|
||||
await page.evaluate(() => window.art.plugins.artplayerPluginAds.play())
|
||||
await expect(page.locator('.artplayer-plugin-ads-countdown')).not.toHaveText('5秒')
|
||||
await close.click()
|
||||
await expect(page.locator('.artplayer-plugin-ads')).toBeHidden()
|
||||
expect(await page.evaluate(() => window.adsEvents.filter(event => event.name === 'skip').length)).toBe(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import vm from 'node:vm'
|
||||
import validator from 'option-validator'
|
||||
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { loadPackage } from './load.js'
|
||||
|
||||
function commonJS(bytes) {
|
||||
const module = { exports: {} }
|
||||
vm.runInNewContext(bytes.toString(), {
|
||||
module,
|
||||
exports: module.exports,
|
||||
get document() { return globalThis.document },
|
||||
get window() { return globalThis.window },
|
||||
setTimeout: (...args) => globalThis.setTimeout(...args),
|
||||
clearTimeout: id => globalThis.clearTimeout(id),
|
||||
console,
|
||||
}, { timeout: 5000 })
|
||||
return module.exports.default || module.exports
|
||||
}
|
||||
|
||||
export async function adsImplementations() {
|
||||
const baseline = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/ads-release.json', import.meta.url)))
|
||||
const archive = await ensureArchive(baseline.release)
|
||||
const member = `package/${baseline.release.manifest.main}`
|
||||
const bytes = readMember(archive, member)
|
||||
assert.equal(hash(bytes), baseline.release.files[member])
|
||||
const file = 'packages/artplayer-plugin-ads/dist/artplayer-plugin-ads.js'
|
||||
const historical = execFileSync('git', ['show', `${baseline.sourceCommit}:${file}`])
|
||||
assert.equal(hash(historical.toString().replaceAll('\r\n', '\n')), baseline.source[file])
|
||||
const implementations = [
|
||||
{ name: 'source', factory: (await loadPackage('artplayer-plugin-ads')).default },
|
||||
{ name: 'published-1.0.6', historical: true, factory: commonJS(bytes) },
|
||||
{ name: 'frozen-workspace-2.1.0', historical: true, factory: commonJS(historical) },
|
||||
]
|
||||
for (const file of (process.env.ARTPLAYER_TEST_ADS || '').split(path.delimiter).filter(Boolean)) {
|
||||
const bytes = fs.readFileSync(file)
|
||||
const factory = file.endsWith('.mjs') ? (await import(`data:text/javascript;base64,${bytes.toString('base64')}`)).default : commonJS(bytes)
|
||||
implementations.push({ name: `artifact-${path.basename(file)}`, factory })
|
||||
}
|
||||
return implementations
|
||||
}
|
||||
|
||||
// Intent fixture only: these nodes do not model layout, HTML parsing or media decoding.
|
||||
export function adsEnvironment(t) {
|
||||
const document = Object.assign(new EventTarget(), { hidden: false })
|
||||
const opened = []
|
||||
for (const [name, value] of Object.entries({ document, window: { open: url => opened.push(url) } })) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, name)
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value })
|
||||
t.after(() => previous ? Object.defineProperty(globalThis, name, previous) : Reflect.deleteProperty(globalThis, name))
|
||||
}
|
||||
const timers = new Map()
|
||||
let nextId = 0
|
||||
let now = 0
|
||||
t.mock.method(globalThis, 'setTimeout', (callback, delay) => {
|
||||
const id = ++nextId
|
||||
timers.set(id, { callback, at: now + Number(delay) })
|
||||
return id
|
||||
})
|
||||
t.mock.method(globalThis, 'clearTimeout', id => timers.delete(id))
|
||||
t.mock.method(Date, 'now', () => now)
|
||||
function tick(milliseconds) {
|
||||
const end = now + milliseconds
|
||||
let steps = 0
|
||||
while (true) {
|
||||
const entry = [...timers].sort((a, b) => a[1].at - b[1].at)[0]
|
||||
if (!entry || entry[1].at > end)
|
||||
break
|
||||
assert(++steps < 10000, 'Unexpected endless timer chain')
|
||||
const [id, timer] = entry
|
||||
timers.delete(id)
|
||||
now = timer.at
|
||||
timer.callback()
|
||||
}
|
||||
now = end
|
||||
}
|
||||
function host() {
|
||||
const calls = []
|
||||
const listeners = new Map()
|
||||
const proxies = []
|
||||
const nodes = new Map()
|
||||
let normalized
|
||||
class Node extends EventTarget {
|
||||
constructor(tag = 'div', className = '', html = '') {
|
||||
super()
|
||||
this.tagName = tag.toUpperCase()
|
||||
this.className = className
|
||||
this.innerHTML = html
|
||||
this.style = {}
|
||||
this.children = []
|
||||
this.muted = false
|
||||
this.paused = true
|
||||
this.ownerDocument = document
|
||||
if (className)
|
||||
nodes.set(className, this)
|
||||
}
|
||||
|
||||
play() {
|
||||
calls.push(['ad.play'])
|
||||
this.paused = false
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
pause() {
|
||||
calls.push(['ad.pause'])
|
||||
this.paused = true
|
||||
}
|
||||
}
|
||||
const prefix = 'artplayer-plugin-ads'
|
||||
function append(parent, value) {
|
||||
if (typeof value !== 'string') {
|
||||
parent.children.push(value)
|
||||
return value
|
||||
}
|
||||
const match = value.match(/^<(\w+)\s[^>]*class="([^"]*)"[^>]*>([\s\S]*)<\/\w+>$/)
|
||||
assert(match, 'Extend the fixture for a new wrapper; do not claim a real HTML parser')
|
||||
const [, tag, className, inner] = match
|
||||
const node = new Node(tag, className, inner)
|
||||
parent.children.push(node)
|
||||
for (const child of inner.matchAll(/<div class="([^"]+)"[^>]*>([\s\S]*?)<\/div>/g))
|
||||
node.children.push(new Node('div', child[1], child[2]))
|
||||
return node
|
||||
}
|
||||
const art = {
|
||||
template: { $player: new Node() },
|
||||
icons: Object.fromEntries(['volume', 'volumeClose', 'fullscreenOn', 'fullscreenOff', 'loading'].map(name => [name, new Node('span', name)])),
|
||||
constructor: {
|
||||
version: '5.4.1',
|
||||
validator(value, schema) {
|
||||
normalized = validator(value, schema)
|
||||
return normalized
|
||||
},
|
||||
utils: {
|
||||
append,
|
||||
query: (selector, parent) => parent.children.find(child => child.className === selector.slice(1)),
|
||||
setStyle(node, name, value) {
|
||||
calls.push(['style', node?.className, name, value])
|
||||
node.style[name] = value
|
||||
},
|
||||
errorHandle(condition, message) {
|
||||
if (!condition)
|
||||
throw new Error(message)
|
||||
},
|
||||
},
|
||||
},
|
||||
playing: false,
|
||||
fullscreen: false,
|
||||
isDestroy: false,
|
||||
on(name, callback) {
|
||||
if (!listeners.has(name))
|
||||
listeners.set(name, new Set())
|
||||
listeners.get(name).add(callback)
|
||||
return art
|
||||
},
|
||||
off(name, callback) {
|
||||
listeners.get(name)?.delete(callback)
|
||||
return art
|
||||
},
|
||||
once(name, callback) {
|
||||
const once = (...args) => {
|
||||
art.off(name, once)
|
||||
callback(...args)
|
||||
}
|
||||
return art.on(name, once)
|
||||
},
|
||||
emit(name, ...args) {
|
||||
calls.push(['emit', name, ...args])
|
||||
for (const callback of [...(listeners.get(name) || [])]) callback(...args)
|
||||
return art
|
||||
},
|
||||
proxy(target, name, callback) {
|
||||
target.addEventListener(name, callback)
|
||||
const off = () => target.removeEventListener(name, callback)
|
||||
proxies.push(off)
|
||||
return off
|
||||
},
|
||||
play() {
|
||||
calls.push(['content.play', art.isDestroy])
|
||||
art.playing = true
|
||||
art.emit('play')
|
||||
return Promise.resolve()
|
||||
},
|
||||
pause() {
|
||||
calls.push(['content.pause'])
|
||||
art.playing = false
|
||||
art.emit('pause')
|
||||
},
|
||||
destroy() {
|
||||
art.isDestroy = true
|
||||
art.emit('destroy')
|
||||
proxies.splice(0).forEach(off => off())
|
||||
listeners.clear()
|
||||
},
|
||||
}
|
||||
return {
|
||||
art,
|
||||
nodes,
|
||||
calls,
|
||||
listeners,
|
||||
get normalized() { return normalized },
|
||||
node: suffix => nodes.get(suffix ? `${prefix}-${suffix}` : prefix),
|
||||
start() { art.emit('ready').emit('play') },
|
||||
fire(suffix, name) { nodes.get(`${prefix}-${suffix}`).dispatchEvent(new Event(name)) },
|
||||
visibility(hidden) {
|
||||
document.hidden = hidden
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
art.emit('document:visibilitychange')
|
||||
},
|
||||
}
|
||||
}
|
||||
return { host, tick, timers, document, opened }
|
||||
}
|
||||
Reference in new issue
Block a user