fix(thumbnail): [PKG-TOOL-THUMB-04] protect event names and nested once dispatch

This commit is contained in:
Harvey Zhao committed 2026-09-13 05:29:59 +08:00
1 parent 3bff236145
commit 17befee4b1
19 files changed
+9058 -26

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { thumbnailCandidate } from '../helpers/thumbnail.js'
import { expect, test } from './fixtures.js'
const candidate = await thumbnailCandidate()
test('Thumbnail native browser event registry handles special names and nested once without changing DOM cleanup', async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.addScriptTag({ content: candidate.code })
const result = await page.evaluate(() => {
const input = document.createElement('input')
input.type = 'file'
document.body.append(input)
const tool = new ArtplayerToolThumbnail({ fileInput: input })
const keys = []
for (const name of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) {
let calls = 0
tool.emit(name)
const prototype = Object.getPrototypeOf(tool.e)
tool.on(name, () => calls++).once(name, () => calls++)
tool.emit(name).emit(name).off(name)
keys.push({ name, calls, prototypePreserved: Object.getPrototypeOf(tool.e) === prototype, removed: !Object.hasOwn(tool.e, name) })
}
let nested = false
const seen = []
tool.on('custom', value => {
if (!nested) {
nested = true
tool.emit('custom', 'inner')
}
seen.push(`on:${value}`)
})
tool.once('custom', value => seen.push(`once:${value}`))
tool.emit('custom', 'outer')
const video = tool.video
tool.destroy()
tool.destroy()
input.remove()
return { keys, seen, videoConnected: video.isConnected, inputConnected: input.isConnected, processing: tool.processing }
})
expect(result.keys).toEqual(['__proto__', 'constructor', 'toString', 'hasOwnProperty'].map(name => ({ name, calls: 3, prototypePreserved: true, removed: true })))
expect(result.seen).toEqual(['on:inner', 'once:inner', 'on:outer'])
expect(result.videoConnected).toBe(false)
expect(result.inputConnected).toBe(false)
expect(result.processing).toBe(false)
await testInfo.attach('thumbnail-emitter', { contentType: 'application/json', body: JSON.stringify({ candidate: candidate.name, sha256: hash(candidate.code), result }) })
})
+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Candidate event regressions are also run against the frozen workspace.
import test from 'node:test'
import { thumbnailCandidate, thumbnailEnvironment } from './helpers/thumbnail.js'
const implementation = await thumbnailCandidate()
function create() {
const env = thumbnailEnvironment(implementation)
return new env.Factory({ fileInput: new env.Element('input') })
}
test('Thumbnail emitter accepts prototype-colliding event names without changing its registry prototype', () => {
for (const name of ['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty', '__defineGetter__']) {
const tool = create()
assert.equal(tool.emit(name), tool)
assert.equal(tool.off(name, () => {}), tool)
const prototype = Object.getPrototypeOf(tool.e)
let calls = 0
const callback = () => calls++
tool.on(name, callback).once(name, callback)
const descriptor = Object.getOwnPropertyDescriptor(tool.e, name)
assert.equal(descriptor.enumerable, true)
assert.equal(descriptor.configurable, true)
assert.equal(descriptor.writable, true)
tool.emit(name).emit(name)
assert.equal(calls, 3)
tool.off(name, callback)
assert.equal(Object.hasOwn(tool.e, name), false)
assert.equal(Object.getPrototypeOf(tool.e), prototype)
tool.destroy()
}
})
test('Thumbnail emitter does not read inherited listener getters or trigger inherited setters', () => {
const tool = create()
let reads = 0
let writes = 0
const prototype = Object.create(null, {
value: {
get() {
reads++
return []
},
set() { writes++ },
},
})
tool.e = Object.create(prototype)
tool.emit('value').off('value', () => {})
let calls = 0
tool.on('value', () => calls++).emit('value')
assert.equal(calls, 1)
assert.equal(reads, 0)
assert.equal(writes, 0)
assert.equal(Object.getPrototypeOf(tool.e), prototype)
tool.off('value')
assert.equal(Object.hasOwn(tool.e, 'value'), false)
tool.destroy()
})
test('Thumbnail once is consumed once across nested dispatch snapshots', () => {
const tool = create()
let nesting = false
const seen = []
tool.on('value', (value) => {
if (!nesting) {
nesting = true
tool.emit('value', 'inner')
}
seen.push(`on:${value}`)
})
tool.once('value', value => seen.push(`once:${value}`))
tool.emit('value', 'outer')
assert.deepEqual(seen, ['on:inner', 'once:inner', 'on:outer'])
tool.destroy()
})
test('Thumbnail nested once callback failure stays consumed when its caller catches the error', () => {
const tool = create()
let nesting = false
let calls = 0
const failure = { reason: 'once listener' }
tool.on('value', () => {
if (nesting)
return
nesting = true
assert.throws(() => tool.emit('value'), error => error === failure)
})
tool.once('value', () => {
calls++
throw failure
})
assert.doesNotThrow(() => tool.emit('value'))
assert.equal(calls, 1)
tool.emit('value')
assert.equal(calls, 1)
tool.destroy()
})
test('Thumbnail once re-registration is independent from a consumed outer registration', () => {
const tool = create()
let nesting = false
const seen = []
tool.on('value', () => {
if (!nesting) {
nesting = true
tool.emit('value', 'inner')
}
})
function callback(value) {
seen.push(value)
if (value === 'inner')
tool.once('value', callback)
}
tool.once('value', callback).emit('value', 'outer').emit('value', 'next')
assert.deepEqual(seen, ['inner', 'next'])
tool.destroy()
})
test('Thumbnail keeps duplicate registrations, symbol/numeric names and independent instance registries', () => {
const first = create()
const second = create()
for (const name of [0, Symbol('event')]) {
let firstCalls = 0
let secondCalls = 0
const callback = () => firstCalls++
first.once(name, callback).once(name, callback)
second.on(name, () => secondCalls++)
first.emit(name).emit(name)
assert.equal(firstCalls, 2)
assert.equal(secondCalls, 0)
second.emit(name)
assert.equal(secondCalls, 1)
assert.notEqual(first.e, second.e)
}
first.destroy()
second.destroy()
})