refactor(core): CORE-19 type capture and preserve progress lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 14:24:59 +08:00
1 parent 98a1800032
commit ccf77c4ed2
49 files changed
+31570 -681

No files matched your search

+10 -6
View File
@@ -57,22 +57,26 @@ test('candidate: native keyboard play/pause, seek, volume and web-fullscreen Esc
await page.evaluate(() => {
window.art.isFocus = true
window.nativeHotkeys = []
window.nativeHotkeySeeks = []
window.art.on('hotkey', event => window.nativeHotkeys.push(event.code))
window.art.on('seek', (time, requested) => window.nativeHotkeySeeks.push(requested))
})
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0.1)).toBe(true)
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused && !window.art.template.$video.seeking)).toBe(true)
await page.evaluate(() => {
window.art.currentTime = 1
})
await expect.poll(() => page.evaluate(() => !window.art.template.$video.seeking && Math.abs(window.art.currentTime - 1) < 0.1)).toBe(true)
const start = await page.evaluate(() => ({ time: window.art.currentTime, step: window.Artplayer.SEEK_STEP }))
await page.keyboard.press('ArrowRight')
expect(await page.evaluate(() => window.nativeHotkeys.at(-1))).toBe('ArrowRight')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(5)
expect(await page.evaluate(() => window.nativeHotkeySeeks.at(-1))).toBeCloseTo(start.time + start.step, 1)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(start.time)
await expect.poll(() => page.evaluate(() => window.art.template.$video.seeking)).toBe(false)
const forward = await page.evaluate(() => window.art.currentTime)
await page.keyboard.press('ArrowLeft')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeLessThan(2)
expect(await page.evaluate(() => window.nativeHotkeySeeks.at(-1))).toBeCloseTo(forward - start.step, 1)
expect(await page.evaluate(() => window.nativeHotkeySeeks.length)).toBe(2)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeLessThan(forward)
await expect.poll(() => page.evaluate(() => window.art.template.$video.seeking)).toBe(false)
const volume = await page.evaluate(() => {
window.art.volume = 0.5
return window.art.volume
+202
View File
@@ -0,0 +1,202 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600">
<rect x="0" y="0" width="100" height="60" fill="hsl(0 70% 45%)"/>
<text x="35" y="38" fill="white" font-size="25">0</text>
<rect x="100" y="0" width="100" height="60" fill="hsl(137.508 70% 45%)"/>
<text x="135" y="38" fill="white" font-size="25">1</text>
<rect x="200" y="0" width="100" height="60" fill="hsl(275.016 70% 45%)"/>
<text x="235" y="38" fill="white" font-size="25">2</text>
<rect x="300" y="0" width="100" height="60" fill="hsl(52.524 70% 45%)"/>
<text x="335" y="38" fill="white" font-size="25">3</text>
<rect x="400" y="0" width="100" height="60" fill="hsl(190.03200000000004 70% 45%)"/>
<text x="435" y="38" fill="white" font-size="25">4</text>
<rect x="500" y="0" width="100" height="60" fill="hsl(327.5400000000001 70% 45%)"/>
<text x="535" y="38" fill="white" font-size="25">5</text>
<rect x="600" y="0" width="100" height="60" fill="hsl(105.048 70% 45%)"/>
<text x="635" y="38" fill="white" font-size="25">6</text>
<rect x="700" y="0" width="100" height="60" fill="hsl(242.55600000000004 70% 45%)"/>
<text x="735" y="38" fill="white" font-size="25">7</text>
<rect x="800" y="0" width="100" height="60" fill="hsl(20.064000000000078 70% 45%)"/>
<text x="835" y="38" fill="white" font-size="25">8</text>
<rect x="900" y="0" width="100" height="60" fill="hsl(157.57200000000012 70% 45%)"/>
<text x="935" y="38" fill="white" font-size="25">9</text>
<rect x="0" y="60" width="100" height="60" fill="hsl(295.08000000000015 70% 45%)"/>
<text x="35" y="98" fill="white" font-size="25">10</text>
<rect x="100" y="60" width="100" height="60" fill="hsl(72.58800000000019 70% 45%)"/>
<text x="135" y="98" fill="white" font-size="25">11</text>
<rect x="200" y="60" width="100" height="60" fill="hsl(210.096 70% 45%)"/>
<text x="235" y="98" fill="white" font-size="25">12</text>
<rect x="300" y="60" width="100" height="60" fill="hsl(347.60400000000004 70% 45%)"/>
<text x="335" y="98" fill="white" font-size="25">13</text>
<rect x="400" y="60" width="100" height="60" fill="hsl(125.11200000000008 70% 45%)"/>
<text x="435" y="98" fill="white" font-size="25">14</text>
<rect x="500" y="60" width="100" height="60" fill="hsl(262.62000000000035 70% 45%)"/>
<text x="535" y="98" fill="white" font-size="25">15</text>
<rect x="600" y="60" width="100" height="60" fill="hsl(40.128000000000156 70% 45%)"/>
<text x="635" y="98" fill="white" font-size="25">16</text>
<rect x="700" y="60" width="100" height="60" fill="hsl(177.63599999999997 70% 45%)"/>
<text x="735" y="98" fill="white" font-size="25">17</text>
<rect x="800" y="60" width="100" height="60" fill="hsl(315.14400000000023 70% 45%)"/>
<text x="835" y="98" fill="white" font-size="25">18</text>
<rect x="900" y="60" width="100" height="60" fill="hsl(92.65200000000004 70% 45%)"/>
<text x="935" y="98" fill="white" font-size="25">19</text>
<rect x="0" y="120" width="100" height="60" fill="hsl(230.1600000000003 70% 45%)"/>
<text x="35" y="158" fill="white" font-size="25">20</text>
<rect x="100" y="120" width="100" height="60" fill="hsl(7.66800000000012 70% 45%)"/>
<text x="135" y="158" fill="white" font-size="25">21</text>
<rect x="200" y="120" width="100" height="60" fill="hsl(145.17600000000039 70% 45%)"/>
<text x="235" y="158" fill="white" font-size="25">22</text>
<rect x="300" y="120" width="100" height="60" fill="hsl(282.6840000000002 70% 45%)"/>
<text x="335" y="158" fill="white" font-size="25">23</text>
<rect x="400" y="120" width="100" height="60" fill="hsl(60.19200000000001 70% 45%)"/>
<text x="435" y="158" fill="white" font-size="25">24</text>
<rect x="500" y="120" width="100" height="60" fill="hsl(197.70000000000027 70% 45%)"/>
<text x="535" y="158" fill="white" font-size="25">25</text>
<rect x="600" y="120" width="100" height="60" fill="hsl(335.2080000000001 70% 45%)"/>
<text x="635" y="158" fill="white" font-size="25">26</text>
<rect x="700" y="120" width="100" height="60" fill="hsl(112.71600000000035 70% 45%)"/>
<text x="735" y="158" fill="white" font-size="25">27</text>
<rect x="800" y="120" width="100" height="60" fill="hsl(250.22400000000016 70% 45%)"/>
<text x="835" y="158" fill="white" font-size="25">28</text>
<rect x="900" y="120" width="100" height="60" fill="hsl(27.732000000000426 70% 45%)"/>
<text x="935" y="158" fill="white" font-size="25">29</text>
<rect x="0" y="180" width="100" height="60" fill="hsl(165.2400000000007 70% 45%)"/>
<text x="35" y="218" fill="white" font-size="25">30</text>
<rect x="100" y="180" width="100" height="60" fill="hsl(302.7480000000005 70% 45%)"/>
<text x="135" y="218" fill="white" font-size="25">31</text>
<rect x="200" y="180" width="100" height="60" fill="hsl(80.25600000000031 70% 45%)"/>
<text x="235" y="218" fill="white" font-size="25">32</text>
<rect x="300" y="180" width="100" height="60" fill="hsl(217.76400000000012 70% 45%)"/>
<text x="335" y="218" fill="white" font-size="25">33</text>
<rect x="400" y="180" width="100" height="60" fill="hsl(355.27199999999993 70% 45%)"/>
<text x="435" y="218" fill="white" font-size="25">34</text>
<rect x="500" y="180" width="100" height="60" fill="hsl(132.78000000000065 70% 45%)"/>
<text x="535" y="218" fill="white" font-size="25">35</text>
<rect x="600" y="180" width="100" height="60" fill="hsl(270.28800000000047 70% 45%)"/>
<text x="635" y="218" fill="white" font-size="25">36</text>
<rect x="700" y="180" width="100" height="60" fill="hsl(47.79600000000028 70% 45%)"/>
<text x="735" y="218" fill="white" font-size="25">37</text>
<rect x="800" y="180" width="100" height="60" fill="hsl(185.3040000000001 70% 45%)"/>
<text x="835" y="218" fill="white" font-size="25">38</text>
<rect x="900" y="180" width="100" height="60" fill="hsl(322.8120000000008 70% 45%)"/>
<text x="935" y="218" fill="white" font-size="25">39</text>
<rect x="0" y="240" width="100" height="60" fill="hsl(100.32000000000062 70% 45%)"/>
<text x="35" y="278" fill="white" font-size="25">40</text>
<rect x="100" y="240" width="100" height="60" fill="hsl(237.82800000000043 70% 45%)"/>
<text x="135" y="278" fill="white" font-size="25">41</text>
<rect x="200" y="240" width="100" height="60" fill="hsl(15.33600000000024 70% 45%)"/>
<text x="235" y="278" fill="white" font-size="25">42</text>
<rect x="300" y="240" width="100" height="60" fill="hsl(152.84400000000005 70% 45%)"/>
<text x="335" y="278" fill="white" font-size="25">43</text>
<rect x="400" y="240" width="100" height="60" fill="hsl(290.35200000000077 70% 45%)"/>
<text x="435" y="278" fill="white" font-size="25">44</text>
<rect x="500" y="240" width="100" height="60" fill="hsl(67.86000000000058 70% 45%)"/>
<text x="535" y="278" fill="white" font-size="25">45</text>
<rect x="600" y="240" width="100" height="60" fill="hsl(205.3680000000004 70% 45%)"/>
<text x="635" y="278" fill="white" font-size="25">46</text>
<rect x="700" y="240" width="100" height="60" fill="hsl(342.8760000000002 70% 45%)"/>
<text x="735" y="278" fill="white" font-size="25">47</text>
<rect x="800" y="240" width="100" height="60" fill="hsl(120.38400000000001 70% 45%)"/>
<text x="835" y="278" fill="white" font-size="25">48</text>
<rect x="900" y="240" width="100" height="60" fill="hsl(257.89200000000073 70% 45%)"/>
<text x="935" y="278" fill="white" font-size="25">49</text>
<rect x="0" y="300" width="100" height="60" fill="hsl(35.400000000000546 70% 45%)"/>
<text x="35" y="338" fill="white" font-size="25">50</text>
<rect x="100" y="300" width="100" height="60" fill="hsl(172.90800000000036 70% 45%)"/>
<text x="135" y="338" fill="white" font-size="25">51</text>
<rect x="200" y="300" width="100" height="60" fill="hsl(310.41600000000017 70% 45%)"/>
<text x="235" y="338" fill="white" font-size="25">52</text>
<rect x="300" y="300" width="100" height="60" fill="hsl(87.92400000000089 70% 45%)"/>
<text x="335" y="338" fill="white" font-size="25">53</text>
<rect x="400" y="300" width="100" height="60" fill="hsl(225.4320000000007 70% 45%)"/>
<text x="435" y="338" fill="white" font-size="25">54</text>
<rect x="500" y="300" width="100" height="60" fill="hsl(2.9400000000005093 70% 45%)"/>
<text x="535" y="338" fill="white" font-size="25">55</text>
<rect x="600" y="300" width="100" height="60" fill="hsl(140.44800000000032 70% 45%)"/>
<text x="635" y="338" fill="white" font-size="25">56</text>
<rect x="700" y="300" width="100" height="60" fill="hsl(277.95600000000013 70% 45%)"/>
<text x="735" y="338" fill="white" font-size="25">57</text>
<rect x="800" y="300" width="100" height="60" fill="hsl(55.46400000000085 70% 45%)"/>
<text x="835" y="338" fill="white" font-size="25">58</text>
<rect x="900" y="300" width="100" height="60" fill="hsl(192.97200000000066 70% 45%)"/>
<text x="935" y="338" fill="white" font-size="25">59</text>
<rect x="0" y="360" width="100" height="60" fill="hsl(330.4800000000014 70% 45%)"/>
<text x="35" y="398" fill="white" font-size="25">60</text>
<rect x="100" y="360" width="100" height="60" fill="hsl(107.9880000000012 70% 45%)"/>
<text x="135" y="398" fill="white" font-size="25">61</text>
<rect x="200" y="360" width="100" height="60" fill="hsl(245.496000000001 70% 45%)"/>
<text x="235" y="398" fill="white" font-size="25">62</text>
<rect x="300" y="360" width="100" height="60" fill="hsl(23.004000000000815 70% 45%)"/>
<text x="335" y="398" fill="white" font-size="25">63</text>
<rect x="400" y="360" width="100" height="60" fill="hsl(160.51200000000063 70% 45%)"/>
<text x="435" y="398" fill="white" font-size="25">64</text>
<rect x="500" y="360" width="100" height="60" fill="hsl(298.02000000000044 70% 45%)"/>
<text x="535" y="398" fill="white" font-size="25">65</text>
<rect x="600" y="360" width="100" height="60" fill="hsl(75.52800000000025 70% 45%)"/>
<text x="635" y="398" fill="white" font-size="25">66</text>
<rect x="700" y="360" width="100" height="60" fill="hsl(213.03600000000006 70% 45%)"/>
<text x="735" y="398" fill="white" font-size="25">67</text>
<rect x="800" y="360" width="100" height="60" fill="hsl(350.54399999999987 70% 45%)"/>
<text x="835" y="398" fill="white" font-size="25">68</text>
<rect x="900" y="360" width="100" height="60" fill="hsl(128.0520000000015 70% 45%)"/>
<text x="935" y="398" fill="white" font-size="25">69</text>
<rect x="0" y="420" width="100" height="60" fill="hsl(265.5600000000013 70% 45%)"/>
<text x="35" y="458" fill="white" font-size="25">70</text>
<rect x="100" y="420" width="100" height="60" fill="hsl(43.06800000000112 70% 45%)"/>
<text x="135" y="458" fill="white" font-size="25">71</text>
<rect x="200" y="420" width="100" height="60" fill="hsl(180.57600000000093 70% 45%)"/>
<text x="235" y="458" fill="white" font-size="25">72</text>
<rect x="300" y="420" width="100" height="60" fill="hsl(318.08400000000074 70% 45%)"/>
<text x="335" y="458" fill="white" font-size="25">73</text>
<rect x="400" y="420" width="100" height="60" fill="hsl(95.59200000000055 70% 45%)"/>
<text x="435" y="458" fill="white" font-size="25">74</text>
<rect x="500" y="420" width="100" height="60" fill="hsl(233.10000000000036 70% 45%)"/>
<text x="535" y="458" fill="white" font-size="25">75</text>
<rect x="600" y="420" width="100" height="60" fill="hsl(10.608000000000175 70% 45%)"/>
<text x="635" y="458" fill="white" font-size="25">76</text>
<rect x="700" y="420" width="100" height="60" fill="hsl(148.11599999999999 70% 45%)"/>
<text x="735" y="458" fill="white" font-size="25">77</text>
<rect x="800" y="420" width="100" height="60" fill="hsl(285.6240000000016 70% 45%)"/>
<text x="835" y="458" fill="white" font-size="25">78</text>
<rect x="900" y="420" width="100" height="60" fill="hsl(63.132000000001426 70% 45%)"/>
<text x="935" y="458" fill="white" font-size="25">79</text>
<rect x="0" y="480" width="100" height="60" fill="hsl(200.64000000000124 70% 45%)"/>
<text x="35" y="518" fill="white" font-size="25">80</text>
<rect x="100" y="480" width="100" height="60" fill="hsl(338.14800000000105 70% 45%)"/>
<text x="135" y="518" fill="white" font-size="25">81</text>
<rect x="200" y="480" width="100" height="60" fill="hsl(115.65600000000086 70% 45%)"/>
<text x="235" y="518" fill="white" font-size="25">82</text>
<rect x="300" y="480" width="100" height="60" fill="hsl(253.16400000000067 70% 45%)"/>
<text x="335" y="518" fill="white" font-size="25">83</text>
<rect x="400" y="480" width="100" height="60" fill="hsl(30.67200000000048 70% 45%)"/>
<text x="435" y="518" fill="white" font-size="25">84</text>
<rect x="500" y="480" width="100" height="60" fill="hsl(168.1800000000003 70% 45%)"/>
<text x="535" y="518" fill="white" font-size="25">85</text>
<rect x="600" y="480" width="100" height="60" fill="hsl(305.6880000000001 70% 45%)"/>
<text x="635" y="518" fill="white" font-size="25">86</text>
<rect x="700" y="480" width="100" height="60" fill="hsl(83.19600000000173 70% 45%)"/>
<text x="735" y="518" fill="white" font-size="25">87</text>
<rect x="800" y="480" width="100" height="60" fill="hsl(220.70400000000154 70% 45%)"/>
<text x="835" y="518" fill="white" font-size="25">88</text>
<rect x="900" y="480" width="100" height="60" fill="hsl(358.21200000000135 70% 45%)"/>
<text x="935" y="518" fill="white" font-size="25">89</text>
<rect x="0" y="540" width="100" height="60" fill="hsl(135.72000000000116 70% 45%)"/>
<text x="35" y="578" fill="white" font-size="25">90</text>
<rect x="100" y="540" width="100" height="60" fill="hsl(273.228000000001 70% 45%)"/>
<text x="135" y="578" fill="white" font-size="25">91</text>
<rect x="200" y="540" width="100" height="60" fill="hsl(50.736000000000786 70% 45%)"/>
<text x="235" y="578" fill="white" font-size="25">92</text>
<rect x="300" y="540" width="100" height="60" fill="hsl(188.2440000000006 70% 45%)"/>
<text x="335" y="578" fill="white" font-size="25">93</text>
<rect x="400" y="540" width="100" height="60" fill="hsl(325.7520000000004 70% 45%)"/>
<text x="435" y="578" fill="white" font-size="25">94</text>
<rect x="500" y="540" width="100" height="60" fill="hsl(103.26000000000022 70% 45%)"/>
<text x="535" y="578" fill="white" font-size="25">95</text>
<rect x="600" y="540" width="100" height="60" fill="hsl(240.76800000000003 70% 45%)"/>
<text x="635" y="578" fill="white" font-size="25">96</text>
<rect x="700" y="540" width="100" height="60" fill="hsl(18.27600000000166 70% 45%)"/>
<text x="735" y="578" fill="white" font-size="25">97</text>
<rect x="800" y="540" width="100" height="60" fill="hsl(155.78400000000147 70% 45%)"/>
<text x="835" y="578" fill="white" font-size="25">98</text>
<rect x="900" y="540" width="100" height="60" fill="hsl(293.2920000000013 70% 45%)"/>
<text x="935" y="578" fill="white" font-size="25">99</text>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

+234
View File
@@ -0,0 +1,234 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await expect.poll(() => page.evaluate(() => window.art.video.paused && !window.art.video.seeking)).toBe(true)
}
for (const core of ['published', 'candidate']) {
for (const mode of ['remove', 'destroy', 'source']) {
test(`${core}: progress click reentry through ${mode} cannot continue an obsolete seek`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate((mode) => {
const art = window.art
let seeks = 0
let bars = 0
art.on('seek', () => seeks++)
art.on('setBar', (type) => {
if (type !== 'played' || bars++)
return
if (mode === 'remove')
art.controls.remove('progress')
else if (mode === 'destroy')
art.destroy()
else
art.url = '/test/pattern.mp4?progress=reentrant'
})
const progress = art.template.$progress
progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 }))
const result = { seeks, bars }
art.destroy()
return result
}, mode)
expect(result).toEqual({ seeks: core === 'published' ? 1 : 0, bars: 1 })
})
}
test(`${core}: real progress click keeps setBar before seek and clamps the edges`, async ({ page }) => {
await setup(page, core)
const rect = await page.evaluate(() => {
const art = window.art
window.progressOrder = []
art.on('setBar', (type, percent) => {
if (type === 'played')
window.progressOrder.push(['setBar', percent])
})
art.on('seek', () => window.progressOrder.push(['seek']))
const rect = art.template.$progress.getBoundingClientRect()
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
})
await page.mouse.click(rect.x, rect.y)
await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false)
expect(await page.evaluate(() => window.art.currentTime / window.art.duration)).toBeCloseTo(0.5, 1)
const result = await page.evaluate(() => {
const art = window.art
const firstSeek = window.progressOrder.findIndex(item => item[0] === 'seek')
const previous = window.progressOrder[firstSeek - 1]
const progress = art.template.$progress
progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().left - 100 }))
const start = art.currentTime
progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().right + 100 }))
const end = art.currentTime
const duration = art.duration
art.destroy()
return { previous, start, end, duration }
})
expect(result.previous[0]).toBe('setBar')
expect(result.previous[1]).toBeCloseTo(0.5, 1)
expect(result.start).toBe(0)
expect(result.end).toBeCloseTo(result.duration, 1)
})
test(`${core}: a progress drag ends when its source is replaced and can restart on the new source`, async ({ page }) => {
await setup(page, core)
const stale = await page.evaluate(() => {
const art = window.art
window.dragSeeks = 0
art.on('seek', () => window.dragSeeks++)
const progress = art.template.$progress
progress.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
art.url = '/test/pattern.mp4?progress=new-source'
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 }))
return window.dragSeeks
})
expect(stale).toBe(core === 'published' ? 1 : 0)
await expect.poll(() => page.evaluate(() => window.art.video.readyState >= 2)).toBe(true)
expect(await page.evaluate(() => {
const art = window.art
const progress = art.template.$progress
progress.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 }))
art.emit('document:mouseup', new MouseEvent('mouseup'))
const seeks = window.dragSeeks
art.destroy()
return seeks
})).toBe(stale + 1)
})
test(`${core}: a caller-owned media Blob URL remains readable after switching and destroying the player`, async ({ page, browserName }, testInfo) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(async () => {
window.blobCapabilities = []
let blob
let fallback
for (const extension of ['mp4', 'webm']) {
const candidate = await (await fetch(`/test/pattern.${extension}`)).blob()
fallback ||= candidate
const url = URL.createObjectURL(candidate)
const video = document.createElement('video')
const capability = await new Promise((resolve) => {
video.onloadedmetadata = () => resolve({ extension, width: video.videoWidth, error: 0 })
video.onerror = () => resolve({ extension, width: video.videoWidth, error: video.error.code })
video.src = url
video.load()
})
window.blobCapabilities.push(capability)
video.onloadedmetadata = null
video.onerror = null
video.removeAttribute('src')
video.load()
URL.revokeObjectURL(url)
if (!capability.error) {
blob = candidate
break
}
}
window.blobPlayable = !!blob
blob ||= fallback
window.sharedBlobSize = blob.size
window.sharedBlobUrl = URL.createObjectURL(blob)
window.borrowedRevocations = []
const original = URL.revokeObjectURL
URL.revokeObjectURL = function (url) {
window.borrowedRevocations.push(url)
return original.call(this, url)
}
window.createPlayer(window.blobPlayable ? window.sharedBlobUrl : '/test/pattern.mp4')
})
const capabilities = await page.evaluate(() => window.blobCapabilities)
await testInfo.attach('native-blob-capabilities', { contentType: 'application/json', body: JSON.stringify(capabilities) })
if (!capabilities.some(item => item.error === 0)) {
expect(browserName).toBe('webkit')
expect(capabilities).toEqual([{ extension: 'mp4', width: 0, error: 4 }, { extension: 'webm', width: 0, error: 4 }])
}
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const result = await page.evaluate(async () => {
const art = window.art
// The Windows WebKit runtime cannot decode either Blob fixture natively.
// Still exercise actual URL assignment/replacement and byte ownership there.
if (!window.blobPlayable)
art.url = window.sharedBlobUrl
const assigned = art.video.src === window.sharedBlobUrl
await art.switchUrl('/test/pattern.mp4?borrowed=replaced')
const read = async () => {
try {
return (await (await fetch(window.sharedBlobUrl)).blob()).size
}
catch (error) {
return error.name
}
}
const afterSwitch = await read()
art.destroy()
const afterDestroy = await read()
const revoked = window.borrowedRevocations.filter(url => url === window.sharedBlobUrl).length
URL.revokeObjectURL(window.sharedBlobUrl)
return { afterSwitch, afterDestroy, revoked, size: window.sharedBlobSize, assigned, decodedBlob: window.blobPlayable }
})
expect(result.size).toBeGreaterThan(100)
expect(result.assigned).toBe(true)
await testInfo.attach('borrowed-url-evidence', { contentType: 'application/json', body: JSON.stringify(result) })
expect(result.revoked).toBe(core === 'published' ? 1 : 0)
expect(result.afterSwitch).toBe(core === 'published' ? 'TypeError' : result.size)
expect(result.afterDestroy).toBe(core === 'published' ? 'TypeError' : result.size)
})
test(`${core}: quality selectors retain default labels, source position and empty-array behavior`, async ({ page, browserName }, testInfo) => {
await setup(page, core)
await page.evaluate(() => {
const art = window.art
window.qualityNativeEvents = []
for (const key of ['currentTime', 'playbackRate', 'src']) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, key)
Object.defineProperty(art.video, key, {
configurable: true,
get() { return descriptor.get.call(this) },
set(value) {
window.qualityNativeEvents.push({ key, value, time: this.currentTime, seeking: this.seeking, source: this.currentSrc })
descriptor.set.call(this, value)
},
})
}
for (const event of ['loadedmetadata', 'seeking', 'seeked', 'canplay', 'ratechange', 'pause', 'playing'])
art.video.addEventListener(event, () => window.qualityNativeEvents.push({ event, time: art.currentTime, seeking: art.video.seeking, source: art.video.currentSrc }))
art.playbackRate = 1.5
art.currentTime = 2
window.qualityRestarts = []
art.on('restart', url => window.qualityRestarts.push(url))
window.qualityOptions = [
{ html: 'Quality A', url: '/test/pattern.mp4?quality=A' },
{ html: 'Quality B', url: '/test/pattern.mp4?quality=B', default: true },
]
art.quality = window.qualityOptions
})
await expect.poll(() => page.evaluate(() => Math.abs(window.art.currentTime - 2) < 0.05 && !window.art.video.seeking)).toBe(true)
await expect(page.locator('.art-control-quality .art-selector-value')).toHaveText('Quality B')
await page.locator('.art-control-quality').hover()
await page.locator('.art-control-quality .art-selector-item').filter({ hasText: 'Quality A' }).click()
await expect.poll(() => page.evaluate(() => window.art.url.includes('quality=A'))).toBe(true)
await expect.poll(() => page.evaluate(() => window.qualityRestarts)).toEqual(['/test/pattern.mp4?quality=A'])
await expect.poll(() => page.evaluate(() => window.art.video.readyState >= 3 && !window.art.video.seeking)).toBe(true)
await expect(page.locator('.art-control-quality .art-selector-value')).toHaveText('Quality A')
await testInfo.attach('quality-native-events', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => window.qualityNativeEvents)) })
const restoredTime = await page.evaluate(() => window.art.currentTime)
if (core === 'published' && browserName === 'webkit')
expect(restoredTime).toBeLessThan(0.05)
else
expect(restoredTime).toBeCloseTo(2, 1)
expect(await page.evaluate(() => window.art.playbackRate)).toBe(1.5)
expect(await page.evaluate(() => window.qualityOptions.map(item => !!item.default))).toEqual([true, false])
const empty = await page.evaluate(() => {
const art = window.art
art.quality = []
const result = { text: art.controls.quality.textContent, read: art.quality, hasGetter: !!Object.getOwnPropertyDescriptor(art, 'quality').get }
art.destroy()
return result
})
expect(empty).toEqual({ text: '', read: undefined, hasGetter: false })
})
}
+214
View File
@@ -0,0 +1,214 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, crossOrigin = false, control = false) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(({ crossOrigin, control }) => {
const url = new URL('/test/pattern.mp4', location.href)
if (crossOrigin)
url.hostname = 'localhost'
window.art = new window.Artplayer({ container: '.player', url: url.href, muted: true, screenshot: control })
}, { crossOrigin, control })
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.evaluate(() => window.art.pause())
}
for (const core of ['published', 'candidate']) {
test(`${core}: captures decoded PNG pixels and consumer-owned Blob URLs`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
const data = await art.getDataURL()
const url = await art.getBlobUrl()
const image = new Image()
image.src = data
await image.decode()
const canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const context = canvas.getContext('2d')
context.drawImage(image, 0, 0)
const pixels = context.getImageData(0, 0, image.width, image.height).data
const colors = new Set()
for (let index = 0; index < pixels.length; index += 4)
colors.add(`${pixels[index]},${pixels[index + 1]},${pixels[index + 2]}`)
const expected = [art.video.videoWidth, art.video.videoHeight]
const descriptors = ['getDataURL', 'getBlobUrl', 'screenshot'].map((name) => {
const d = Object.getOwnPropertyDescriptor(art, name)
return [d.writable, d.configurable, d.enumerable]
})
art.destroy()
const blob = await (await fetch(url)).blob()
URL.revokeObjectURL(url)
return { png: data.startsWith('data:image/png;base64,'), dimensions: [image.width, image.height], expected, colors: colors.size, blobType: blob.type, blobSize: blob.size, descriptors }
})
expect(result.png).toBe(true)
expect(result.dimensions).toEqual(result.expected)
expect(result.dimensions[0]).toBeGreaterThan(0)
expect(result.colors).toBeGreaterThan(10)
expect(result.blobType).toBe('image/png')
expect(result.blobSize).toBeGreaterThan(100)
expect(result.descriptors).toEqual(Array.from({ length: 3 }, () => [false, false, false]))
})
test(`${core}: screenshot preserves download filename, event data and Promise result`, async ({ page }) => {
await setup(page, core)
const downloadPromise = page.waitForEvent('download')
const result = await page.evaluate(async () => {
const art = window.art
const events = []
art.on('screenshot', value => events.push(value))
const screenshot = art.screenshot
const value = await screenshot('chosen.png')
art.destroy()
return { count: events.length, same: events[0] === value, png: value.startsWith('data:image/png;base64,') }
})
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('chosen.png.png')
expect(result).toEqual({ count: 1, same: true, png: true })
await download.delete()
})
test(`${core}: actual cross-origin video rejects capture with SecurityError and notice`, async ({ page }) => {
await setup(page, core, true)
const result = await page.evaluate(async () => {
const art = window.art
const errors = []
for (const name of ['getDataURL', 'getBlobUrl', 'screenshot']) {
try {
await art[name]()
errors.push('resolved')
}
catch (error) {
errors.push(error.name)
}
}
const result = { errors, notice: art.notice.show, text: art.template.$noticeInner.textContent.length }
art.destroy()
return result
})
expect(result.errors).toEqual(['SecurityError', 'SecurityError', 'SecurityError'])
expect(result.notice).toBe(true)
expect(result.text).toBeGreaterThan(0)
})
test(`${core}: source replacement does not download a stale captured frame`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
let events = 0
let downloads = 0
const original = HTMLAnchorElement.prototype.click
HTMLAnchorElement.prototype.click = () => {
downloads++
}
art.on('screenshot', () => {
events++
})
try {
const promise = art.screenshot()
art.url = '/test/pattern.mp4?replacement=1'
const result = { png: (await promise).startsWith('data:image/png;base64,'), events, downloads }
art.destroy()
return result
}
finally {
HTMLAnchorElement.prototype.click = original
}
})
const oldEffects = core === 'published' ? 1 : 0
expect(result).toEqual({ png: true, events: oldEffects, downloads: oldEffects })
})
test(`${core}: screenshot toolbar rejection has exact historical error accounting`, async ({ page }, testInfo) => {
await setup(page, core, false, true)
await page.evaluate((core) => {
window.captureFailures = []
const failure = new Error('controlled screenshot draw failure')
const original = HTMLCanvasElement.prototype.toDataURL
window.restoreCapture = () => {
HTMLCanvasElement.prototype.toDataURL = original
}
HTMLCanvasElement.prototype.toDataURL = () => {
throw failure
}
if (core === 'published') {
window.addEventListener('unhandledrejection', (event) => {
if (event.reason === failure) {
window.captureFailures.push(event.reason.message)
event.preventDefault()
}
})
}
window.art.notice.show = false
}, core)
await page.locator('.art-control-screenshot').click()
await expect.poll(() => page.evaluate(() => window.art.template.$noticeInner.textContent)).toBe('controlled screenshot draw failure')
if (core === 'published')
await expect.poll(() => page.evaluate(() => window.captureFailures.length)).toBe(1)
const known = await page.evaluate(() => {
window.restoreCapture()
window.art.destroy()
return window.captureFailures
})
expect(known).toEqual(core === 'published' ? ['controlled screenshot draw failure'] : [])
await testInfo.attach('known-screenshot-rejections', { contentType: 'application/json', body: JSON.stringify(known) })
})
}
test('candidate: null toBlob callback rejects and reports without a page error', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(async () => {
const prototype = HTMLCanvasElement.prototype
const original = prototype.toBlob
prototype.toBlob = function (callback) {
queueMicrotask(() => callback(null))
}
try {
await window.art.getBlobUrl()
return 'resolved'
}
catch (error) {
return error.message
}
finally {
prototype.toBlob = original
window.art.destroy()
}
})).toBe('Unable to encode screenshot blob')
})
test('candidate: destroy during screenshot await suppresses the queued download and event', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(async () => {
const art = window.art
let events = 0
let downloads = 0
const original = HTMLAnchorElement.prototype.click
HTMLAnchorElement.prototype.click = () => {
downloads++
}
art.on('screenshot', () => {
events++
})
try {
const promise = art.screenshot()
art.destroy()
return { png: (await promise).startsWith('data:image/png;base64,'), events, downloads }
}
finally {
HTMLAnchorElement.prototype.click = original
}
})).toEqual({ png: true, events: 0, downloads: 0 })
})
test('candidate: cross-origin screenshot toolbar handles its internal rejected Promise', async ({ page }) => {
await setup(page, 'candidate', true, true)
await page.evaluate(() => {
window.art.notice.show = false
})
await page.locator('.art-control-screenshot').click()
await expect.poll(() => page.evaluate(() => window.art.notice.show)).toBe(true)
await page.evaluate(() => window.art.destroy())
})
+1
View File
@@ -61,6 +61,7 @@ for (const extension of ['mp4', 'webm']) {
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
add('/test/thumbnail-grid.svg', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-grid.svg')), { kind: 'test-thumbnail-grid', file: 'test/browser/media/thumbnail-grid.svg' })
function send(req, res, bytes, filename) {
res.setHeader('Content-Type', mime[path.extname(filename)] || 'application/octet-stream')
res.setHeader('Cache-Control', 'no-store')
+51
View File
@@ -116,6 +116,57 @@ test('candidate: quality switch restores position/rate and preserves real custom
expect(result.error).toBe(0)
})
test('candidate: direct currentTime during a native quality seek is not overwritten by automatic restoration', async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?position=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await expect.poll(() => page.evaluate(() => window.art.video.paused && !window.art.video.seeking)).toBe(true)
await page.evaluate(() => {
window.art.currentTime = 2
})
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && Math.abs(window.art.currentTime - 2) < 0.05)).toBe(true)
const result = await page.evaluate(async () => {
const { art } = window
let overrides = 0
const seeks = []
const writes = []
for (const name of ['currentTime', 'playbackRate']) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, name)
Object.defineProperty(art.video, name, {
configurable: true,
get() { return descriptor.get.call(this) },
set(value) {
writes.push({ name, value, before: this.currentTime, seeking: this.seeking })
descriptor.set.call(this, value)
writes.push({ name: `${name}:after`, value: this.currentTime, seeking: this.seeking })
},
})
}
art.once('video:seeked', () => {
overrides++
art.currentTime = 4
})
art.on('video:seeked', () => seeks.push({ time: art.currentTime, seeking: art.video.seeking }))
await art.switchQuality('/test/pattern.mp4?position=consumer')
const result = { overrides, seeks, writes, time: art.currentTime, paused: art.video.paused, seeking: art.video.seeking }
art.destroy()
return result
})
await testInfo.attach('quality-direct-position', { contentType: 'application/json', body: JSON.stringify(result) })
expect(result.overrides).toBe(1)
// Assert the player write contract separately from native seek precision.
// WebKit may finish this reentrant native seek at a different media clock.
const positions = result.writes.filter(item => item.name === 'currentTime')
expect(positions).toHaveLength(2)
expect(positions[0].value).toBeCloseTo(2, 1)
expect(positions[1].value).toBe(4)
expect(result.paused).toBe(true)
expect(result.seeking).toBe(false)
})
test('candidate: real switch settles when the controlled native resume rejects', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?resume=initial'))
+165
View File
@@ -0,0 +1,165 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, scale = 1) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(({ scale }) => {
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
thumbnails: { url: '/test/thumbnail-grid.svg', number: 100, column: 10, width: 100, height: 60, scale },
})
window.hoverThumbnail = (percentage) => {
const art = window.art
const progress = art.template.$progress
const clientX = progress.getBoundingClientRect().left + progress.clientWidth * percentage
art.emit('setBar', 'hover', percentage, new MouseEvent('mousemove', { clientX }))
}
}, { scale })
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
async function visible(page) {
await expect.poll(() => page.evaluate(() => window.art.controls.thumbnails.style.backgroundImage)).not.toBe('')
}
for (const core of ['published', 'candidate']) {
test(`${core}: thumbnail cells follow the generated grid at row boundaries and on returning to the first cell`, async ({ page }, testInfo) => {
await setup(page, core)
await page.evaluate(() => window.hoverThumbnail(0.1))
await visible(page)
const result = await page.evaluate(() => {
const art = window.art
const element = art.controls.thumbnails
const row = element.style.backgroundPosition
const dimensions = [element.style.width, element.style.height]
window.hoverThumbnail(0.11)
const ordinary = element.style.backgroundPosition
window.hoverThumbnail(0.001)
const first = element.style.backgroundPosition
const left = element.style.left
window.hoverThumbnail(0.999)
const last = element.style.backgroundPosition
const right = Number.parseFloat(element.style.left)
const expectedRight = art.template.$progress.clientWidth - 100
const descriptor = Object.getOwnPropertyDescriptor(art, 'thumbnails')
return { row, ordinary, first, last, left, right, expectedRight, dimensions, immutable: !descriptor.enumerable && !descriptor.configurable }
})
expect(result.row).toBe(core === 'published' ? '-900px 0px' : '0px -60px')
expect(result.ordinary).toBe('-100px -60px')
expect(result.first).toBe(core === 'published' ? '-100px -60px' : '0px 0px')
expect(result.last).toBe('-900px -540px')
expect(result.left).toBe('0px')
expect(result.right).toBe(result.expectedRight)
expect(result.dimensions).toEqual(['100px', '60px'])
expect(result.immutable).toBe(true)
await testInfo.attach('thumbnail-grid', { contentType: 'image/png', body: await page.locator('.player').screenshot() })
await page.evaluate(() => window.art.destroy())
})
test(`${core}: scaled internal thumbnails and public image results have distinct Blob URL owners`, async ({ page }) => {
await setup(page, core, 0.5)
await page.evaluate(() => {
window.thumbnailRevocations = []
const original = URL.revokeObjectURL
URL.revokeObjectURL = function (url) {
window.thumbnailRevocations.push(url)
return original.call(this, url)
}
})
await page.evaluate(() => window.hoverThumbnail(0.2))
await visible(page)
const result = await page.evaluate(async () => {
const art = window.art
const style = art.controls.thumbnails.style
const internal = style.backgroundImage.slice(5, -2)
const image = await window.Artplayer.utils.loadImg('/test/thumbnail-grid.svg', 0.5)
const publicUrl = image.src
const dimensions = [image.naturalWidth, image.naturalHeight]
const previewSize = [style.width, style.height]
art.destroy()
const revokedInternal = window.thumbnailRevocations.filter(url => url === internal).length
const revokedPublic = window.thumbnailRevocations.filter(url => url === publicUrl).length
const blob = await (await fetch(publicUrl)).blob()
URL.revokeObjectURL(publicUrl)
if (!revokedInternal)
URL.revokeObjectURL(internal)
return { dimensions, previewSize, internalBlob: internal.startsWith('blob:'), revokedInternal, revokedPublic, publicType: blob.type }
})
expect(result).toEqual({ dimensions: [500, 300], previewSize: ['50px', '30px'], internalBlob: true, revokedInternal: core === 'published' ? 0 : 1, revokedPublic: 0, publicType: 'image/png' })
})
test(`${core}: a delayed image follows the latest hover rather than the first request position`, async ({ page, request }) => {
const svg = await (await request.get('/test/thumbnail-grid.svg')).body()
let release
const gate = new Promise(resolve => release = resolve)
let requested
const received = new Promise(resolve => requested = resolve)
await page.route('**/test/thumbnail-grid.svg?delayed=1', async (route) => {
requested()
await gate
await route.fulfill({ contentType: 'image/svg+xml', body: svg })
})
await setup(page, core)
await page.evaluate(() => {
window.art.thumbnails = { ...window.art.thumbnails, url: '/test/thumbnail-grid.svg?delayed=1' }
window.hoverThumbnail(0.2)
})
await received
await page.evaluate(() => window.hoverThumbnail(0.7))
release()
await visible(page)
const result = await page.evaluate(() => ({ left: Number.parseFloat(window.art.controls.thumbnails.style.left), width: window.art.template.$progress.clientWidth }))
expect(result.left).toBeCloseTo(result.width * (core === 'published' ? 0.2 : 0.7) - 50, 4)
await page.evaluate(() => window.art.destroy())
})
}
test('candidate: removing thumbnails revokes the scaled image and a replacement control can reload', async ({ page }) => {
await setup(page, 'candidate', 0.5)
await page.evaluate(() => window.hoverThumbnail(0.2))
await visible(page)
await page.evaluate(() => {
const art = window.art
const url = art.controls.thumbnails.style.backgroundImage.slice(5, -2)
window.removedThumbnailRevocations = 0
const original = URL.revokeObjectURL
URL.revokeObjectURL = function (value) {
if (value === url)
window.removedThumbnailRevocations++
return original.call(this, value)
}
art.controls.remove('thumbnails')
art.controls.add({ name: 'thumbnails', position: 'top', index: 20 })
window.hoverThumbnail(0.4)
})
await visible(page)
expect(await page.evaluate(() => window.removedThumbnailRevocations)).toBe(1)
await page.evaluate(() => window.art.destroy())
expect(await page.evaluate(() => window.removedThumbnailRevocations)).toBe(1)
})
test('candidate: image failure permits a retry through the same thumbnail configuration', async ({ page, request }) => {
const svg = await (await request.get('/test/thumbnail-grid.svg')).body()
let attempts = 0
await page.route('**/test/thumbnail-grid.svg?retry=1', async (route) => {
attempts++
await route.fulfill({
status: attempts === 1 ? 503 : 200,
headers: { 'Cache-Control': 'no-store' },
contentType: 'image/svg+xml',
body: attempts === 1 ? '<temporary-image-failure/>' : svg,
})
})
await setup(page, 'candidate')
const warning = page.waitForEvent('console', message => message.type() === 'warning' && message.text().includes('ArtPlayer thumbnail load failed'))
await page.evaluate(() => {
window.art.thumbnails = { ...window.art.thumbnails, url: '/test/thumbnail-grid.svg?retry=1' }
window.hoverThumbnail(0.2)
})
await warning
await page.evaluate(() => window.hoverThumbnail(0.3))
await visible(page)
expect(attempts).toBe(2)
await page.evaluate(() => window.art.destroy())
})