refactor(docs): [SITE-SMOKE-01] verify example readiness and cleanup

This commit is contained in:
Harvey Zhao committed 2026-09-14 07:02:49 +08:00
1 parent d74ecc43cc
commit 1a5440a67f
25 files changed
+3510 -4770

No files matched your search

+10 -134
View File
@@ -1,137 +1,13 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { generateDocumentationSmoke } from './docs-smoke/generator.ts'
// 使用中文文档作为源(中文是原始文档,英文是 AI 翻译的)
const docsDir = path.resolve('packages/artplayer-vitepress/docs')
const outputFile = path.resolve('docs/test/test.js')
// 排除的目录
const excludeDirs = ['en', '.vitepress', 'public', 'plugin']
// Regex to match code blocks after "Run Code" markers
// Match until we find a closing ``` that's on its own line
const runCodePattern = /<div className="run-code">.*?<\/div>[\t\v\f\r \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]*\n\s*```js[^\n]*\n([\s\S]*?)\n```(?=\s*\n|$)/g
function extractCodeBlocks(content, filePath) {
const blocks = []
let match = runCodePattern.exec(content)
while (match !== null) {
const code = match[1].trim()
// Skip if code contains markdown syntax (unclosed code block issue)
if (code.includes('\n## ') || code.includes('\n:::') || code.includes('<div className="run-code">')) {
console.warn(`⚠️ Skipping malformed code block in ${filePath}`)
continue
}
blocks.push({
code,
file: filePath,
})
match = runCodePattern.exec(content)
}
return blocks
assert(process.argv.slice(2).every(arg => arg === '--check'), 'Use yarn build:test [--check]')
const outputs = await generateDocumentationSmoke(process.cwd())
for (const [file, content] of outputs) {
if (process.argv.includes('--check'))
assert.equal(fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n'), content, `Generated documentation smoke drift: ${file}`)
else fs.writeFileSync(file, content)
}
function processMarkdownFiles(dir, relativePath = '') {
const results = []
const items = fs.readdirSync(dir)
for (const item of items) {
// 跳过排除的目录
if (excludeDirs.includes(item)) {
continue
}
const fullPath = path.join(dir, item)
const relPath = path.join(relativePath, item)
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
results.push(...processMarkdownFiles(fullPath, relPath))
}
else if (item.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8')
const blocks = extractCodeBlocks(content, relPath)
if (blocks.length > 0) {
results.push({
file: relPath,
blocks,
})
}
}
}
return results
}
function generateTestCode(fileResults) {
const lines = []
lines.push(`// Auto-generated smoke tests from documentation code blocks`)
lines.push(`// Generated at: ${new Date().toISOString()}`)
lines.push(`// Do not edit manually - run 'node scripts/build-test.js' to regenerate`)
lines.push(``)
lines.push(`describe('Documentation Code Examples', function () {`)
lines.push(` this.timeout(5000);`)
lines.push(``)
lines.push(` beforeEach(function () {`)
lines.push(` // Destroy any existing instances`)
lines.push(` [...Artplayer.instances].forEach((art) => art.destroy());`)
lines.push(` // Reset container`)
lines.push(` const container = document.querySelector('.artplayer-app');`)
lines.push(` if (container) {`)
lines.push(` container.innerHTML = '';`)
lines.push(` }`)
lines.push(` });`)
lines.push(``)
lines.push(` afterEach(function () {`)
lines.push(` [...Artplayer.instances].forEach((art) => art.destroy());`)
lines.push(` });`)
lines.push(``)
for (const { file, blocks } of fileResults) {
const suiteName = file.replace(/\.md$/, '').replace(/\//g, ' > ')
lines.push(` describe('${suiteName}', function () {`)
blocks.forEach((block, index) => {
const testName = `Example ${index + 1}`
lines.push(` it('${testName}', function (done) {`)
lines.push(` try {`)
lines.push(` ${block.code.split('\n').join('\n ')}`)
lines.push(` // Wait a bit for async initialization`)
lines.push(` setTimeout(() => done(), 100);`)
lines.push(` } catch (err) {`)
lines.push(` done(err);`)
lines.push(` }`)
lines.push(` });`)
lines.push(``)
})
lines.push(` });`)
lines.push(``)
}
lines.push(`});`)
return lines.join('\n')
}
// Main execution
const fileResults = processMarkdownFiles(docsDir)
let totalBlocks = 0
for (const { file, blocks } of fileResults) {
console.log(`📄 ${file}: ${blocks.length} code blocks`)
totalBlocks += blocks.length
}
const testCode = generateTestCode(fileResults)
fs.writeFileSync(outputFile, testCode)
console.log(`\n✅ Generated ${totalBlocks} test cases from ${fileResults.length} files`)
console.log(`📝 Output: ${outputFile}`)
console.log(`Documentation readiness smoke ${process.argv.includes('--check') ? 'checked' : 'generated'}: ${outputs.size} outputs; complete demo acceptance remains separate`)
+75
View File
@@ -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.
+41
View File
@@ -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`],
])
}
+37
View File
@@ -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
}
+223
View File
@@ -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)
})
}
})
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"types": ["node"],
"allowImportingTsExtensions": true,
"checkJs": true
},
"include": ["docs-smoke/**/*.ts", "build-test.js"]
}