mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-21 04:31:20 -08:00
fix(site): [SITE-VCONSOLE-01] cancel stale console lifecycle work
This commit is contained in:
1 parent
838c3eb432
commit
e7f31b6fc8
22 files changed
+2318
-32
No files matched your search
@@ -0,0 +1,176 @@
|
||||
/* eslint-disable no-console -- Exercise the vendor's public log interception. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const artifact = process.env.ARTPLAYER_VCONSOLE_BASELINE === '1' ? 'scripts/site-vendor/vconsole/upstream.js' : 'docs/assets/js/vconsole.min.js'
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/assets/js/vconsole.min.js', route => route.fulfill({ contentType: 'application/javascript', body: fs.readFileSync(artifact) }))
|
||||
})
|
||||
|
||||
for (const recreate of [false, true]) {
|
||||
test(`vConsole cancels queued logging on last plugin removal, recreate=${recreate}`, async ({ page, browser }, testInfo) => {
|
||||
const errors = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.route('**/vconsole-lifecycle.html', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><script src="/assets/js/vconsole.min.js"></script>' }))
|
||||
await page.goto('/vconsole-lifecycle.html')
|
||||
const result = await page.evaluate(async (recreate) => {
|
||||
const raf = window.requestAnimationFrame.bind(window)
|
||||
const cancel = window.cancelAnimationFrame.bind(window)
|
||||
const callbacks = []
|
||||
const cancellations = []
|
||||
window.requestAnimationFrame = (callback) => {
|
||||
const id = raf(callback)
|
||||
callbacks.push({ id, callback })
|
||||
return id
|
||||
}
|
||||
window.cancelAnimationFrame = (id) => {
|
||||
cancellations.push(id)
|
||||
cancel(id)
|
||||
}
|
||||
const first = new window.VConsole({ defaultPlugins: [] })
|
||||
console.log('stale-before-destroy')
|
||||
first.destroy()
|
||||
const late = callbacks.slice()
|
||||
let next
|
||||
if (recreate) {
|
||||
await new Promise((resolve) => {
|
||||
next = new window.VConsole({ defaultPlugins: [], onReady: resolve })
|
||||
})
|
||||
next.show()
|
||||
console.log('fresh-after-recreate')
|
||||
}
|
||||
// Native frames allow the original queued callback to reproduce the failure.
|
||||
await new Promise(resolve => raf(() => raf(resolve)))
|
||||
// A cancelled callback delivered late must also be inert after recreation.
|
||||
const staleErrors = []
|
||||
for (const entry of late) {
|
||||
if (cancellations.includes(entry.id)) {
|
||||
try {
|
||||
entry.callback(performance.now())
|
||||
}
|
||||
catch (error) {
|
||||
staleErrors.push(String(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
const logs = document.querySelector('#__vconsole')?.textContent || ''
|
||||
window.requestAnimationFrame = raf
|
||||
window.cancelAnimationFrame = cancel
|
||||
next?.destroy()
|
||||
await new Promise(resolve => raf(resolve))
|
||||
return { scheduled: callbacks.length, cancelled: cancellations.length, staleErrors, logs, roots: document.querySelectorAll('#__vconsole').length }
|
||||
}, recreate)
|
||||
await testInfo.attach('vconsole-lifecycle', { contentType: 'application/json', body: JSON.stringify({ result, errors, browser: browser.version(), artifactSha256: createHash('sha256').update(fs.readFileSync(artifact)).digest('hex') }) })
|
||||
expect(result.scheduled).toBeGreaterThan(0)
|
||||
expect(result.cancelled).toBeGreaterThan(0)
|
||||
expect(errors).toEqual([])
|
||||
expect(result.staleErrors).toEqual([])
|
||||
expect(result.roots).toBe(0)
|
||||
if (recreate) {
|
||||
expect(result.logs).toContain('fresh-after-recreate')
|
||||
expect(result.logs).not.toContain('stale-before-destroy')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('vConsole removed plugin panels cannot attach to a replacement with the same id', async ({ page }) => {
|
||||
const errors = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.route('**/vconsole-lifecycle.html', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><script src="/assets/js/vconsole.min.js"></script>' }))
|
||||
await page.goto('/vconsole-lifecycle.html')
|
||||
const result = await page.evaluate(async () => {
|
||||
let instance
|
||||
await new Promise((resolve) => {
|
||||
instance = new window.VConsole({ defaultPlugins: [], onReady: resolve })
|
||||
})
|
||||
const makePlugin = (text) => {
|
||||
const plugin = new window.VConsole.VConsolePlugin('replacement', 'Replacement')
|
||||
plugin.on('renderTab', callback => callback(`<div class="panel-ownership-probe">${text}</div>`))
|
||||
return plugin
|
||||
}
|
||||
instance.addPlugin(makePlugin('stale-panel'))
|
||||
instance.removePlugin('replacement')
|
||||
instance.addPlugin(makePlugin('fresh-panel'))
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
const panels = Array.from(document.querySelectorAll('.panel-ownership-probe'), element => element.textContent)
|
||||
instance.destroy()
|
||||
return panels
|
||||
})
|
||||
expect(result).toEqual(['fresh-panel'])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
for (const mode of ['amd', 'commonjs']) {
|
||||
test(`vConsole retains ${mode} factory and private patch state`, async ({ page }) => {
|
||||
const errors = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
const setup = mode === 'amd'
|
||||
? 'window.define=function(name,deps,factory){window.testModuleName=name;window.testConstructor=factory()};window.define.amd=true;'
|
||||
: 'window.module={exports:{}};window.exports=window.module.exports;'
|
||||
await page.route('**/vconsole-lifecycle.html', route => route.fulfill({ contentType: 'text/html', body: `<!doctype html><script>${setup}</script><script src="/assets/js/vconsole.min.js"></script>` }))
|
||||
await page.goto('/vconsole-lifecycle.html')
|
||||
const result = await page.evaluate(async (mode) => {
|
||||
const Constructor = mode === 'amd' ? window.testConstructor : window.module.exports
|
||||
let instance
|
||||
await new Promise((resolve) => {
|
||||
instance = new Constructor({ defaultPlugins: [], onReady: resolve })
|
||||
})
|
||||
instance.show()
|
||||
console.log('factory-contract-log')
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
const logs = document.querySelector('#__vconsole').textContent
|
||||
instance.destroy()
|
||||
return { logs, global: typeof window.VConsole, helper: typeof window.__artplayerVConsoleLogLifecycle, roots: document.querySelectorAll('#__vconsole').length }
|
||||
}, mode)
|
||||
expect(result.logs).toContain('factory-contract-log')
|
||||
expect(result.global).toBe('undefined')
|
||||
expect(result.helper).toBe('undefined')
|
||||
expect(result.roots).toBe(0)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test('vConsole scroller resumes pending layout work safely after destruction', async ({ page }, testInfo) => {
|
||||
const errors = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.route('**/vconsole-lifecycle.html', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><script src="/assets/js/vconsole.min.js"></script>' }))
|
||||
await page.goto('/vconsole-lifecycle.html')
|
||||
await page.evaluate(async () => {
|
||||
await new Promise((resolve) => {
|
||||
window.scrollerInstance = new window.VConsole({ defaultPlugins: [], onReady: resolve })
|
||||
})
|
||||
window.scrollerInstance.show()
|
||||
console.log('scroller-ready')
|
||||
})
|
||||
await expect(page.getByText('scroller-ready', { exact: true })).toBeVisible()
|
||||
const result = await page.evaluate(async () => {
|
||||
const instance = window.scrollerInstance
|
||||
const nativeTimer = window.setTimeout.bind(window)
|
||||
const pending = []
|
||||
window.setTimeout = (callback, delay, ...args) => {
|
||||
if (delay === 0) {
|
||||
pending.push(() => callback(...args))
|
||||
return 0
|
||||
}
|
||||
return nativeTimer(callback, delay, ...args)
|
||||
}
|
||||
document.querySelector('.vc-scroller-viewport').style.height = '137px'
|
||||
for (let index = 0; index < 20; index++)
|
||||
console.log(`layout-row-${index}`)
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
const rows = document.querySelectorAll('.vc-log-row').length
|
||||
instance.destroy()
|
||||
window.setTimeout = nativeTimer
|
||||
for (const callback of pending)
|
||||
callback()
|
||||
await new Promise(resolve => nativeTimer(resolve, 30))
|
||||
return { pending: pending.length, rows, roots: document.querySelectorAll('#__vconsole').length }
|
||||
})
|
||||
await testInfo.attach('vconsole-scroller', { contentType: 'application/json', body: JSON.stringify({ result, errors, artifactSha256: createHash('sha256').update(fs.readFileSync(artifact)).digest('hex') }) })
|
||||
expect(result.pending).toBeGreaterThan(0)
|
||||
expect(result.rows).toBeGreaterThan(0)
|
||||
expect(result.roots).toBe(0)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Exercise scheduling ownership and cleanup failure semantics.
|
||||
import test from 'node:test'
|
||||
import { generateVconsole, hooks } from '../scripts/site-vendor/vconsole/build.ts'
|
||||
import { enqueue, renderTab, unbind } from '../scripts/site-vendor/vconsole/lifecycle.ts'
|
||||
|
||||
function fixture() {
|
||||
const queued = new Map()
|
||||
const cancelled = []
|
||||
const flushed = []
|
||||
let sequence = 0
|
||||
let restored = 0
|
||||
const host = {
|
||||
ADDED_LOG_PLUGIN_ID: ['default'],
|
||||
logQueue: [],
|
||||
flushLogScheduled: false,
|
||||
_flushLogs() {
|
||||
flushed.push(this.logQueue)
|
||||
this.logQueue = []
|
||||
},
|
||||
unmockConsole() { restored++ },
|
||||
}
|
||||
const frames = {
|
||||
requestAnimationFrame(callback) {
|
||||
queued.set(++sequence, callback)
|
||||
return sequence
|
||||
},
|
||||
cancelAnimationFrame(id) { cancelled.push(id) },
|
||||
}
|
||||
return { host, frames, queued, cancelled, flushed, restored: () => restored }
|
||||
}
|
||||
|
||||
test('vConsole batches in original order and releases the scheduling flag before flushing', () => {
|
||||
const { host, frames, queued, flushed } = fixture()
|
||||
enqueue(host, 'one', frames)
|
||||
enqueue(host, 'two', frames)
|
||||
assert.equal(queued.size, 1)
|
||||
queued.get(1)()
|
||||
assert.equal(host.flushLogScheduled, false)
|
||||
assert.deepEqual(flushed, [['one', 'two']])
|
||||
enqueue(host, 'three', frames)
|
||||
queued.get(2)()
|
||||
assert.deepEqual(flushed, [['one', 'two'], ['three']])
|
||||
})
|
||||
|
||||
test('vConsole cancelled callbacks cannot flush or consume a new instance queue', () => {
|
||||
const { host, frames, queued, cancelled, flushed, restored } = fixture()
|
||||
enqueue(host, 'old', frames)
|
||||
host.ADDED_LOG_PLUGIN_ID = []
|
||||
unbind(host, frames)
|
||||
enqueue(host, 'captured old logger after destroy', frames)
|
||||
assert.deepEqual(host.logQueue, [])
|
||||
assert.deepEqual(cancelled, [1])
|
||||
assert.equal(restored(), 1)
|
||||
host.ADDED_LOG_PLUGIN_ID = ['default']
|
||||
enqueue(host, 'new', frames)
|
||||
queued.get(1)()
|
||||
assert.equal(host.flushLogScheduled, true)
|
||||
assert.deepEqual(host.logQueue, ['new'])
|
||||
queued.get(2)()
|
||||
assert.deepEqual(flushed, [['new']])
|
||||
})
|
||||
|
||||
test('vConsole releases ownership and restores console even if native cancellation throws', () => {
|
||||
const { host, frames, queued, restored, flushed } = fixture()
|
||||
const failure = new Error('cancel failed')
|
||||
enqueue(host, 'old', frames)
|
||||
frames.cancelAnimationFrame = () => {
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => unbind(host, frames), error => error === failure)
|
||||
assert.equal(restored(), 1)
|
||||
assert.equal(host.flushLogScheduled, false)
|
||||
queued.get(1)()
|
||||
assert.deepEqual(flushed, [])
|
||||
})
|
||||
|
||||
test('vConsole allows reentrant logging while a prior batch is being flushed', () => {
|
||||
const { host, frames, queued } = fixture()
|
||||
const batches = []
|
||||
host._flushLogs = () => {
|
||||
batches.push(host.logQueue)
|
||||
host.logQueue = []
|
||||
if (batches.length === 1)
|
||||
enqueue(host, 'reentrant', frames)
|
||||
}
|
||||
enqueue(host, 'initial', frames)
|
||||
queued.get(1)()
|
||||
queued.get(2)()
|
||||
assert.deepEqual(batches, [['initial'], ['reentrant']])
|
||||
})
|
||||
|
||||
test('vConsole delayed panels retain their original instance and plugin ownership', async () => {
|
||||
const plugin = { id: 'custom' }
|
||||
const host = { isInited: true, pluginList: { custom: plugin } }
|
||||
const rendered = []
|
||||
renderTab(host, plugin, () => rendered.push('removed'))
|
||||
host.pluginList.custom = { id: 'custom' }
|
||||
renderTab(host, host.pluginList.custom, () => rendered.push('replacement'))
|
||||
const destroyed = { isInited: true, pluginList: { custom: plugin } }
|
||||
renderTab(destroyed, plugin, () => rendered.push('destroyed'))
|
||||
destroyed.isInited = false
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
assert.deepEqual(rendered, ['replacement'])
|
||||
})
|
||||
|
||||
test('vConsole generation is deterministic and preserves all bytes outside pinned hooks', async () => {
|
||||
const root = path.resolve('.')
|
||||
const candidate = await generateVconsole(root)
|
||||
assert.equal(candidate, await generateVconsole(root))
|
||||
const upstream = fs.readFileSync('scripts/site-vendor/vconsole/upstream.js', 'utf8')
|
||||
const original = upstream.split(hooks.factory)
|
||||
assert.equal(original.length, 2)
|
||||
const suffix = original[1]
|
||||
.replace(hooks.signal, 'e._signalLog=function(t){__artplayerVConsoleLogLifecycle.enqueue(this,t,window)}')
|
||||
.replace(hooks.unbind, '0===this.ADDED_LOG_PLUGIN_ID.length&&__artplayerVConsoleLogLifecycle.unbind(this,window)')
|
||||
.replace(hooks.panel, 'o&&__artplayerVConsoleLogLifecycle.renderTab(e,t,(function(){var e=document.querySelector("#__vc_plug_"+t.id);n.HD(o)?e.innerHTML+=o:n.mf(o.appendTo)?o.appendTo(e):n.kK(o)&&e.insertAdjacentElement("beforeend",o)}))')
|
||||
.replace(hooks.resizeResume, 'case 9:if(!s)return n.abrupt("return");F(p),X(p,W.getPosition(),k),0!==k&&H(N&&L),K();')
|
||||
.replace(hooks.itemResume, 'case 12:if(!s)return o.abrupt("return");X(p,W.getPosition(),k),e(6,s.style.height=P+"px",s),K();')
|
||||
assert(candidate.startsWith(`${original[0]}(this||self,(function(){var __artplayerVConsoleLogLifecycle=`))
|
||||
assert(candidate.endsWith(`return function(){${suffix}`))
|
||||
assert.equal(candidate, fs.readFileSync('docs/assets/js/vconsole.min.js', 'utf8'))
|
||||
})
|
||||
|
||||
test('vConsole generator rejects changed upstream input before producing a candidate', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'artplayer-vconsole-'))
|
||||
try {
|
||||
const directory = path.join(root, 'scripts/site-vendor/vconsole')
|
||||
fs.mkdirSync(directory, { recursive: true })
|
||||
fs.writeFileSync(path.join(directory, 'upstream.js'), 'unexpected source')
|
||||
await assert.rejects(generateVconsole(root), /Frozen vConsole source changed/)
|
||||
}
|
||||
finally {
|
||||
assert.equal(path.dirname(root), path.resolve(os.tmpdir()))
|
||||
assert(path.basename(root).startsWith('artplayer-vconsole-'))
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user