Files
ArtPlayer/test/ads-lifecycle.test.js
T

343 lines
12 KiB
JavaScript

import assert from 'node:assert/strict'
import { getEventListeners } from 'node:events'
// eslint-disable-next-line test/no-import-node-test -- Controlled lifecycle contracts use Node's runner.
import test from 'node:test'
import { setImmediate as settle } from 'node:timers/promises'
import { adsEnvironment, adsImplementations } from './helpers/ads.js'
const implementations = (await adsImplementations()).filter(item => !item.historical)
const skips = host => host.calls.filter(call => call[0] === 'emit' && call[1] === 'artplayerPluginAds:skip')
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()
const plugin = factory({ html: 'ad', totalDuration: 3 })(host.art)
host.start()
plugin.play()
plugin.play()
assert.equal(env.timers.size, 1)
env.tick(1000)
assert.equal(host.node('countdown').innerHTML, '2秒')
plugin.pause()
assert.equal(env.timers.size, 0)
})
test(`${name}: skip clears timers and becomes inert under repeated methods/events`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const plugin = factory({ html: 'ad', totalDuration: 1 })(host.art)
host.start()
plugin.skip()
plugin.skip()
plugin.play()
host.visibility(false)
env.tick(5000)
assert.equal(env.timers.size, 0)
assert.equal(skips(host).length, 1)
assert.equal(contentPlays(host).length, 1)
})
test(`${name}: destroy cancels countdown and future public calls never resume content`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const plugin = factory({ html: 'ad', totalDuration: 1 })(host.art)
host.start()
host.art.destroy()
plugin.play()
plugin.skip()
env.tick(5000)
assert.equal(env.timers.size, 0)
assert.equal(contentPlays(host).length, 0)
assert.equal(skips(host).length, 0)
})
test(`${name}: one factory retains per-instance normalized event payloads`, (t) => {
const env = adsEnvironment(t)
const first = env.host()
const second = env.host()
const attach = factory({ html: 'shared' })
const one = attach(first.art)
first.start()
attach(second.art)
second.start()
one.skip()
assert.equal(skips(first)[0][2], first.normalized)
assert.notEqual(skips(first)[0][2], second.normalized)
})
test(`${name}: zero threshold close works before the first timer tick`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ html: 'ad', playDuration: 0 })(host.art)
host.start()
host.fire('close', 'click')
assert.equal(skips(host).length, 1)
assert.equal(env.timers.size, 0)
})
test(`${name}: play before initialization is inert and skip cancels pending preroll`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const plugin = factory({ html: 'ad' })(host.art)
plugin.play()
assert.equal(env.timers.size, 0)
plugin.skip()
host.start()
assert.equal(host.node(), undefined)
assert.equal(skips(host).length, 1)
assert.equal(contentPlays(host).length, 0)
})
test(`${name}: duplicate metadata starts the ad once`, (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, 1)
assert.equal(host.calls.filter(call => call[0] === 'ad.play').length, 1)
})
test(`${name}: core version numbers do not reject hosts providing the required capabilities`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
host.art.constructor.version = '4.5.5'
factory({ html: 'ad', totalDuration: 2 })(host.art)
host.start()
env.tick(1000)
host.visibility(true)
env.tick(5000)
assert.equal(host.node('countdown').innerHTML, '1秒')
host.visibility(false)
env.tick(1000)
assert.equal(skips(host).length, 1)
})
test(`${name}: synchronous content-play failure still hides ad and completes exactly once`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const warning = []
t.mock.method(console, 'warn', (...args) => warning.push(args))
const plugin = factory({ html: 'ad' })(host.art)
host.start()
const error = new Error('Content play failed')
host.art.play = () => {
throw error
}
assert.doesNotThrow(() => plugin.skip())
assert.equal(host.node().style.display, 'none')
assert.equal(skips(host).length, 1)
assert(warning.some(args => args.includes(error)))
})
test(`${name}: destroy during content restoration suppresses subsequent skip dispatch`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const plugin = factory({ html: 'ad' })(host.art)
host.start()
host.art.on('play', () => host.art.destroy())
plugin.skip()
assert.equal(skips(host).length, 0)
assert.equal(env.timers.size, 0)
})
test(`${name}: metadata emitted by the src setter is observed after listeners are installed`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const append = host.art.constructor.utils.append
host.art.constructor.utils.append = (...args) => {
const node = append(...args)
if (node.tagName === 'VIDEO') {
let source
Object.defineProperty(node, 'src', {
configurable: true,
get: () => source,
set(value) {
source = value
node.dispatchEvent(new Event('loadedmetadata'))
},
})
}
return node
}
factory({ video: 'cached.mp4' })(host.art)
host.start()
assert.equal(env.timers.size, 1)
assert.equal(host.calls.filter(call => call[0] === 'ad.play').length, 1)
})
test(`${name}: rejected ad play restores content once and observes the original error`, async (t) => {
const env = adsEnvironment(t)
const host = env.host()
const warnings = []
t.mock.method(console, 'warn', (...args) => warnings.push(args))
factory({ video: 'ad.mp4' })(host.art)
host.start()
const error = new Error('Ad play rejected')
host.node('video').play = () => Promise.reject(error)
host.fire('video', 'loadedmetadata')
await settle()
assert.equal(skips(host).length, 1)
assert.equal(contentPlays(host).length, 1)
assert.equal(env.timers.size, 0)
assert(warnings.some(args => args.includes(error)))
})
test(`${name}: late ad play fulfillment after destroy pauses owned media without restoring content`, async (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ video: 'ad.mp4' })(host.art)
host.start()
let resolve
const video = host.node('video')
video.play = () => new Promise((done) => {
resolve = done
})
host.fire('video', 'loadedmetadata')
host.art.destroy()
const pauses = host.calls.filter(call => call[0] === 'ad.pause').length
video.paused = false
resolve()
await settle()
assert.equal(video.paused, true)
assert.equal(host.calls.filter(call => call[0] === 'ad.pause').length, pauses + 1)
assert.equal(contentPlays(host).length, 0)
assert.equal(video.src, undefined)
assert.equal(host.art.template.$ads, undefined)
})
test(`${name}: destroy releases own DOM/document listeners and leaves foreign listeners intact`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ video: 'ad.mp4' })(host.art)
host.start()
const video = host.node('video')
const foreign = () => {}
video.addEventListener('loadedmetadata', foreign)
assert.equal(getEventListeners(video, 'loadedmetadata').length, 2)
assert.equal(getEventListeners(env.document, 'visibilitychange').length, 1)
host.art.destroy()
assert.deepEqual(getEventListeners(video, 'loadedmetadata'), [foreign])
assert.equal(getEventListeners(video, 'error').length, 0)
assert.equal(getEventListeners(env.document, 'visibilitychange').length, 0)
assert.equal(host.node().parentNode, null)
assert.equal(host.art.template.$ads, undefined)
host.fire('video', 'loadedmetadata')
assert.equal(env.timers.size, 0)
})
test(`${name}: partial view failure removes owned root and subscriptions before propagating`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const append = host.art.constructor.utils.append
const error = new Error('View failed')
host.art.constructor.utils.append = (...args) => {
if (host.node('loading'))
throw error
return append(...args)
}
factory({ html: 'ad' })(host.art)
assert.throws(() => host.start(), candidate => candidate === error)
assert.equal(host.node().parentNode, null)
assert.equal(host.art.template.$ads, undefined)
assert.equal([...host.listeners.values()].flatMap(set => [...set]).length, 0)
assert.equal(env.timers.size, 0)
})
test(`${name}: synchronous destroy during root creation leaves no newly appended overlay`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const append = host.art.constructor.utils.append
host.art.constructor.utils.append = (...args) => {
const node = append(...args)
if (node.className === 'artplayer-plugin-ads')
host.art.destroy()
return node
}
factory({ html: 'ad' })(host.art)
host.start()
assert.equal(host.node().parentNode, null)
assert.equal(host.art.template.$ads, undefined)
assert.equal(env.timers.size, 0)
assert.equal(host.calls.filter(call => call[0] === 'content.pause').length, 0)
})
test(`${name}: fullscreen icons follow initial and externally changed state`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
host.art.fullscreen = true
factory({ html: 'ad' })(host.art)
host.start()
assert.equal(host.nodes.get('fullscreenOn').style.display, 'none')
assert.equal(host.nodes.get('fullscreenOff').style.display, 'inline-flex')
host.art.fullscreen = false
host.art.emit('fullscreen', false)
assert.equal(host.nodes.get('fullscreenOn').style.display, 'inline-flex')
assert.equal(host.nodes.get('fullscreenOff').style.display, 'none')
})
test(`${name}: destroy during ad pause prevents late UI writes and skip notification`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
const plugin = factory({ video: 'ad.mp4' })(host.art)
host.start()
const video = host.node('video')
let once = false
video.pause = () => {
if (!once) {
once = true
host.art.destroy()
}
}
plugin.skip()
assert.equal(skips(host).length, 0)
assert.equal(host.node().style.display, undefined)
})
test(`${name}: destroy preserves a caller replacement of template.$ads`, (t) => {
const env = adsEnvironment(t)
const host = env.host()
factory({ html: 'ad' })(host.art)
host.start()
const foreign = {}
host.art.template.$ads = foreign
host.art.destroy()
assert.equal(host.art.template.$ads, foreign)
assert.equal(host.node().parentNode, null)
})
test(`${name}: rejected play after destroy is observed without restoring or reporting cancellation`, async (t) => {
const env = adsEnvironment(t)
const host = env.host()
const warnings = []
t.mock.method(console, 'warn', (...args) => warnings.push(args))
factory({ video: 'ad.mp4' })(host.art)
host.start()
let reject
host.node('video').play = () => new Promise((_, fail) => {
reject = fail
})
host.fire('video', 'loadedmetadata')
host.art.destroy()
reject(new Error('Cancelled by destruction'))
await settle()
assert.equal(warnings.length, 0)
assert.equal(contentPlays(host).length, 0)
assert.equal(skips(host).length, 0)
})
}