mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
266 lines
14 KiB
JavaScript
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))
|
|
}
|