refactor(docs): [SITE-03] migrate desktop editor and integrate typed generation

This commit is contained in:
Harvey Zhao committed 2026-09-14 09:25:46 +08:00
1 parent a468cb2c39
commit 2f130c58f1
37 files changed
+1921 -305

No files matched your search

+9
View File
@@ -168,3 +168,12 @@ environment used to locate the pinned Yarn executable. The Markdown test uses
the installed VitePress renderer to reproduce random code-group IDs and verify
stable IDs, uniqueness and label associations. Built-page browser checks live in
`test/browser/document-site.spec.js`; their editor destination is intercepted.
## Desktop editor regressions
`yarn test:site-editor` covers frozen old TS execution/file error behavior, latest-run
ordering, library/compiler failures, FileReader settlement and storage denial.
`test/browser/site-editor.spec.js` uses the actual Monaco 0.30.1 worker and original
vendor assets for typed Run, Ctrl-S, imports, preferences, declaration failure,
language readiness and model disposal. Console Error objects are inspected through
their arguments: Firefox may serialize their display text as only `Error`.
+1 -1
View File
@@ -3,7 +3,7 @@ import { hash } from '../../refactor/scripts/releases.mjs'
import { expect, test } from './fixtures.js'
test('Monaco checks all editor declarations together and runs the Chapter consumer', async ({ page }, testInfo) => {
const common = fs.readFileSync('docs/assets/js/common.js', 'utf8')
const common = fs.readFileSync('packages/artplayer-vitepress/browser/editor-libraries.ts', 'utf8')
const list = common.match(/(?:let|const) libUris = \[([\s\S]*?)\]/)?.[1] || ''
const names = [...list.matchAll(/'\.\/assets\/ts\/([^']+\.d\.ts)'/g)].map(match => match[1])
expect(names).toHaveLength(22)
+247
View File
@@ -0,0 +1,247 @@
import { Buffer } from 'node:buffer'
import { expect, test } from './fixtures.js'
const player
= 'var art = new Artplayer({ container: ".artplayer-app", url: "/test/pattern.mp4", muted: true });'
async function setup(page) {
await page.route('https://**/*', route =>
route.fulfill({ status: 200, body: '' }))
}
function waitError(page, expected) {
return page.waitForEvent(
'console',
async message =>
message.type() === 'error'
&& message.args().length > 0
&& (
await message
.args()[0]
.evaluate(value =>
value instanceof Error ? value.message : String(value),
)
).includes(expected),
)
}
test('desktop TypeScript mode emits and runs typed code, Ctrl-S reruns and invalid syntax keeps the current player', async ({
page,
}) => {
await setup(page)
await page.addInitScript(() => localStorage.setItem('ts', 'true'))
await page.goto(
`/?code=${encodeURIComponent(`const count: number = 7; window.typedCount = count; ${player}`)}`,
)
await page.waitForFunction(
() => window.art?.isReady && window.typedCount === 7,
)
expect(
await page.evaluate(() => window.monaco.editor.getModels().length),
).toBe(23)
const first = await page.evaluate(() => {
window.previous = window.art
return window.art.id
})
await page.evaluate(() =>
window.monaco.editor
.getModels()
.find(model => !model.uri.path.endsWith('.d.ts'))
.setValue(
'const next: number = 8; window.typedCount = next; var art = new Artplayer({container:".artplayer-app",url:"/test/pattern.mp4",muted:true});',
),
)
await page.keyboard.press('Control+s')
await page.waitForFunction(
id => window.art?.isReady && window.art.id !== id,
first,
)
expect(
await page.evaluate(() => ({
count: window.typedCount,
destroyed: window.previous.isDestroy,
models: window.monaco.editor.getModels().length,
})),
).toEqual({ count: 8, destroyed: true, models: 23 })
await page.evaluate(() => {
window.previous = window.art
window.monaco.editor
.getModels()
.find(model => !model.uri.path.endsWith('.d.ts'))
.setValue('const invalid: = 1')
})
const error = waitError(page, 'TypeScript syntax error')
await page.locator('.run').click()
await error
expect(
await page.evaluate(
() => window.art === window.previous && !window.art.isDestroy,
),
).toBe(true)
await page.evaluate(() =>
window.dispatchEvent(
new PageTransitionEvent('pagehide', { persisted: false }),
),
)
expect(
await page.evaluate(() => window.monaco.editor.getModels().length),
).toBe(0)
})
test('desktop file imports retain order, support CSS and remain usable after Run', async ({
page,
}) => {
await setup(page)
await page.goto(`/?code=${encodeURIComponent(player)}`)
await page.waitForFunction(() => window.art?.isReady)
await page.locator('#file').setInputFiles([
{
name: 'FIRST.JS',
mimeType: 'text/javascript',
buffer: Buffer.from('window.importOrder = [1];'),
},
{
name: 'second.js',
mimeType: 'text/javascript',
buffer: Buffer.from('window.importOrder.push(2);'),
},
{
name: 'custom.css',
mimeType: 'text/css',
buffer: Buffer.from(
'.artplayer-app { --editor-import-marker: imported; }',
),
},
])
await expect(page.locator('.libsInput')).toHaveValue('[custom.css]')
expect(await page.evaluate(() => window.importOrder)).toEqual([1, 2])
expect(
await page
.locator('.artplayer-app')
.evaluate(element =>
getComputedStyle(element)
.getPropertyValue('--editor-import-marker')
.trim(),
),
).toBe('imported')
const before = await page.evaluate(() => window.art.id)
await page.locator('.run').click()
await page.waitForFunction(
id => window.art?.isReady && window.art.id !== id,
before,
)
expect(await page.evaluate(() => window.importOrder)).toEqual([1, 2])
})
test('denied browser storage falls back to a working editor and reports unsaved preferences', async ({
page,
}) => {
await setup(page)
await page.addInitScript(() =>
Object.defineProperty(window, 'localStorage', {
get() {
throw new DOMException('Denied', 'SecurityError')
},
}),
)
await page.goto(`/?code=${encodeURIComponent(player)}`)
await page.waitForFunction(() => window.art?.isReady)
const before = await page.evaluate(() => window.art.id)
const error = waitError(page, 'preference was not saved')
await page.locator('#prod').click()
await error
await expect(page.locator('#prod')).not.toBeChecked()
expect(await page.evaluate(() => window.art.id)).toBe(before)
})
test('stored production and visibility settings reload with their existing keys', async ({
page,
}) => {
await setup(page)
const scripts = []
page.on('request', (request) => {
if (request.resourceType() === 'script')
scripts.push(new URL(request.url()).pathname)
})
await page.addInitScript(() => {
if (!localStorage.getItem('editor-test-init')) {
for (const key of ['prod', 'code', 'log'])
localStorage.setItem(key, 'true')
localStorage.setItem('editor-test-init', 'true')
}
})
await page.goto(`/?code=${encodeURIComponent(player)}`)
await page.waitForFunction(() => window.art?.isReady)
expect(scripts).toContain('/compiled/artplayer.js')
await expect(page.locator('#editor')).toBeHidden()
await expect(page.locator('.console')).toBeHidden()
await page.locator('#code').uncheck()
await expect(page.locator('#editor')).toBeVisible()
await page.waitForFunction(() => window.art?.isReady)
expect(await page.evaluate(() => localStorage.getItem('code'))).toBe('false')
})
test('Monaco language AMD modules finish before example dependencies can suspend define', async ({
page,
}) => {
await setup(page)
let release
let started
const gate = new Promise((resolve) => {
release = resolve
})
const requested = new Promise((resolve) => {
started = resolve
})
let dependencies = 0
await page.route(
'**/vs/basic-languages/typescript/typescript.js',
async (route) => {
const response = await route.fetch()
started()
await gate
await route.fulfill({
response,
body: `window.languageAmdReady = true;\n${await response.text()}`,
})
},
)
await page.route('**/site-editor-dependency.js', (route) => {
dependencies++
return route.fulfill({
contentType: 'text/javascript',
body: 'if (!window.languageAmdReady) throw new Error("Language module was not ready"); window.editorDependencyLoaded = true;',
})
})
await page.goto(
`/?libs=${encodeURIComponent('./site-editor-dependency.js')}&code=${encodeURIComponent(player)}`,
{ waitUntil: 'domcontentloaded' },
)
await requested
await page.locator('.run').dispatchEvent('click')
expect(dependencies).toBe(0)
release()
await page.waitForFunction(
() => window.art?.isReady && window.editorDependencyLoaded,
)
expect(dependencies).toBe(1)
expect(await page.evaluate(() => typeof window.define)).toBe('function')
})
test('failed declaration loading disposes partial models and prevents premature Run', async ({
page,
}) => {
await setup(page)
await page.route('**/assets/ts/artplayer-plugin-chapter.d.ts', route =>
route.fulfill({ status: 404, body: 'Missing declaration' }))
const error = waitError(page, 'Loading editor declaration failed')
await page.goto('/?code=window.prematureRun=true')
await error
await expect(page.locator('.run')).toHaveAttribute('aria-disabled', 'true')
await page.locator('.run').dispatchEvent('click')
expect(
await page.evaluate(() => ({
models: window.monaco.editor.getModels().length,
ran: !!window.prematureRun,
})),
).toEqual({ models: 0, ran: false })
})
+188
View File
@@ -0,0 +1,188 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
// eslint-disable-next-line test/no-import-node-test -- Verify editor failure and race contracts with controlled asynchronous boundaries.
import test from 'node:test'
import vm from 'node:vm'
import ts from 'typescript'
import { readEditorFile } from '../packages/artplayer-vitepress/browser/editor-files.ts'
import {
readPreference,
writePreference,
} from '../packages/artplayer-vitepress/browser/editor-preferences.ts'
import { createEditorSession } from '../packages/artplayer-vitepress/browser/editor-session.ts'
const tick = () => new Promise(resolve => setImmediate(resolve))
function deferred() {
let resolve
const promise = new Promise((done) => {
resolve = done
})
return { promise, resolve }
}
test('old editor executes TS syntax directly and has no file error settlement', async () => {
const old = execFileSync(
'git',
[
'show',
'a468cb2c39e626f7b4f737d0ca60334150deae2b:docs/assets/js/common.js',
],
{ encoding: 'utf8' },
)
const ast = ts.createSourceFile('old.js', old, ts.ScriptTarget.Latest, true)
function find(name) {
let code
function visit(node) {
if (ts.isFunctionDeclaration(node) && node.name.text === name)
code = node.getText(ast)
ts.forEachChild(node, visit)
}
visit(ast)
return code
}
assert.throws(
() =>
vm.runInNewContext(`${find('runCode')};runCode()`, {
Event,
window: { dispatchEvent() {} },
Artplayer: { instances: [] },
editor: { getValue: () => 'const count: number = 7;' },
}),
/Missing initializer|Unexpected token/,
)
let reader
class Reader {
constructor() {
reader = this
}
readAsText() {}
}
const pending = vm.runInNewContext(
`${find('readFile')};readFile({name:'broken.js'})`,
{ FileReader: Reader },
)
let settled = false
pending.then(
() => {
settled = true
},
() => {
settled = true
},
)
assert.equal(reader.onerror, undefined)
await tick()
assert.equal(settled, false)
const html = execFileSync('git', ['show', 'a468cb2c39e626f7b4f737d0ca60334150deae2b:docs/index.html'], { encoding: 'utf8' })
const boot = html.match(/<script>(\s*var artplayer[\s\S]*?)<\/script>/)[1]
assert.throws(() => vm.runInNewContext(boot, {
document: { createElement: () => ({}) },
localStorage: { getItem() { throw new Error('Storage denied') } },
}), /Storage denied/)
})
test('latest editor run wins across delayed example and compiler responses; disposal forbids execution', async () => {
const example = deferred()
const compile = deferred()
const executions = []
const writes = []
const session = createEditorSession({
loadLibraries: async () => {},
exampleSource: () => example.promise,
writeCode: value => writes.push(value),
compile: value =>
value === 'old compile' ? compile.promise : Promise.resolve(value),
execute: code => executions.push(code),
})
const initial = session.run({
example: 'slow',
code: encodeURIComponent('wrong priority'),
})
await tick()
await session.run({ code: encodeURIComponent('new') })
example.resolve('late example')
await initial
assert.deepEqual(writes, ['new'])
const older = session.run({ code: encodeURIComponent('old compile') })
await tick()
await session.run({ code: encodeURIComponent('newest') })
compile.resolve('late compiled')
await older
assert.deepEqual(executions, ['new', 'newest'])
session.dispose()
await session.run({ code: 'never' })
assert.deepEqual(executions, ['new', 'newest'])
})
test('library and compiler failures reject without executing a partial or invalid run', async () => {
for (const where of ['library', 'compile']) {
const session = createEditorSession({
loadLibraries: async () => {
if (where === 'library')
throw new Error('library failed')
},
exampleSource: async () => '',
writeCode() {},
compile: async () => {
throw new Error('compile failed')
},
execute() {
assert.fail('Must not execute')
},
})
await assert.rejects(session.run({ code: 'source' }), /failed/)
}
})
test('file reading settles errors, synchronous failures and cancellation and removes handlers', async () => {
for (const mode of ['error', 'throw', 'abort', 'loaded']) {
const controller = new AbortController()
const reader = {
result: 'source',
error: new Error('unreadable'),
readAsText() {
if (mode === 'throw')
throw new Error('read threw')
if (mode === 'error')
queueMicrotask(() => reader.onerror())
if (mode === 'loaded')
queueMicrotask(() => reader.onload())
},
abort() {
reader.onabort?.()
},
}
const promise = readEditorFile(
{ name: 'input.js' },
controller.signal,
() => reader,
)
if (mode === 'abort')
controller.abort()
if (mode === 'loaded')
assert.equal(await promise, 'source')
else await assert.rejects(promise, /unreadable|threw|cancelled/)
for (const event of ['onload', 'onerror', 'onabort'])
assert.equal(reader[event], null)
}
})
test('preferences retain exact keys and string values and tolerate denied storage', () => {
const values = new Map()
const storage = () => ({
getItem: key => values.get(key),
setItem: (key, value) => values.set(key, value),
})
assert.equal(readPreference('ts', storage), false)
assert.equal(writePreference('ts', true, storage), true)
assert.equal(readPreference('ts', storage), true)
assert.equal(values.get('ts'), 'true')
writePreference('ts', false, storage)
assert.equal(values.get('ts'), 'false')
const denied = () => {
throw new Error('Storage denied')
}
assert.equal(readPreference('prod', denied), false)
assert.equal(writePreference('prod', true, denied), false)
})