mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-15 17:16:16 -08:00
refactor(docs): [SITE-SMOKE-01] verify example readiness and cleanup
This commit is contained in:
1 parent
d74ecc43cc
commit
1a5440a67f
25 files changed
+3510
-4770
No files matched your search
@@ -0,0 +1,75 @@
|
||||
# Documentation readiness smoke
|
||||
|
||||
This generator keeps the existing `yarn build:test` / `node scripts/build-test.js`
|
||||
entrypoint and `docs/test/test.js` URL. Use the pinned Node 24.21.0 runtime and
|
||||
Yarn 1.22.22. Node runs the owned TypeScript modules using native type stripping;
|
||||
the command shim remains JavaScript and is included in strict `checkJs` checking.
|
||||
The root typecheck commands use `node node_modules/typescript/bin/tsc` explicitly:
|
||||
Yarn's shared `tsc` binary can point at the installed 5.1.6 compatibility alias.
|
||||
The main 5.9.3 compiler and older compatibility compilers retain distinct roles.
|
||||
|
||||
| File | Ownership |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `parser.ts` | Markdown tokens, Run Code/fence adjacency, source locations and closing fences |
|
||||
| `generator.ts` | Deterministic file discovery, syntax compilation without execution, dependency list and browser bundle |
|
||||
| `runtime.ts` | One sequential example frame, script loading, error/readiness observation, teardown and Mocha registration |
|
||||
| `../build-test.js` | Original CLI path, argument validation and write/read-only check |
|
||||
| `../tsconfig.docs.json` | Strict Node/DOM tools check, including the JavaScript shim |
|
||||
|
||||
```sh
|
||||
yarn build:test
|
||||
yarn check:docs-smoke
|
||||
yarn typecheck:docs-tools
|
||||
node --test refactor/scripts/docs-smoke.test.mjs
|
||||
yarn test:browser test/browser/docs-smoke.spec.js
|
||||
```
|
||||
|
||||
`docs/test/examples.json` records stable source file/line IDs, original code and
|
||||
the ordered script list from `docs/test/index.html`. No timestamps enter generated
|
||||
content. Generation validates every selected example before writing either output;
|
||||
`--check` compares both files without modifying them. Parse/syntax errors fail the
|
||||
command with a source location. This is not a transactional multi-file filesystem
|
||||
write if the filesystem itself fails during output.
|
||||
|
||||
The scope stays Chinese JavaScript fences after Run Code markers, excluding
|
||||
`en`, `plugin`, `public` and `.vitepress`. There are 233 current cases in 11 files.
|
||||
CRLF is normalized to LF. New cases must be reviewed and the deliberate count
|
||||
assertion updated. JS syntax compilation does not execute snippets or validate
|
||||
their API semantics. TypeScript/editor declaration generation remains SITE-02.
|
||||
|
||||
The browser bundle registers cases with the existing Mocha BDD page. Each case:
|
||||
|
||||
1. Creates an iframe with its own ArtPlayer container and loads the page's runtime
|
||||
scripts in order, excluding Mocha, Chai and the generated test script itself.
|
||||
2. Evaluates the snippet as a classic script in that frame, awaits its returned
|
||||
promise if any, and waits for the currently created players' `ready` events.
|
||||
3. Observes thrown errors, unhandled rejections and media/player errors until that
|
||||
readiness boundary plus one task turn. A 4500 ms deadline produces failure,
|
||||
never success. Cases without players cover evaluation only.
|
||||
4. Detaches observer listeners, destroys frame players, removes the iframe and
|
||||
restores local/session storage. Cleanup errors are failures and retain an
|
||||
earlier failure in the error list. Concurrent calls are rejected.
|
||||
|
||||
The iframe is an owned browsing context, **not a security sandbox**. It shares
|
||||
the parent origin. Run the test page on an isolated test origin with no concurrent
|
||||
same-origin work: storage restoration does not merge writes from other tabs.
|
||||
Unchanged browser storage is restored after each case, while frame-owned timers
|
||||
and globals disappear when the frame is removed. Arbitrary external resources
|
||||
that a snippet deliberately hands to another window are outside this ownership.
|
||||
|
||||
Readiness is not playback, interaction or complete asynchronous feature acceptance.
|
||||
Delayed actions scheduled after this boundary are not verified; examples involving
|
||||
clicks, seek, SDKs, windows and full lifecycle behavior remain EX-03/package tests.
|
||||
The automated harness tests use actual core/media and three selected unchanged
|
||||
documentation snippets with controlled media. They do not certify all 233 examples
|
||||
or the remote SDK endpoints in the full manual page.
|
||||
|
||||
Windows WebKit has the existing DPIP-MEDIA-01 dimension discrepancy: the same
|
||||
320x180 media can report 640x360 layout dimensions. The harness retains exact raw
|
||||
readings and only these documented pairs; it does not close intrinsic-size or
|
||||
real Safari/device acceptance. See `refactor/site-inventory.md` for broader ownership.
|
||||
|
||||
Dependencies: root development-only `@types/node` 24.10.0 and
|
||||
`@types/markdown-it` 14.1.2 provide strict tools typing. Runtime parsing uses the
|
||||
existing MarkdownIt 14.1.0 and browser bundling uses the existing pinned esbuild.
|
||||
No player package runtime dependency changes.
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { DocumentationExample } from './parser.ts'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Script } from 'node:vm'
|
||||
import { build } from 'esbuild'
|
||||
import { extractExamples } from './parser.ts'
|
||||
|
||||
export function collectExamples(directory: string, prefix = ''): DocumentationExample[] {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, 'en')).flatMap((entry) => {
|
||||
if (['en', '.vitepress', 'public', 'plugin'].includes(entry.name))
|
||||
return []
|
||||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name
|
||||
const file = path.join(directory, entry.name)
|
||||
if (entry.isDirectory())
|
||||
return collectExamples(file, relative)
|
||||
if (!entry.name.endsWith('.md'))
|
||||
return []
|
||||
const examples = extractExamples(fs.readFileSync(file, 'utf8'), relative)
|
||||
for (const example of examples) {
|
||||
// eslint-disable-next-line no-new -- Compile syntax without executing documentation code.
|
||||
new Script(example.code, { filename: example.id })
|
||||
}
|
||||
return examples
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateDocumentationSmoke(root: string): Promise<Map<string, string>> {
|
||||
const examples = collectExamples(path.join(root, 'packages/artplayer-vitepress/docs'))
|
||||
const html = fs.readFileSync(path.join(root, 'docs/test/index.html'), 'utf8').replace(/<!--[\s\S]*?-->/g, '')
|
||||
const scripts = [...html.matchAll(/<script\b[^>]+\bsrc=["']([^"']+)["']/g)].map(match => match[1]!).filter(url => !['./chai.js', './mocha.js', './test.js'].includes(url))
|
||||
if (!scripts.some(url => /\/artplayer\.js$/.test(url)))
|
||||
throw new Error('Documentation smoke page must load the core')
|
||||
const bundle = await build({ stdin: { contents: `import { registerDocumentationSmoke } from './runtime.ts';\nregisterDocumentationSmoke(${JSON.stringify(examples)}, ${JSON.stringify(scripts)});`, resolveDir: path.join(root, 'scripts/docs-smoke'), sourcefile: 'documentation-smoke-entry.ts', loader: 'ts' }, bundle: true, write: false, platform: 'browser', format: 'iife', target: 'es2020', legalComments: 'none', logLevel: 'silent' })
|
||||
const script = bundle.outputFiles?.[0]?.text
|
||||
if (!script)
|
||||
throw new Error('Documentation smoke build produced no script')
|
||||
return new Map([
|
||||
['docs/test/test.js', `// Generated by yarn build:test. Readiness smoke only; do not edit.\n${script}`],
|
||||
['docs/test/examples.json', `${JSON.stringify({ scope: 'Chinese Run Code JavaScript; excludes en/plugin/public/.vitepress. Waits for created players to become ready, observes errors until that boundary, then destroys the frame. Not complete feature, delayed-callback or interaction acceptance.', examples, scripts }, null, 2)}\n`],
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
export interface DocumentationExample {
|
||||
id: string
|
||||
file: string
|
||||
line: number
|
||||
code: string
|
||||
}
|
||||
|
||||
const markdown = new MarkdownIt({ html: true })
|
||||
|
||||
export function extractExamples(content: string, file: string): DocumentationExample[] {
|
||||
const normalized = content.replaceAll('\r\n', '\n')
|
||||
const lines = normalized.split('\n')
|
||||
const tokens = markdown.parse(normalized, {})
|
||||
const examples: DocumentationExample[] = []
|
||||
for (const [index, marker] of tokens.entries()) {
|
||||
if (marker.type !== 'html_block' || !/<div\s+class(?:Name)?="run-code"[\s>]/.test(marker.content))
|
||||
continue
|
||||
const line = (marker.map?.[0] ?? 0) + 1
|
||||
const invalid = (message: string) => new Error(`${file}:${line}: ${message}`)
|
||||
const fence = tokens[index + 1]
|
||||
if (!fence || fence.type !== 'fence' || !/^js(?:\{|\s|$)/.test(fence.info))
|
||||
throw invalid('Run Code must be followed by a JavaScript fence')
|
||||
const range = fence.map
|
||||
if (!range)
|
||||
throw invalid('Missing code fence source range')
|
||||
const closing = lines[range[1] - 1]?.trim() || ''
|
||||
if (closing.length < fence.markup.length || [...closing].some(character => character !== fence.markup[0]))
|
||||
throw invalid('Unclosed Run Code fence')
|
||||
const code = fence.content.trim()
|
||||
if (!code)
|
||||
throw invalid('Empty Run Code example')
|
||||
examples.push({ id: `${file}:${range[0] + 2}`, file, line: range[0] + 2, code })
|
||||
}
|
||||
return examples
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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<SmokeResult> {
|
||||
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<never>((_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<void> {
|
||||
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<SmokeResult> => {
|
||||
await loaded(
|
||||
iframe,
|
||||
() => {
|
||||
iframe.srcdoc
|
||||
= '<!doctype html><html><body style="margin:0"><div class="artplayer-app" style="width:640px;height:360px"></div></body></html>'
|
||||
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<void>((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<void>((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>) => 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user