Files
ArtPlayer/refactor/scripts/asr-local-demo.mjs

266 lines
14 KiB
JavaScript

import assert from 'node:assert/strict'
import { execFileSync, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium, expect, firefox } from '@playwright/test'
// Windows verification only: owns its Yarn process tree and a separate report directory.
assert.equal(process.platform, 'win32', 'This replay uses Windows taskkill process-tree cleanup')
const root = fileURLToPath(new URL('../../', import.meta.url))
const evidenceRoot = path.join(root, 'refactor/.cache/asr05-editor')
fs.mkdirSync(evidenceRoot, { recursive: true })
const output = fs.mkdtempSync(path.join(evidenceRoot, 'run-'))
const report = { startedAt: new Date().toISOString(), node: process.version, platform: process.platform, output, browsers: [] }
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
const lfHash = file => sha256(fs.readFileSync(path.join(root, file), 'utf8').replace(/\r\n/gu, '\n'))
function sourceSnapshot() {
const directory = path.join(root, 'packages/artplayer-plugin-asr/src')
const files = fs.readdirSync(directory).sort().filter(name => name.endsWith('.ts')).map(name => `packages/artplayer-plugin-asr/src/${name}`)
files.push('scripts/dev.js', 'scripts/utils.js', 'scripts/projects.js', 'scripts/rebuild.js', 'package.json', 'yarn.lock', 'packages/artplayer-plugin-asr/package.json')
return Object.fromEntries(files.map(file => [file, lfHash(file)]))
}
report.head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', windowsHide: true }).trim()
report.sourceAndBuildLfSha256 = sourceSnapshot()
report.coreProd = { file: 'docs/compiled/artplayer.js', sha256: sha256(fs.readFileSync(path.join(root, 'docs/compiled/artplayer.js'))), lfSha256: lfHash('docs/compiled/artplayer.js') }
report.scriptSha256 = sha256(fs.readFileSync(fileURLToPath(import.meta.url)))
report.mediaSha256 = sha256(fs.readFileSync(path.join(root, 'docs/assets/sample/steve-jobs.mp4')))
const pageUrl = 'http://localhost:8082/?libs=./uncompiled/artplayer-plugin-asr/index.js&example=asr.local'
const scriptFiles = {
'/compiled/artplayer.js': 'docs/compiled/artplayer.js',
'/uncompiled/artplayer-plugin-asr/index.js': 'docs/uncompiled/artplayer-plugin-asr/index.js',
'/assets/example/asr.local.js': 'docs/assets/example/asr.local.js',
}
async function verifyFreePort() {
const probe = net.createServer()
await new Promise((resolve, reject) => {
probe.once('error', reject)
probe.listen(8082, resolve)
})
await new Promise((resolve, reject) => probe.close(error => error ? reject(error) : resolve()))
}
async function validate(browserType) {
const result = { engine: browserType.name(), navigations: [], requests: [], requestFailures: [], errors: [], consoleErrors: [], blockedAdvertising: [], responses: [], checks: [] }
report.browsers.push(result)
const browser = await browserType.launch({ headless: true })
result.version = browser.version()
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
await context.tracing.start({ screenshots: true, snapshots: true, sources: true })
const page = await context.newPage()
let phase = 'initial-load'
page.on('framenavigated', (frame) => {
if (frame === page.mainFrame())
result.navigations.push({ url: frame.url(), phase })
})
const responseReads = []
page.on('pageerror', error => result.errors.push(error.message))
page.on('console', (message) => {
if (message.type() === 'error')
result.consoleErrors.push({ text: message.text(), location: message.location() })
})
page.on('request', request => result.requests.push({ url: request.url(), method: request.method(), type: request.resourceType() }))
page.on('requestfailed', request => result.requestFailures.push({ url: request.url(), error: request.failure()?.errorText, phase }))
page.on('websocket', socket => result.requests.push({ url: socket.url(), type: 'websocket' }))
page.on('response', (response) => {
const pathname = new URL(response.url()).pathname
if (!scriptFiles[pathname])
return
responseReads.push((async () => {
const bytes = await response.body()
const disk = fs.readFileSync(path.join(root, scriptFiles[pathname]))
const captured = { url: response.url(), status: response.status(), sha256: sha256(bytes), diskSha256: sha256(disk), bytes: bytes.length }
result.responses.push(captured)
fs.writeFileSync(path.join(output, `${result.engine}-${path.basename(scriptFiles[pathname])}`), bytes)
assert.equal(captured.sha256, captured.diskSha256, `Served file drift: ${pathname}`)
assert.equal(captured.status, 200)
})())
})
await page.route('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js*', async (route) => {
result.blockedAdvertising.push(route.request().url())
await route.abort('blockedbyclient')
})
await context.addInitScript(() => {
localStorage.setItem('prod', 'true')
window.asrContexts = []
const NativeContext = window.AudioContext || window.webkitAudioContext
window.AudioContext = class extends NativeContext {
constructor(options) {
super(options)
window.asrContexts.push(this)
}
}
})
const statistics = page.locator('.art-layer-asr-local-statistics')
const playControl = page.locator('.art-control-playAndPause')
const chunks = () => statistics.textContent().then(text => Number(/Chunks: (\d+)/u.exec(text)?.[1] || 0))
const nonzero = async () => {
await expect.poll(async () => Number(/Current peak: (\d+)/u.exec(await statistics.textContent())?.[1] || 0), { timeout: 30000 }).toBeGreaterThan(0)
await expect(page.locator('.art-layer-asr')).toContainText('Simulated local subtitle: audio chunk')
}
try {
await page.goto(pageUrl)
await expect.poll(() => page.evaluate(() => Boolean(window.art?.isReady && window.art?.plugins.artplayerPluginAsr)), { timeout: 30000 }).toBe(true)
const editor = await page.evaluate(() => ({
models: window.monaco.editor.getModels().filter(model => model.getLanguageId() === 'javascript').map(model => model.getValue()),
coreVersion: window.Artplayer.version,
pluginVersion: window.artplayerPluginAsr.version,
source: window.art.video.currentSrc,
userAgent: navigator.userAgent,
}))
result.editor = editor
assert(editor.models.includes(fs.readFileSync(path.join(root, scriptFiles['/assets/example/asr.local.js']), 'utf8')))
assert.equal(editor.source, 'http://localhost:8082/assets/sample/steve-jobs.mp4')
await playControl.press('Enter')
await nonzero()
phase = 'capture'
result.firstStatistics = await statistics.textContent()
result.checks.push('Actual Monaco example and native playback produce nonzero PCM and simulated subtitle')
await page.screenshot({ path: path.join(output, `${result.engine}-playing.png`), fullPage: true })
await page.locator('.art-video-player').hover()
await page.locator('.art-control-asr-local-stop').click()
await expect(statistics).toContainText('Local capture stopped.')
const stoppedAt = await page.evaluate(() => window.art.currentTime)
await expect.poll(() => page.evaluate(() => window.art.currentTime), { timeout: 10000 }).toBeGreaterThan(stoppedAt + 0.8)
await expect(statistics).toContainText('Local capture stopped.')
assert.equal(await page.evaluate(() => window.art.playing), true)
assert.deepEqual(await page.evaluate(() => window.asrContexts.map(context => context.state)), ['running'])
result.checks.push('Stop ASR settles while native playback advances and capture statistics remain stopped')
await playControl.press('Enter')
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(false)
await playControl.press('Enter')
await nonzero()
const beforePause = await chunks()
await playControl.press('Enter')
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(false)
await page.waitForTimeout(600)
assert.equal(await chunks(), beforePause)
await playControl.press('Enter')
await expect.poll(chunks).toBeGreaterThan(beforePause)
assert.equal(await page.evaluate(() => window.asrContexts.length), 1)
result.checks.push('Pause/play resumes after stop and ordinary pause preserves one native context')
await page.evaluate(() => {
window.asrOldArt = window.art
})
phase = 'run-cleanup'
await page.locator('.run').click()
await expect.poll(() => page.evaluate(() => Boolean(window.art !== window.asrOldArt && window.art?.isReady)), { timeout: 30000 }).toBe(true)
await expect.poll(() => page.evaluate(() => window.asrContexts[0].state)).toBe('closed')
assert.equal(await page.evaluate(() => window.asrOldArt.isDestroy), true)
assert.equal(await page.evaluate(() => window.Artplayer.instances.length), 1)
await expect(page.locator('.art-video-player')).toHaveCount(1)
await playControl.press('Enter')
await nonzero()
phase = 'rerun-capture'
assert.deepEqual(await page.evaluate(() => window.asrContexts.map(context => context.state)), ['closed', 'running'])
result.checks.push('Actual Run Code destroys old player/context, installs one new player, and native sampling resumes')
await page.screenshot({ path: path.join(output, `${result.engine}-rerun.png`), fullPage: true })
phase = 'destroy-cleanup'
await page.evaluate(() => window.art.destroy(true))
await expect.poll(() => page.evaluate(() => window.asrContexts.every(context => context.state === 'closed'))).toBe(true)
assert.equal(await page.evaluate(() => window.Artplayer.instances.length), 0)
result.checks.push('Final destroy closes every observed native AudioContext and empties instance registry')
await Promise.all(responseReads)
for (const pathname of Object.keys(scriptFiles))
assert(result.responses.some(response => new URL(response.url).pathname === pathname), `Missing actual served script: ${pathname}`)
const unexpectedNetwork = result.requests.filter(({ url }) => {
const parsed = new URL(url)
return !['localhost', '127.0.0.1'].includes(parsed.hostname) && !['blob:', 'data:'].includes(parsed.protocol) && !result.blockedAdvertising.includes(url)
})
assert.deepEqual(unexpectedNetwork, [], 'Only local media/code and the explicitly aborted advertising request are permitted')
const mediaUrl = 'http://localhost:8082/assets/sample/steve-jobs.mp4'
result.expectedMediaCancellations = result.requestFailures.filter(({ url, error, phase }) => url === mediaUrl && ['net::ERR_ABORTED', 'NS_BINDING_ABORTED'].includes(error) && ['run-cleanup', 'destroy-cleanup'].includes(phase))
assert.deepEqual(result.requestFailures.filter(failure => !result.blockedAdvertising.includes(failure.url) && !result.expectedMediaCancellations.includes(failure)), [], 'Unexpected failed local requests')
assert.deepEqual(result.consoleErrors.filter(({ text, location }) => {
const chromiumAdvertising = /^Failed to load resource: net::ERR_BLOCKED_BY_CLIENT(?:\.Inspector)?$/u.test(text) && result.blockedAdvertising.includes(location.url)
const firefoxAdvertising = text.includes('Cross-Origin Request Blocked:') && result.blockedAdvertising.some(url => text.includes(url))
return !chromiumAdvertising && !firefoxAdvertising
}), [], 'Console errors must be attributable to the explicitly blocked advertising URL')
assert.deepEqual(result.errors, [])
assert.equal(result.navigations.filter(({ phase }) => phase === 'capture' || phase === 'rerun-capture').length, 0, 'The dev server reloaded the page during sampling')
result.status = 'passed'
}
catch (error) {
result.status = 'failed'
result.error = error.stack
await page.screenshot({ path: path.join(output, `${result.engine}-failure.png`), fullPage: true }).catch(() => {})
throw error
}
finally {
await Promise.allSettled(responseReads)
await context.tracing.stop({ path: path.join(output, `${result.engine}-trace.zip`) })
await browser.close()
}
}
let server
let serverLog = ''
let docsWatcher
report.docsChanges = []
try {
await verifyFreePort()
docsWatcher = fs.watch(path.join(root, 'docs'), { recursive: true }, (event, filename) => report.docsChanges.push({ at: new Date().toISOString(), event, filename }))
const yarn = path.join(root, 'refactor/.cache/toolchains/yarn-1.22.22/package/bin/yarn.js')
server = spawn(process.execPath, [yarn, 'dev', 'artplayer-plugin-asr', '--no-open'], {
cwd: root,
windowsHide: true,
env: { ...process.env, PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH}` },
stdio: ['ignore', 'pipe', 'pipe'],
})
report.serverPid = server.pid
report.serverCommand = [process.execPath, yarn, 'dev', 'artplayer-plugin-asr', '--no-open']
server.stdout.on('data', (data) => {
serverLog += data
})
server.stderr.on('data', (data) => {
serverLog += data
})
await expect.poll(() => {
assert.equal(server.exitCode, null, serverLog)
return serverLog.includes('Watching') && serverLog.includes('Built in')
}, { timeout: 30000 }).toBe(true)
for (const browserType of [chromium, firefox]) {
try {
await validate(browserType)
}
catch (error) {
process.exitCode = 1
console.error(`${browserType.name()}: ${error.message}`)
}
}
assert.deepEqual(sourceSnapshot(), report.sourceAndBuildLfSha256, 'ASR source/build configuration changed during validation; rerun for a stable candidate')
}
catch (error) {
report.error = error.stack
process.exitCode = 1
}
finally {
docsWatcher?.close()
if (server?.pid && server.exitCode === null) {
report.serverCleanupExitCode = await new Promise((resolve) => {
const kill = spawn('taskkill', ['/PID', String(server.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
kill.once('exit', resolve)
})
try {
assert.equal(report.serverCleanupExitCode, 0, 'Owned Yarn process tree cleanup failed')
await verifyFreePort()
report.port8082Released = true
}
catch (error) {
report.cleanupError = error.stack
process.exitCode = 1
}
}
report.finishedAt = new Date().toISOString()
fs.writeFileSync(path.join(output, 'server.log'), serverLog)
fs.writeFileSync(path.join(output, 'report.json'), `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify({ output, browsers: report.browsers.map(({ engine, status }) => ({ engine, status })), error: report.error }, null, 2))
}