mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-16 17:46:17 -08:00
refactor(docs): [SITE-03] migrate desktop editor and integrate typed generation
This commit is contained in:
1 parent
a468cb2c39
commit
2f130c58f1
37 files changed
+1921
-305
No files matched your search
@@ -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`.
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user