import assert from 'node:assert/strict' // eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner. import { mock, test } from 'node:test' import { setImmediate } from 'node:timers/promises' import { loadModules } from './helpers/load.js' const { Emitter, urlMix, switchMix, playMix, pauseMix, beginLifecycle, getScope, positionRestoration, currentTimeMix } = await loadModules({ Emitter: 'packages/artplayer/src/utils/emitter', urlMix: 'packages/artplayer/src/player/urlMix', switchMix: 'packages/artplayer/src/player/switchMix', playMix: 'packages/artplayer/src/player/playMix', pauseMix: 'packages/artplayer/src/player/pauseMix', beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' }, getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' }, positionRestoration: { file: 'packages/artplayer/src/source/restore-position', name: 'positionRestoration' }, currentTimeMix: 'packages/artplayer/src/player/currentTimeMix', }) function createArt(customType) { const art = Object.assign(new Emitter(), { template: { $video: { src: 'old.mp4' } }, option: { url: 'old.mp4', type: customType ? 'custom' : '', customType: { custom: customType } }, isReady: true, playing: false, currentTime: 37, playbackRate: 1.5, aspectRatio: '16:9', loading: { show: false }, notice: { show: '' }, i18n: { get: key => key }, pause: mock.fn(), play: mock.fn(() => Promise.resolve()), }) beginLifecycle(art) art.template.$video.pause = () => { art.playing = false } pauseMix(art) art.pause = mock.fn(art.pause) urlMix(art) switchMix(art) return art } function createPlayingArt() { const art = createArt() const video = art.template.$video video.paused = false Object.defineProperty(art, 'playing', { get: () => !video.paused }) video.pause = () => { video.paused = true } video.play = async () => { video.paused = false } art.constructor = { instances: [] } playMix(art) art.play = mock.fn(art.play) return art } test('rapid switches inherit playback intent despite their own native pauses', async () => { const art = createPlayingArt() const first = art.switchUrl('first.mp4') assert.equal(art.playing, false) const second = art.switchUrl('second.mp4') const third = art.switchUrl('last.mp4') await Promise.all([first, second]) assert.equal(art.play.mock.callCount(), 0) art.emit('video:canplay') await third assert.equal(art.url, 'last.mp4') assert.equal(art.playing, true) assert.equal(art.play.mock.callCount(), 1) assert.deepEqual(Object.keys(art.e), []) }) test('explicit pause during a switch cancels inherited playback for subsequent switches', async () => { const art = createPlayingArt() const first = art.switchUrl('first.mp4') art.pause() const second = art.switchUrl('last.mp4') art.emit('video:canplay') await Promise.all([first, second]) assert.equal(art.play.mock.callCount(), 0) assert.equal(art.playing, false) assert.deepEqual(Object.keys(art.e), []) }) test('explicit pause before readiness prevents the current switch from resuming', async () => { const art = createPlayingArt() const pending = art.switchUrl('pending.mp4') art.pause() art.emit('video:canplay') await pending assert.equal(art.playing, false) assert.equal(art.play.mock.callCount(), 0) }) test('a reentrant explicit pause is not swallowed by the internal switch pause', async () => { const art = createPlayingArt() art.once('pause', () => art.pause()) const pending = art.switchUrl('pending.mp4') art.emit('video:canplay') await pending assert.equal(art.playing, false) assert.equal(art.play.mock.callCount(), 0) assert.deepEqual(Object.keys(art.e), []) }) test('a user pause updates intent before an existing pause listener starts another switch', async () => { const art = createPlayingArt() const first = art.switchUrl('first.mp4') let second art.once('pause', () => { second = art.switchUrl('last.mp4') }) art.pause() art.emit('video:canplay') await Promise.all([first, second]) assert.equal(art.url, 'last.mp4') assert.equal(art.playing, false) assert.equal(art.play.mock.callCount(), 0) assert.deepEqual(Object.keys(art.e), []) }) test('a pending explicit play command survives replacement even before native play fulfills', async () => { const art = createPlayingArt() const first = art.switchUrl('first.mp4') art.pause() let finish let calls = 0 art.template.$video.play = () => { if (++calls === 1) return new Promise(resolve => finish = resolve) art.template.$video.paused = false return Promise.resolve() } const playing = art.play() const second = art.switchUrl('last.mp4') await first art.emit('video:canplay') await second finish(42) assert.equal(await playing, 42) assert.equal(calls, 2) assert.equal(art.playing, true) }) test('a late native resume cannot publish play or clear notice after an explicit pause', async () => { const art = createPlayingArt() let finish const events = [] art.on('play', () => events.push('play')) art.template.$video.play = () => new Promise(resolve => finish = resolve) const pending = art.switchUrl('pending.mp4') art.emit('video:canplay') art.pause() finish(42) await pending assert.deepEqual(events, []) assert.equal(art.notice.show, 'Pause') assert.equal(art.playing, false) }) test('cancelled, failed and completed switches never leak playback intent to a new source', async () => { for (const boundary of ['direct-url', 'failure', 'completed', 'destroy']) { const art = createPlayingArt() const pending = art.switchUrl('first.mp4') if (boundary === 'failure') { const error = new Error('Controlled media failure') const rejected = assert.rejects(pending, actual => actual === error) art.emit('video:error', error) await rejected } else { if (boundary === 'direct-url') art.url = 'direct.mp4' if (boundary === 'completed') art.emit('video:canplay') if (boundary === 'destroy') getScope(art).dispose() await pending } art.pause() const played = art.play.mock.callCount() const next = art.switchUrl('next.mp4') art.emit('video:canplay') await next assert.equal(art.play.mock.callCount(), played, boundary) assert.equal(art.playing, false, boundary) assert.deepEqual(Object.keys(art.e), []) } }) test('source assignment does not revoke a caller-owned Blob URL still used elsewhere', async (t) => { const art = createArt() const blob = new Blob(['shared media'], { type: 'video/mp4' }) const url = URL.createObjectURL(blob) t.after(() => URL.revokeObjectURL(url)) art.url = url art.url = 'next.mp4' assert.equal(await (await fetch(url)).text(), 'shared media') getScope(art).dispose() assert.equal(await (await fetch(url)).text(), 'shared media') }) test('quality restoration waits for a pending native seek before restoring playback rate', async () => { const art = createArt() let settled = false const switching = art.switchQuality('next.mp4').then(() => { settled = true }) art.playbackRate = 1 art.template.$video.seeking = true art.emit('video:loadedmetadata') art.emit('video:canplay') await Promise.resolve() assert.equal(settled, false) assert.equal(art.playbackRate, 1) assert.equal(art.currentTime, 37) art.template.$video.seeking = false art.emit('video:seeked') await switching assert.equal(settled, true) assert.equal(art.playbackRate, 1.5) assert.deepEqual(Object.keys(art.e), []) }) test('a switch waiting for seeked settles on cancellation and ignores the late seek', async () => { const art = createArt() const switching = art.switchQuality('next.mp4') art.playbackRate = 1 art.template.$video.seeking = true art.emit('video:canplay') getScope(art).dispose() await switching art.template.$video.seeking = false art.emit('video:seeked') assert.equal(art.playbackRate, 1) assert.deepEqual(Object.keys(art.e), []) }) test('a missed native quality seek is corrected once and settles after the second seeked', async () => { const art = createArt() let time = 37 const writes = [] Object.defineProperty(art, 'currentTime', { get: () => time, set(value) { writes.push(value) time = value art.template.$video.seeking = true }, }) let settled = false const switching = art.switchQuality('next.mp4').then(() => { settled = true }) art.emit('video:loadedmetadata') art.emit('video:canplay') time = 0 art.template.$video.seeking = false art.emit('video:seeked') await Promise.resolve() assert.equal(settled, false) assert.deepEqual(writes, [37, 37]) assert.equal(art.playbackRate, 1.5) // Even a second native miss must not start an unbounded correction loop. time = 0 art.template.$video.seeking = false art.emit('video:seeked') await switching assert.deepEqual(writes, [37, 37]) assert.deepEqual(Object.keys(art.e), []) }) test('public seek during quality loading wins over metadata restoration and native correction', async () => { for (const beforeMetadata of [false, true]) { const art = createArt() const switching = art.switchQuality('next.mp4') art.template.$video.seeking = true if (!beforeMetadata) art.emit('video:loadedmetadata') art.currentTime = 6 art.emit('seek', 6) if (beforeMetadata) art.emit('video:loadedmetadata') art.template.$video.seeking = false art.emit('video:canplay') await switching assert.equal(art.currentTime, 6) assert.deepEqual(Object.keys(art.e), []) } }) test('direct public currentTime writes during quality loading supersede automatic restoration', async () => { for (const beforeMetadata of [false, true]) { const art = createArt() art.duration = 100 art.template.$video.currentTime = 37 currentTimeMix(art) const switching = art.switchQuality('next.mp4') art.template.$video.seeking = true if (!beforeMetadata) art.emit('video:loadedmetadata') art.currentTime = 6 if (beforeMetadata) art.emit('video:loadedmetadata') art.template.$video.seeking = false art.emit('video:canplay') await switching assert.equal(art.currentTime, 6) assert.deepEqual(Object.keys(art.e), []) } }) test('position restoration preserves clamping and native frame rounding', () => { let time = 0 const video = { seeking: false } const writes = [] const art = { template: { $video: video }, get currentTime() { return time }, set currentTime(value) { writes.push(value) time = Math.min(10, value) video.seeking = true }, } const restoration = positionRestoration(art, 37, () => true) restoration.restore() time = 9.99 video.seeking = false assert.equal(restoration.ready(), true) assert.deepEqual(writes, [37]) time = 0 assert.equal(restoration.ready(), false) assert.deepEqual(writes, [37, 10]) }) test('a reentrant position read cannot correct a cancelled or manually superseded seek', () => { for (const boundary of ['cancel', 'manual']) { let active = true let reenter = false let time = 0 let restoration const writes = [] const art = { template: { $video: { seeking: true } }, get currentTime() { if (reenter) { if (boundary === 'cancel') active = false else restoration.manual() } return time }, set currentTime(value) { writes.push(value) time = value }, } restoration = positionRestoration(art, 37, () => active) restoration.restore() art.template.$video.seeking = false time = 6 reenter = true assert.equal(restoration.ready(), boundary === 'manual') assert.deepEqual(writes, [37]) } }) test('synchronous correction seeked reentry resumes a source only once', async () => { const art = createArt() art.playing = true let time = 37 let correcting = false Object.defineProperty(art, 'currentTime', { get: () => time, set(value) { time = value art.template.$video.seeking = !correcting if (correcting) art.emit('video:seeked') }, }) const switching = art.switchQuality('next.mp4') art.emit('video:loadedmetadata') art.emit('video:canplay') time = 0 correcting = true art.template.$video.seeking = false art.emit('video:seeked') await switching assert.equal(time, 37) assert.equal(art.play.mock.callCount(), 1) assert.deepEqual(Object.keys(art.e), []) }) test('superseded switches settle before readiness and only the latest restores state/restarts', async () => { const art = createArt() const restarts = [] art.on('restart', url => restarts.push(url)) const first = art.switchQuality('first.mp4') art.currentTime = 9 const second = art.switchQuality('second.mp4') assert.equal(await first, undefined) art.currentTime = 0 art.emit('video:loadedmetadata') assert.equal(art.currentTime, 9) art.emit('video:canplay') assert.equal(await second, undefined) assert.deepEqual(restarts, ['second.mp4']) assert.deepEqual(Object.keys(art.e), ['restart']) }) test('direct URL assignment cancels an outstanding switch and stale restart listeners', async () => { const art = createArt() const restarts = [] art.on('restart', url => restarts.push(url)) const switching = art.switchQuality('switch.mp4') art.url = 'direct.mp4' await switching art.currentTime = 4 art.emit('video:loadedmetadata') art.emit('video:canplay') assert.equal(art.currentTime, 4) assert.deepEqual(restarts, ['direct.mp4']) }) test('scope disposal settles a pending switch and prevents post-destroy work', async () => { const art = createArt() const switching = art.switchUrl('pending.mp4') getScope(art).dispose() assert.equal(await switching, undefined) assert.deepEqual(Object.keys(art.e), []) art.url = 'closed.mp4' await art.switchUrl('also-closed.mp4') assert.equal(art.url, 'pending.mp4') assert.equal(art.pause.mock.callCount(), 1) }) test('same URL remains a no-op and switch setter retains the public function identity', async () => { const art = createArt() art.notice.show = 'keep' assert.equal(await art.switchUrl('old.mp4'), undefined) assert.equal(art.pause.mock.callCount(), 0) assert.equal(art.notice.show, 'keep') assert.equal(Object.getOwnPropertyDescriptor(art, 'switch').set, art.switchUrl) for (const name of ['url', 'switch', 'switchUrl', 'switchQuality']) { const descriptor = Object.getOwnPropertyDescriptor(art, name) assert.equal(descriptor.enumerable, false) assert.equal(descriptor.configurable, false) } }) test('a reentrant pause cannot make its superseded switch overwrite the latest URL', async () => { const art = createArt() art.pause = () => { art.url = 'reentrant.mp4' } await art.switchUrl('stale.mp4') assert.equal(art.url, 'reentrant.mp4') }) test('synchronous media setters cannot lose metadata, readiness or original errors', async () => { for (const fails of [false, true]) { const art = createArt() const error = new Error('synchronous source failure') let src = 'old.mp4' Object.defineProperty(art.template.$video, 'src', { get: () => src, set(value) { src = value if (fails) { art.emit('video:error', error) } else { art.currentTime = 0 art.emit('video:loadedmetadata') art.emit('video:canplay') } }, }) const switching = art.switchQuality('sync.mp4') if (fails) { await assert.rejects(switching, actual => actual === error) } else { await switching assert.equal(art.currentTime, 37) } assert.deepEqual(Object.keys(art.e), []) } }) test('late resume fulfillment or rejection cannot clear a newer operation notice', async () => { for (const rejects of [false, true]) { const art = createArt() art.playing = true let finish art.play = () => new Promise((resolve, reject) => { finish = rejects ? reject : resolve }) const first = art.switchUrl('first.mp4') art.emit('video:canplay') art.playing = false // Model the explicit pause command, not only a read-only playback-state snapshot. art.pause() const second = art.switchUrl('second.mp4') await first art.notice.show = 'new source notice' finish(new Error('late play')) await setImmediate() assert.equal(art.notice.show, 'new source notice') art.emit('video:canplay') await second assert.deepEqual(Object.keys(art.e), []) } }) test('state restoration errors reject without leaking handlers or unhandled promises', async () => { for (const property of ['currentTime', 'playbackRate', 'aspectRatio']) { const art = createArt() const error = new Error(property) const switching = art.switchQuality('new.mp4') const previous = art[property] Object.defineProperty(art, property, { get: () => previous, set() { throw error }, }) const rejected = assert.rejects(switching, actual => actual === error) art.emit(property === 'currentTime' ? 'video:loadedmetadata' : 'video:canplay') await rejected assert.deepEqual(Object.keys(art.e), []) } }) test('customType deferral ignores the old resource and preserves callback identities', async () => { let called const art = createArt(function (video, url, owner) { called = [this, video, url, owner, arguments.length] video.src = url owner.emit('video:loadedmetadata') owner.emit('video:canplay') }) const switching = art.switchQuality('custom.mp4') art.currentTime = 0 art.emit('video:canplay') assert.equal(called, undefined) await switching assert.deepEqual(called, [art, art.template.$video, 'custom.mp4', art, 3]) assert.equal(art.currentTime, 37) assert.equal(art.option.url, 'custom.mp4') }) test('customType failures reject switches with the original thrown/rejected value', async () => { for (const asynchronous of [false, true]) { const error = new Error('customType failed') const art = createArt(() => { if (asynchronous) return Promise.reject(error) throw error }) await assert.rejects(art.switchUrl('failed.mp4'), actual => actual === error) assert.deepEqual(Object.keys(art.e), []) } }) test('superseded customType callbacks never start and empty URL switches settle', async () => { const callback = mock.fn((video, url, art) => { video.src = url art.emit('video:canplay') }) const art = createArt(callback) const first = art.switchUrl('first.mp4') const second = art.switchUrl('second.mp4') await Promise.all([first, second]) assert.equal(callback.mock.callCount(), 1) assert.equal(callback.mock.calls[0].arguments[1], 'second.mp4') await art.switchUrl('') assert.equal(art.loading.show, true) assert.equal(art.url, 'second.mp4') }) test('reentrant media listeners and property setters cannot restore stale state', async () => { for (const boundary of ['metadata', 'rate']) { const art = createArt() if (boundary === 'metadata') { art.once('video:loadedmetadata', () => { art.url = 'reentrant.mp4' art.currentTime = 6 }) } const switching = art.switchQuality('stale.mp4') art.currentTime = 0 art.aspectRatio = 'default' if (boundary === 'rate') { Object.defineProperty(art, 'playbackRate', { get: () => 1, set() { art.url = 'reentrant.mp4' }, }) } art.emit(boundary === 'metadata' ? 'video:loadedmetadata' : 'video:canplay') await switching assert.equal(art.url, 'reentrant.mp4') assert.equal(art.aspectRatio, 'default') if (boundary === 'metadata') assert.equal(art.currentTime, 6) getScope(art).dispose() assert.deepEqual(Object.keys(art.e), []) } }) test('direct customType rejection is reported once; obsolete returned failures are ignored', async (t) => { const warning = t.mock.method(console, 'warn', () => {}) const error = new Error('direct custom source') let rejectLate let started const invoked = new Promise(resolve => started = resolve) const art = createArt(() => new Promise((resolve, reject) => { rejectLate = reject started() })) art.url = 'late.mp4' await invoked art.option.type = '' art.url = 'current.mp4' rejectLate(error) await setImmediate() assert.equal(warning.mock.callCount(), 0) art.option.type = 'custom' art.option.customType.custom = () => Promise.reject(error) await Object.getOwnPropertyDescriptor(art, 'url').set.call(art, 'failed.mp4') await setImmediate() assert.equal(warning.mock.callCount(), 1) assert.deepEqual(warning.mock.calls[0].arguments, ['Failed to initialize ArtPlayer source:', error]) assert.deepEqual(Object.keys(art.e), []) }) test('same-URL calls do not cancel the still-loading original operation', async () => { const art = createArt() let firstSettled = false const first = art.switchQuality('pending.mp4').then(() => firstSettled = true) await art.switchUrl('pending.mp4') assert.equal(firstSettled, false) art.currentTime = 0 art.emit('video:loadedmetadata') art.emit('video:canplay') await first assert.equal(art.currentTime, 37) assert.equal(art.pause.mock.callCount(), 1) }) test('synchronous readiness replay consumes metadata once and emits restart once', async () => { const art = createArt() const restarts = [] art.on('restart', url => restarts.push(url)) let src = 'old.mp4' Object.defineProperty(art.template.$video, 'src', { get: () => src, set(value) { src = value art.emit('video:loadedmetadata') art.emit('video:loadedmetadata') }, }) const switching = art.switchQuality('sync.mp4') assert.equal(art.currentTime, 37) art.currentTime = 4 art.emit('video:loadedmetadata') assert.equal(art.currentTime, 4) art.emit('video:canplay') art.emit('video:canplay') await switching assert.deepEqual(restarts, ['sync.mp4']) assert.deepEqual(Object.keys(art.e), ['restart']) }) test('the real play mixin cannot publish stale resume effects after source replacement', async () => { const art = createArt() art.i18n = { get: key => key } art.constructor = { instances: [] } let resolveNative art.template.$video.play = () => new Promise(resolve => resolveNative = resolve) playMix(art) art.playing = true const plays = [] art.on('play', () => plays.push('play')) const switching = art.switchUrl('pending-resume.mp4') art.emit('video:canplay') art.url = 'latest.mp4' await switching art.notice.show = 'latest notice' resolveNative(42) await setImmediate() assert.equal(art.notice.show, 'latest notice') assert.deepEqual(plays, []) }) test('public pending play preserves its result while obsolete notification/event/mutex work stops', async () => { for (const destroy of [false, true]) { const art = createArt() art.i18n = { get: key => key } const other = { pause: mock.fn() } art.constructor = { instances: [other] } art.option.mutex = true art.url = 'initial.mp4' let resolveNative art.template.$video.play = () => new Promise(resolve => resolveNative = resolve) playMix(art) const plays = mock.fn() art.on('play', plays) const playing = art.play() if (destroy) getScope(art).dispose() else art.url = 'replacement.mp4' art.notice.show = 'preserve' resolveNative(42) assert.equal(await playing, 42) assert.equal(art.notice.show, 'preserve') assert.equal(plays.mock.callCount(), 0) assert.equal(other.pause.mock.callCount(), 0) } })