import type Artplayer from '../../packages/artplayer/public/artplayer.ts' import type { DocumentationExample } from './parser.ts' interface FrameWindow extends Window { Artplayer?: typeof Artplayer eval: (code: string) => unknown } export interface SmokeResult { id: string instances: number ready: number media: { readyState: number, width: number, height: number, duration: number }[] } let running = false /** Evaluate one example in an owned browsing context; readiness is not feature acceptance. */ export async function runExample( example: DocumentationExample, scripts: string[], timeoutMs = 4500, ): Promise { if (running) throw new Error('Documentation smoke cases must run sequentially') const savedStorage = [localStorage, sessionStorage].map(storage => ({ storage, entries: Object.entries(storage) })) const iframe = document.createElement('iframe') iframe.dataset.documentationSmoke = example.id iframe.title = `Documentation smoke: ${example.id}` iframe.style.cssText = 'width:640px;height:360px;border:0;display:block' const context: { frame: FrameWindow | null } = { frame: null } let active = true const disposers: (() => void)[] = [] let fail: (error: unknown) => void = () => {} const failure = new Promise((_resolve, reject) => { fail = reject }) const deadline = window.setTimeout( () => fail(new Error(`${example.id}: readiness timeout (${timeoutMs}ms)`)), timeoutMs, ) running = true const ensureActive = () => { if (!active) throw new Error('Documentation context was disposed') } function loaded(target: EventTarget, action: () => void, description: string): Promise { return new Promise((resolve, reject) => { const done = () => { cleanup() resolve() } const error = () => { cleanup() reject(new Error(`Failed to load ${description}`)) } function cleanup() { target.removeEventListener('load', done) target.removeEventListener('error', error) } target.addEventListener('load', done, { once: true }) target.addEventListener('error', error, { once: true }) disposers.push(() => { cleanup() reject(new Error(`Cancelled ${description}`)) }) action() }) } const work = async (): Promise => { await loaded( iframe, () => { iframe.srcdoc = '
' document.body.appendChild(iframe) }, 'documentation frame', ) ensureActive() const frame = iframe.contentWindow as FrameWindow | null context.frame = frame if (!frame) throw new Error('Documentation frame is unavailable') const onError = (event: ErrorEvent) => { event.preventDefault() fail(new Error(`${example.id}: ${event.message}`)) } const onRejection = (event: PromiseRejectionEvent) => { event.preventDefault() fail(new Error(`${example.id}: ${String(event.reason)}`)) } frame.addEventListener('error', onError) frame.addEventListener('unhandledrejection', onRejection) disposers.push(() => { frame?.removeEventListener('error', onError) frame?.removeEventListener('unhandledrejection', onRejection) }) for (const url of scripts) { const script = frame.document.createElement('script') script.src = new URL(url, location.href).href await loaded(script, () => frame.document.head.appendChild(script), script.src) ensureActive() } if (!frame.Artplayer) throw new Error('Artplayer was not loaded in the documentation frame') // Preserve classic-script globals while keeping timers, DOM and listeners inside this frame. await frame.eval(`${example.code}\n//# sourceURL=artplayer-doc-example-${encodeURIComponent(example.id)}.js`) ensureActive() const instances = frame.Artplayer.instances.slice() await Promise.all( instances.map( instance => new Promise((resolve, reject) => { if (instance.isReady) { resolve() return } const ready = () => { cleanup() resolve() } const error = () => { cleanup() reject(new Error(`${example.id}: player readiness failed`)) } function cleanup() { instance.off('ready', ready) instance.off('error', error) instance.off('video:error', error) } instance.on('ready', ready) instance.on('error', error) instance.on('video:error', error) disposers.push(() => { cleanup() reject(new Error('Cancelled player readiness')) }) }), ), ) // A task boundary delivers unhandled promise rejections from evaluation/ready callbacks. await new Promise((resolve) => { const timer = window.setTimeout(resolve, 0) disposers.push(() => window.clearTimeout(timer)) }) ensureActive() return { id: example.id, instances: instances.length, ready: instances.filter(instance => instance.isReady).length, media: instances.map(instance => ({ readyState: instance.video.readyState, width: instance.video.videoWidth, height: instance.video.videoHeight, duration: instance.video.duration, })), } } let result: SmokeResult | undefined let problem: unknown let failed = false try { result = await Promise.race([work(), failure]) } catch (error) { failed = true problem = error } finally { active = false window.clearTimeout(deadline) for (const dispose of disposers) dispose() const cleanupErrors: unknown[] = [] for (const instance of context.frame?.Artplayer?.instances.slice() || []) { try { instance.destroy(true) } catch (error) { cleanupErrors.push(error) } } iframe.remove() for (const { storage, entries } of savedStorage) { try { storage.clear() for (const [key, value] of entries) storage.setItem(key, value) } catch (error) { cleanupErrors.push(error) } } running = false if (cleanupErrors.length) { const errors = failed ? [problem, ...cleanupErrors] : cleanupErrors problem = Object.assign(new Error(`Documentation cleanup failed: ${errors.map(String).join('; ')}`), { errors }) failed = true } } if (failed) throw problem if (!result) throw new Error('Documentation smoke did not produce a result') return result } interface MochaHost { describe: (name: string, callback: (this: { timeout: (ms: number) => void }) => void) => void it: (name: string, callback: () => Promise) => void } export function registerDocumentationSmoke(examples: DocumentationExample[], scripts: string[]): void { const host = globalThis as unknown as MochaHost if (typeof host.describe !== 'function' || typeof host.it !== 'function') throw new Error('Documentation smoke requires the existing Mocha BDD page') host.describe('Documentation readiness smoke (not feature acceptance)', function () { this.timeout(6000) for (const example of examples) { host.it(example.id, async () => { await runExample(example, scripts) }) } }) }