fix(site): [SITE-VCONSOLE-01] cancel stale console lifecycle work

This commit is contained in:
Harvey Zhao committed 2026-09-14 17:30:17 +08:00
1 parent 838c3eb432
commit e7f31b6fc8
22 files changed
+2318 -32

No files matched your search

+176
View File
@@ -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([])
})
+141
View File
@@ -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 })
}
})