import { expect, test } from './fixtures.js' async function openChapter(page, chapter, chapters = []) { await page.goto(`/test/player.html?core=published&chapter=${chapter}`) await page.evaluate((chapters) => { window.chapterInput = chapters window.createPlayer('/test/pattern.mp4', { chapters }) }, chapters) await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true) } function segments(page) { return page.locator('.art-chapter').evaluateAll(nodes => nodes.map(node => ({ start: node.dataset.start, end: node.dataset.end, title: node.dataset.title, width: node.style.width, }))) } async function hover(page, percentage) { const box = await page.locator('.art-control-progress-inner').boundingBox() const x = Math.round(box.x + box.width * percentage) await page.mouse.move(x, box.y + box.height / 2) return (x - box.x) / box.width } for (const chapter of ['published', 'candidate']) { test.describe(`${chapter} chapter on published core`, () => { test('factory, registration, input normalization and safe title text', async ({ page }) => { await openChapter(page, chapter, [{ start: 4, end: 6, title: ' Second ' }, { start: 1, end: 2, title: 'First' }]) expect(await page.evaluate(() => { const plugin = window.art.plugins.artplayerPluginChapter return { factory: typeof window.artplayerPluginChapter(), name: plugin.name, keys: Object.keys(plugin).sort(), input: window.chapterInput } })).toEqual({ factory: 'function', name: 'artplayerPluginChapter', keys: ['name', 'update'], input: [{ start: 0, end: 1, title: '' }, { start: 1, end: 2, title: 'First' }, { start: 2, end: 4, title: '' }, { start: 4, end: 6, title: ' Second ' }, { start: 6, end: 8, title: '' }], }) expect(await segments(page)).toEqual([ { start: '0', end: '1', title: '', width: '12.5%' }, { start: '1', end: '2', title: 'First', width: '12.5%' }, { start: '2', end: '4', title: '', width: '25%' }, { start: '4', end: '6', title: 'Second', width: '25%' }, { start: '6', end: '8', title: '', width: '25%' }, ]) await hover(page, 0.2) await expect(page.locator('.art-chapter-title')).toHaveText('First') await expect(page.locator('.art-chapter-title b')).toHaveCount(0) await expect(page.locator('style#artplayer-plugin-chapter')).toHaveCount(1) await page.evaluate(() => { window.art.destroy() window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, plugins: [window.artplayerPluginChapter()] }) }) await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true) await expect(page.locator('.art-chapter')).toHaveCount(0) }) test('progress bars, real hover, title edges and boundary seek', async ({ page }) => { await openChapter(page, chapter, [{ start: 0, end: 2, title: 'First chapter' }, { start: 2, end: 6, title: 'Middle' }, { start: 6, end: 8, title: 'Last chapter' }]) for (const type of ['played', 'loaded']) { const widths = await page.evaluate((type) => { window.art.emit('setBar', type, 0.375) return [...document.querySelectorAll(`.art-chapter .art-progress-${type}`)].map(node => node.style.width) }, type) expect(widths).toEqual(['100%', '25%', '0px']) } const percentage = await hover(page, 0.375) const widths = await page.locator('.art-chapter .art-progress-hover').evaluateAll(nodes => nodes.map(node => Number.parseFloat(node.style.width))) for (const [index, expected] of [100, (percentage * 8 - 2) / 4 * 100, 0].entries()) expect(widths[index]).toBeCloseTo(expected, 1) const box = await page.locator('.art-control-progress-inner').boundingBox() for (const [percentage, title] of [[0.01, 'First chapter'], [0.5, 'Middle'], [0.99, 'Last chapter']]) { await page.mouse.move(box.x + box.width * percentage, box.y + box.height / 2) await expect(page.locator('.art-chapter-title')).toHaveText(title) await expect(page.locator('.art-video-player')).toHaveClass(/art-progress-hover/) const layout = await page.locator('.art-chapter-title').evaluate(title => ({ left: Number.parseFloat(title.style.left), width: title.clientWidth, container: title.parentElement.clientWidth })) expect(layout.left).toBeGreaterThanOrEqual(0) expect(layout.left + layout.width).toBeLessThanOrEqual(layout.container) } await page.mouse.click(box.x + box.width / 4, box.y + box.height / 2) await expect.poll(() => page.locator('video').evaluate(video => video.currentTime)).toBeCloseTo(2, 1) expect(await page.evaluate(() => { window.art.emit('setBar', 'played', 1) return [...document.querySelectorAll('.art-chapter .art-progress-played')].map(node => node.style.width) })).toEqual(['100%', '100%', '100%']) }) test('sync update, Infinity, object identity, empty lists and invalid data', async ({ page }) => { await openChapter(page, chapter) await expect(page.locator('.art-chapter')).toHaveCount(0) expect(await page.evaluate(() => { const entry = { start: 0, end: Infinity, title: 'All' } const chapters = [entry] const result = window.art.plugins.artplayerPluginChapter.update({ chapters }) return { result, same: chapters[0] === entry, end: entry.end } })).toEqual({ result: undefined, same: true, end: 8 }) await expect(page.locator('.art-chapter')).toHaveCount(1) await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({})) await expect(page.locator('.art-chapter')).toHaveCount(0) await expect(page.locator('.art-video-player')).not.toHaveClass(/artplayer-plugin-chapter/) const cases = [ [[{ start: '0', end: 2, title: 'Bad' }], 'TypeError', 'Illegal chapter data type'], [[{ start: 0, end: 2, title: 12 }], 'TypeError', 'Illegal chapter data type'], [[{ start: -1, end: 2, title: 'Bad' }], 'Error', 'Illegal chapter time point'], [[{ start: 2, end: 2, title: 'Bad' }], 'Error', 'Illegal chapter time point'], [[{ start: 0, end: 9, title: 'Bad' }], 'Error', 'Illegal chapter time point'], [[{ start: 0, end: 4, title: 'A' }, { start: 3, end: 6, title: 'B' }], 'Error', 'Illegal chapter time point'], ] for (const [chapters, name, message] of cases) { expect(await page.evaluate((chapters) => { const update = window.art.plugins.artplayerPluginChapter.update update({ chapters: [{ start: 0, end: Infinity, title: 'Valid before failure' }] }) try { update({ chapters }) } catch (error) { return { name: error.name, message: error.message } } }, chapters)).toEqual({ name, message }) await expect(page.locator('.art-chapter')).toHaveCount(0) } expect(await page.evaluate(() => { try { window.art.plugins.artplayerPluginChapter.update() } catch (error) { return error.name } })).toBe('TypeError') }) test('explicit source update and core-owned destruction', async ({ page }) => { await openChapter(page, chapter, [{ start: 0, end: 8, title: 'Initial' }]) await page.evaluate(() => window.art.switchUrl('/assets/sample/video.mp4')) expect(await page.locator('video').evaluate(video => video.duration)).toBeGreaterThan(90) await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: 0, end: Infinity, title: 'New source' }] })) expect(await page.locator('.art-chapter').evaluate(node => Number(node.dataset.end))).toBeGreaterThan(90) await page.evaluate(() => window.art.destroy()) await expect(page.locator('.player')).toBeEmpty() expect(await page.evaluate(() => window.Artplayer.instances.length)).toBe(0) await expect(page.locator('style#artplayer-plugin-chapter')).toHaveCount(1) }) }) } test('published-only observations: blank title, NaN and once-only metadata are historical defects', async ({ page }, testInfo) => { await openChapter(page, 'published', [{ start: 0, end: 2, title: 'Previous' }, { start: 4, end: 8, title: 'Next' }]) await hover(page, 0.1) await hover(page, 0.375) const staleTitle = await page.locator('.art-chapter-title').textContent() expect(staleTitle).toBe('Previous') await page.evaluate(() => window.art.switchUrl('/assets/sample/video.mp4')) expect(await page.locator('.art-chapter').last().evaluate(node => node.dataset.end)).toBe('8') await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: Number.NaN, end: 3, title: 'Invalid' }] })) expect(await page.locator('.art-chapter').first().evaluate(node => node.dataset.start)).toBe('NaN') await testInfo.attach('chapter-known-defects', { body: JSON.stringify({ staleTitle, endAfterSourceChange: 8, acceptsNaN: true }), contentType: 'application/json' }) }) test('candidate clears titles on blank chapters, zero hover and replacement', async ({ page }) => { await openChapter(page, 'candidate', [{ start: 0, end: 2, title: 'Previous' }, { start: 4, end: 8, title: 'Next' }]) const title = page.locator('.art-chapter-title') await hover(page, 0.1) await expect(title).toHaveText('Previous') await hover(page, 0.375) await expect(title).toHaveText('') await expect(title).toHaveCSS('visibility', 'hidden') await hover(page, 0.75) await expect(title).toHaveText('Next') await hover(page, 0) await expect(title).toHaveText('') await hover(page, 0.1) await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: 0, end: Infinity, title: 'Replacement' }] })) await expect(title).toHaveText('') await hover(page, 0.5) await expect(title).toHaveText('Replacement') await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({})) await expect(title).toHaveText('') }) test('candidate rejects NaN and can recover after an invalid update', async ({ page }) => { await openChapter(page, 'candidate', [{ start: 0, end: 8, title: 'Valid' }]) expect(await page.evaluate(() => { try { window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: Number.NaN, end: 3, title: 'Invalid' }] }) } catch (error) { return error.message } })).toBe('Illegal chapter time point') await expect(page.locator('.art-chapter')).toHaveCount(0) await expect(page.locator('.art-video-player')).not.toHaveClass(/artplayer-plugin-chapter/) await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: 0, end: Infinity, title: 'Recovered' }] })) await expect(page.locator('.art-chapter')).toHaveCount(1) await hover(page, 0.5) await expect(page.locator('.art-chapter-title')).toHaveText('Recovered') }) test('candidate releases only its listeners and DOM across multiple instances and early destruction', async ({ page }) => { await openChapter(page, 'candidate', [{ start: 0, end: 8, title: 'First' }]) expect(await page.evaluate(() => { const art = window.art const plugin = art.plugins.artplayerPluginChapter let observerCalls = 0 const observer = () => observerCalls++ art.on('setBar', observer) const listenersBefore = art.e.setBar.length const container = document.createElement('div') container.className = 'second-player' document.body.appendChild(container) const other = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, plugins: [window.artplayerPluginChapter()] }) other.plugins.artplayerPluginChapter.update({ chapters: [] }) art.destroy(false) const entry = { start: 0, end: Infinity, title: 'Ignored' } plugin.update({ chapters: [entry] }) const listenersAfter = art.e.setBar.length art.emit('setBar', 'loaded', 0.5) const result = { ownDom: art.template.$player.querySelectorAll('.art-chapters, .art-chapter-title').length, classRemoved: !art.template.$player.classList.contains('artplayer-plugin-chapter'), listenersRemoved: listenersBefore - listenersAfter, observerCalls, inputUntouched: entry.end === Infinity, otherDom: container.querySelectorAll('.art-chapters, .art-chapter-title').length, otherAlive: !other.isDestroy, } other.destroy() return result })).toEqual({ ownDom: 0, classRemoved: true, listenersRemoved: 1, observerCalls: 1, inputUntouched: true, otherDom: 2, otherAlive: true }) expect(await page.evaluate(() => window.Artplayer.instances.length)).toBe(0) await expect(page.locator('style#artplayer-plugin-chapter')).toHaveCount(1) }) test('candidate ignores pending metadata initialization after immediate destroy', async ({ page }) => { await page.goto('/test/player.html?core=published&chapter=candidate') expect(await page.evaluate(() => { const original = window.artplayerPluginChapter let initialize let plugin const chapters = [{ start: 0, end: Infinity, title: 'Pending' }] const art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, plugins: [(art) => { const count = art.e['video:loadedmetadata']?.length || 0 plugin = original({ chapters })(art) initialize = art.e['video:loadedmetadata'][count].fn._ return plugin }] }) const before = art.e['video:loadedmetadata'].length art.destroy() const after = art.e['video:loadedmetadata']?.length || 0 initialize() plugin.update({ chapters }) return { removed: before - after, untouched: chapters[0].end === Infinity, children: document.querySelector('.player').childElementCount } })).toEqual({ removed: 1, untouched: true, children: 0 }) }) test('candidate deduplicates stylesheet injection while the document is loading', async ({ page, baseURL }) => { await page.setContent(``) await expect(page.locator('style#artplayer-plugin-chapter')).toHaveCount(1) expect(await page.locator('style#artplayer-plugin-chapter').textContent()).toContain('.art-chapters') })