mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-14 00:06:17 -08:00
refactor(core): CORE-01 migrate utility contracts to TypeScript
This commit is contained in:
1 parent
e005e5f249
commit
d3c695024a
33 files changed
+1269
-305
No files matched your search
@@ -22,6 +22,14 @@ When a production module moves, update its loader mapping and its maintenance do
|
||||
|
||||
# Real browser tests
|
||||
|
||||
`test/utils.test.js` compares the published and current utilities, including exact formatting,
|
||||
subtitle text, property/merge behavior, controlled timers and the full utility export surface.
|
||||
It accepts the same `ARTPLAYER_TEST_CORE` option for all three artifact formats. The merge
|
||||
prototype fix is a candidate assertion alongside a published-only defect observation.
|
||||
`test/types/utils-source.ts` checks strict internal source inference without changing the
|
||||
still-separate public declaration contract. Browser utility tests exercise actual downloads,
|
||||
Blob URL contents and cleanup when a controlled click fails.
|
||||
|
||||
See [browser/README.md](browser/README.md) for `yarn test:browser`, browser installation,
|
||||
published/current combinations, media fixtures, candidate mapping and failure reports.
|
||||
These tests are separate from the fast Node suite and run in the Browser playback smoke CI job.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: subtitle object URL contains real VTT data`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
expect(await page.evaluate(async () => {
|
||||
const content = 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nText\n'
|
||||
const url = window.Artplayer.utils.vttToBlob(content)
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
return { blob: url.startsWith('blob:'), type: response.headers.get('content-type'), text: await response.text() }
|
||||
}
|
||||
finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
})).toEqual({ blob: true, type: 'text/vtt', text: 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nText\n' })
|
||||
})
|
||||
|
||||
test(`${core}: download starts and removes its temporary anchor`, async ({ page }) => {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
const download = page.waitForEvent('download')
|
||||
await page.evaluate(() => window.Artplayer.utils.download('/test/pattern.mp4', 'sample.mp4'))
|
||||
const result = await download
|
||||
expect(result.suggestedFilename()).toBe('sample.mp4')
|
||||
expect(await result.failure()).toBeNull()
|
||||
await expect(page.locator('a[download]')).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate download releases the anchor even when click fails', async ({ page }) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
expect(await page.evaluate(() => {
|
||||
const click = HTMLAnchorElement.prototype.click
|
||||
HTMLAnchorElement.prototype.click = () => {
|
||||
throw new Error('controlled click failure')
|
||||
}
|
||||
try {
|
||||
window.Artplayer.utils.download('/test/pattern.mp4', 'sample.mp4')
|
||||
}
|
||||
catch (error) {
|
||||
return error.message
|
||||
}
|
||||
finally {
|
||||
HTMLAnchorElement.prototype.click = click
|
||||
}
|
||||
})).toBe('controlled click failure')
|
||||
await expect(page.locator('a[download]')).toHaveCount(0)
|
||||
})
|
||||
@@ -39,26 +39,26 @@ export async function loadPackage(name, root = workspace) {
|
||||
return importCode(chunks[0].code)
|
||||
}
|
||||
|
||||
function commonJs(code) {
|
||||
function commonJs(code, globals = {}) {
|
||||
const module = { exports: {} }
|
||||
vm.runInNewContext(code, { module, exports: module.exports }, { timeout: 5000 })
|
||||
vm.runInNewContext(code, { ...globals, module, exports: module.exports }, { timeout: 5000 })
|
||||
return module.exports
|
||||
}
|
||||
|
||||
export async function loadPublishedCore() {
|
||||
export async function loadPublishedCore(globals = {}) {
|
||||
const release = JSON.parse(fs.readFileSync(path.join(workspace, 'refactor/baselines/releases.json'), 'utf8')).releases.find(item => item.name === 'artplayer')
|
||||
const archive = await ensureArchive(release)
|
||||
const member = 'package/dist/artplayer.js'
|
||||
const code = readMember(archive, member)
|
||||
assert.equal(hash(code), release.files[member], 'Published core member changed')
|
||||
return { name: `published ${release.version}`, Artplayer: commonJs(code.toString('utf8')) }
|
||||
return { name: `published ${release.version}`, Artplayer: commonJs(code.toString('utf8'), globals) }
|
||||
}
|
||||
|
||||
export async function loadCoreArtifact(filename) {
|
||||
export async function loadCoreArtifact(filename, globals = {}) {
|
||||
const absolute = path.resolve(filename)
|
||||
const Artplayer = absolute.endsWith('.mjs')
|
||||
? (await import(pathToFileURL(absolute))).default
|
||||
: commonJs(fs.readFileSync(absolute, 'utf8'))
|
||||
: commonJs(fs.readFileSync(absolute, 'utf8'), globals)
|
||||
assert.equal(typeof Artplayer?.Emitter, 'function', 'Candidate artifact must expose Artplayer.Emitter')
|
||||
return { name: `candidate ${Artplayer.version} (${path.basename(absolute)})`, Artplayer }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { errorHandle, silencePromise } from '../../packages/artplayer/src/utils/error'
|
||||
import { getExt } from '../../packages/artplayer/src/utils/file'
|
||||
import { def, has, mergeDeep } from '../../packages/artplayer/src/utils/property'
|
||||
import { debounce, sleep, throttle } from '../../packages/artplayer/src/utils/time'
|
||||
|
||||
const pending: Promise<void> = sleep()
|
||||
const extension: string = getExt('video.mp4')
|
||||
const object = { count: 1 }
|
||||
const same: typeof object = def(object, Symbol('metadata'), { value: 'value' })
|
||||
const owns: boolean = has(object, 'count')
|
||||
const merged: { count: number } = mergeDeep(object, { count: 2 })
|
||||
const checked: typeof object = errorHandle(object, 'present')
|
||||
const handled: Promise<number | undefined> = silencePromise(Promise.resolve(2))
|
||||
function callback(this: { count: number }, increment: number) {
|
||||
return this.count + increment
|
||||
}
|
||||
for (const schedule of [debounce(callback, 10), throttle(callback, 10)]) {
|
||||
const result: void = schedule.call(object, 1)
|
||||
// @ts-expect-error Scheduled callbacks preserve argument types.
|
||||
schedule.call(object, '1')
|
||||
// @ts-expect-error Scheduled callbacks preserve receiver types.
|
||||
schedule.call({ count: '1' }, 1)
|
||||
// @ts-expect-error The callback result is not returned synchronously.
|
||||
const wrong: number = schedule.call(object, 1)
|
||||
void [result, wrong]
|
||||
}
|
||||
void [pending, extension, same, owns, merged, checked, handled]
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import process from 'node:process'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Control actual timers without wall-clock sleeps.
|
||||
import { test } from 'node:test'
|
||||
import { loadCoreArtifact, loadPackage, loadPublishedCore } from './helpers/load.js'
|
||||
|
||||
const globals = { setTimeout: (...args) => setTimeout(...args), clearTimeout: timer => clearTimeout(timer) }
|
||||
const published = await loadPublishedCore(globals)
|
||||
const current = { name: 'workspace', Artplayer: (await loadPackage('artplayer')).default }
|
||||
const targets = [published, current]
|
||||
if (process.env.ARTPLAYER_TEST_CORE)
|
||||
targets.push(await loadCoreArtifact(process.env.ARTPLAYER_TEST_CORE, globals))
|
||||
|
||||
for (const { name, Artplayer } of targets) {
|
||||
const utils = Artplayer.utils
|
||||
test(`utils formatting and extension conventions: ${name}`, () => {
|
||||
assert.deepEqual([utils.clamp(4, 5, 1), utils.clamp(-2, 5, 1), utils.clamp(9, 5, 1)], [4, 1, 5])
|
||||
assert(Number.isNaN(utils.clamp(Number.NaN, 0, 1)))
|
||||
for (const [value, result] of [[0, '00:00'], [59.9, '00:59'], [60, '01:00'], [3601, '01:00:01'], [360000, '100:00:00']])
|
||||
assert.equal(utils.secondToTime(value), result)
|
||||
const text = `<b title="A&B">'Text'</b>`
|
||||
assert.equal(utils.escape(text), '<b title="A&B">'Text'</b>')
|
||||
assert.equal(utils.unescape(utils.escape(text)), text)
|
||||
assert.equal(utils.unescape('&lt; <'), '< <')
|
||||
assert.equal(utils.capitalize('hello'), 'Hello')
|
||||
for (const [url, result] of [[' clip.MP4?token=a.b#hash ', 'mp4'], ['clip.webm#x?y=z', 'webm'], [' video ', 'video'], ['', '']])
|
||||
assert.equal(utils.getExt(url), result)
|
||||
})
|
||||
|
||||
test(`utils property descriptors and deep merge array/reference behavior: ${name}`, () => {
|
||||
const key = Symbol('key')
|
||||
const object = Object.create({ inherited: true })
|
||||
assert.equal(utils.def(object, key, { value: 42 }), object)
|
||||
assert.equal(utils.has(object, key), true)
|
||||
assert.equal(utils.has(object, 'inherited'), false)
|
||||
assert.equal(utils.get(object, 'missing'), undefined)
|
||||
assert.deepEqual({ ...utils.get(object, key) }, { value: 42, writable: false, enumerable: false, configurable: false })
|
||||
const first = { nested: { x: 1 }, array: [1] }
|
||||
const array = [2, [3, 4]]
|
||||
const result = utils.mergeDeep(first, { nested: { y: 2 }, array })
|
||||
assert.equal(JSON.stringify(result), '{"nested":{"x":1,"y":2},"array":[1,2,3,4]}')
|
||||
assert.equal(first.array.length, 1)
|
||||
assert.equal(utils.mergeDeep({ array }).array, array)
|
||||
assert.equal(utils.has(utils.mergeDeep(), 'toString'), false)
|
||||
})
|
||||
|
||||
test(`utils subtitle text output: ${name}`, () => {
|
||||
const srt = '1\n00:00:01,2 --> 00:00:02.3456\n{i}Hello{/i} {unknown}\n'
|
||||
assert.equal(utils.srtToVtt(srt), 'WEBVTT \r\n\r\n1\n00:00:01.200 --> 00:00:02.345\n<i>Hello</i> \n\r\n\r\n')
|
||||
const ass = '[Events]\nDialogue: 0,0:00:01.20,0:00:02.34,Default,,0,0,0,,{\\i1} Hello\\N World\n'
|
||||
assert.equal(utils.assToVtt(ass), 'WEBVTT\n\n1\n00:00:01.200 --> 00:00:02.340\nHello\nWorld')
|
||||
assert.equal(utils.assToVtt('[Script Info]\nno dialogue'), 'WEBVTT\n\n')
|
||||
})
|
||||
|
||||
test(`utils errors retain identity and truthy return values: ${name}`, () => {
|
||||
const object = {}
|
||||
assert.equal(utils.errorHandle(object, 'unused'), object)
|
||||
assert.equal(utils.errorHandle('supported', 'unused'), 'supported')
|
||||
for (const condition of [false, 0, '', null, undefined]) {
|
||||
assert.throws(() => utils.errorHandle(condition, 'Failure'), error => error instanceof utils.ArtPlayerError && error.name === 'ArtPlayerError' && error.message === 'Failure')
|
||||
}
|
||||
})
|
||||
|
||||
test(`utils timer arguments, receivers and leading/trailing behavior: ${name}`, async (context) => {
|
||||
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
let slept = false
|
||||
const pending = utils.sleep(10).then(() => slept = true)
|
||||
context.mock.timers.tick(9)
|
||||
await Promise.resolve()
|
||||
assert.equal(slept, false)
|
||||
context.mock.timers.tick(1)
|
||||
await pending
|
||||
const calls = []
|
||||
function receive(value) {
|
||||
calls.push([this.id, value])
|
||||
return 42
|
||||
}
|
||||
const trailing = utils.debounce(receive, 10)
|
||||
assert.equal(trailing.call({ id: 'first' }, 1), undefined)
|
||||
context.mock.timers.tick(5)
|
||||
trailing.call({ id: 'last' }, 2)
|
||||
context.mock.timers.tick(9)
|
||||
assert.deepEqual(calls, [])
|
||||
context.mock.timers.tick(1)
|
||||
assert.deepEqual(calls, [['last', 2]])
|
||||
const leading = utils.throttle(receive, 10)
|
||||
assert.equal(leading.call({ id: 'leading' }, 3), undefined)
|
||||
leading.call({ id: 'ignored' }, 4)
|
||||
context.mock.timers.tick(10)
|
||||
assert.deepEqual(calls, [['last', 2], ['leading', 3]])
|
||||
leading.call({ id: 'later' }, 5)
|
||||
assert.deepEqual(calls, [['last', 2], ['leading', 3], ['later', 5]])
|
||||
})
|
||||
}
|
||||
|
||||
test('utils export surface preserves the published namespace plus existing workspace additions', () => {
|
||||
const expected = [...Object.keys(published.Artplayer.utils), 'silencePromise', 'getSafeAreaInsets'].sort()
|
||||
for (const { Artplayer } of targets.slice(1)) {
|
||||
assert.deepEqual(Object.keys(Artplayer.utils).sort(), expected)
|
||||
for (const key of Object.keys(published.Artplayer.utils)) {
|
||||
assert.equal(typeof Artplayer.utils[key], typeof published.Artplayer.utils[key], key)
|
||||
if (typeof Artplayer.utils[key] === 'function')
|
||||
assert.equal(Artplayer.utils[key].length, published.Artplayer.utils[key].length, key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('mergeDeep keeps __proto__ as own data without replacing result prototypes', () => {
|
||||
const input = JSON.parse('{"__proto__":{"unexpected":true},"constructor":{"prototype":{"marker":true}}}')
|
||||
const old = published.Artplayer.utils.mergeDeep({}, input)
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(old, '__proto__'), false)
|
||||
assert.equal(old.unexpected, true, 'Published defect remains demonstrable')
|
||||
for (const { Artplayer } of targets.slice(1)) {
|
||||
const result = Artplayer.utils.mergeDeep({}, input)
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(result, '__proto__'), true)
|
||||
assert.equal(result.unexpected, undefined)
|
||||
assert.equal(Object.getPrototypeOf(result).unexpected, undefined)
|
||||
assert.equal(Object.getOwnPropertyDescriptor(result, '__proto__').value.unexpected, true)
|
||||
const nested = Artplayer.utils.mergeDeep({ nested: {} }, { nested: input })
|
||||
assert.equal(nested.nested.unexpected, undefined)
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(nested.nested, '__proto__'), true)
|
||||
assert.equal({}.unexpected, undefined)
|
||||
assert.equal(JSON.stringify(input), '{"__proto__":{"unexpected":true},"constructor":{"prototype":{"marker":true}}}')
|
||||
}
|
||||
})
|
||||
|
||||
test('throttle preserves synchronous reentry and retries after callback failure', (context) => {
|
||||
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
for (const { Artplayer } of targets) {
|
||||
let calls = 0
|
||||
const invoke = Artplayer.utils.throttle(() => {
|
||||
calls++
|
||||
if (calls === 1)
|
||||
invoke()
|
||||
}, 10)
|
||||
invoke()
|
||||
assert.equal(calls, 2)
|
||||
const broken = Artplayer.utils.throttle(() => {
|
||||
throw new Error('callback failure')
|
||||
}, 10)
|
||||
assert.throws(broken, /callback failure/)
|
||||
assert.throws(broken, /callback failure/)
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user