mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-16 09:36:16 -08:00
refactor(tooling): [MOD-DEV-01] own dev server lifecycle and report port failures
This commit is contained in:
1 parent
401d4cdd65
commit
772d79a671
24 files changed
+1249
-132
No files matched your search
@@ -5,6 +5,13 @@ scaffold's generated consumers. The new build modules live in `scripts/library/`
|
||||
`yarn typecheck:library` checks them and the retained JS/MJS entrypoints. Browser
|
||||
development/watch/error-recovery coverage is `browser/library-development.spec.js`.
|
||||
|
||||
`yarn test:dev-server` covers actual HTTP ranges/HEAD/gzip, root containment,
|
||||
occupied-port CLI failure without stopping the existing server, repeated same-port
|
||||
reuse with SSE connections, aborted downloads and startup/build cancellation.
|
||||
A controlled-clock test checks heartbeat cleanup separately from real sockets.
|
||||
It is included in `test:node` and `test:library`; the browser suite also verifies
|
||||
actual docs/Monaco TypeScript execution and local media playback through this server.
|
||||
|
||||
`yarn test:scaffold` covers old plugin-generator defects, exclusive writes and
|
||||
rollback, the historical CLI path, and actual generated package builds and type
|
||||
consumers. It runs in ignored fixtures without adding a workspace or real demo;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# Browser regression entry
|
||||
|
||||
`library-development.spec.js` starts the actual typed development runner on an
|
||||
isolated fixture port. It checks generated TS/Less/SVG/inline-worker assets,
|
||||
`library-development.spec.js` starts the actual JS dev CLI and typed runner on an
|
||||
atomically assigned fixture port. It checks generated TS/Less/SVG/inline-worker assets,
|
||||
automatic reload on edits and recovery after a compiler error in all three
|
||||
engines. It owns and stops only its child server, never the user's port 8082
|
||||
session. This is development-tool validation, not media playback acceptance.
|
||||
session. SIGTERM must settle the child naturally with exit0. Its second case serves
|
||||
actual docs/Monaco, executes TypeScript and plays/pauses/seeks a local MP4, checking
|
||||
a decoded canvas frame. The attachment records browser/version, OS and actual
|
||||
core/media hashes; the tracked core5.4.1 asset is not a new release candidate.
|
||||
Analytics/ads are inert routes; real-device and remote OS checks remain separate.
|
||||
|
||||
`site-loading.spec.js` covers actual desktop/mobile loading, dependency order,
|
||||
failure/retry/AMD restoration, example priority, repeated Run and stale responses,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { 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, pathToFileURL } from 'node:url'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { startDevServer } from '../../scripts/library/server.ts'
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url))
|
||||
const cache = path.join(root, 'refactor/.cache')
|
||||
@@ -15,17 +16,6 @@ function removeFixture(directory) {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = net.createServer()
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, resolve)
|
||||
})
|
||||
const port = server.address().port
|
||||
await new Promise(resolve => server.close(resolve))
|
||||
return port
|
||||
}
|
||||
|
||||
test('typed development builds real assets and workers, watches edits and recovers from compilation errors', async ({ page }, testInfo) => {
|
||||
test.setTimeout(60000)
|
||||
const directory = fs.mkdtempSync(path.join(cache, 'library-development-'))
|
||||
@@ -40,15 +30,14 @@ test('typed development builds real assets and workers, watches edits and recove
|
||||
window.probe = artplayerPluginBuildProbe();
|
||||
document.querySelector('main').textContent = String(probe.value);
|
||||
</script></body></html>`)
|
||||
const port = await freePort()
|
||||
const url = `http://localhost:${port}`
|
||||
const module = pathToFileURL(path.join(root, 'scripts/library/development.ts')).href
|
||||
const code = `import { runDevelop } from ${JSON.stringify(module)};
|
||||
const module = pathToFileURL(path.join(root, 'scripts/dev.js')).href
|
||||
const code = `process.on('message', () => process.emit('SIGTERM'));
|
||||
process.argv = [process.execPath, 'fixture.js', '${name}', '--no-open'];
|
||||
await runDevelop({port:${port}});`
|
||||
await import(${JSON.stringify(module)});
|
||||
process.disconnect();`
|
||||
const launcher = path.join(directory, 'launch.mjs')
|
||||
fs.writeFileSync(launcher, code)
|
||||
const child = spawn(process.execPath, [launcher], { cwd: directory, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const child = spawn(process.execPath, [launcher], { cwd: directory, env: { ...process.env, ARTPLAYER_DEV_PORT: '0' }, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe', 'ipc'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data
|
||||
@@ -60,6 +49,7 @@ await runDevelop({port:${port}});`
|
||||
try {
|
||||
await expect.poll(() => output, { timeout: 20000 }).toContain('Watching')
|
||||
expect(child.exitCode).toBeNull()
|
||||
const url = /Demo: (http:\/\/localhost:\d+)/.exec(output)[1]
|
||||
await page.goto(url)
|
||||
await expect(page.locator('main')).toHaveText('42')
|
||||
expect(await page.evaluate(() => window.probe.css)).toContain('#123abc')
|
||||
@@ -91,11 +81,81 @@ await runDevelop({port:${port}});`
|
||||
fs.writeFileSync(entry, original.replace('add(20, 22)', 'add(20, 24)'))
|
||||
await expect.poll(() => output.slice(failed), { timeout: 15000 }).toContain('Built in')
|
||||
await expect(page.locator('main')).toHaveText('44')
|
||||
const html = path.join(directory, 'docs/index.html')
|
||||
fs.appendFileSync(html, '<title>Docs edit reloaded</title>')
|
||||
await expect(page).toHaveTitle('Docs edit reloaded')
|
||||
await expect(page.locator('main')).toHaveText('44')
|
||||
}
|
||||
finally {
|
||||
await testInfo.attach('development-server.log', { body: output, contentType: 'text/plain' })
|
||||
child.kill()
|
||||
await exited
|
||||
const watchdog = setTimeout(() => child.kill(), 5000)
|
||||
if (child.connected)
|
||||
child.send('close')
|
||||
const exitCode = await exited
|
||||
clearTimeout(watchdog)
|
||||
removeFixture(directory)
|
||||
expect(exitCode, 'Dev CLI must close its resources and exit naturally').toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('actual docs editor runs TypeScript and plays and seeks local media through the owned server', async ({ page, browser }, testInfo) => {
|
||||
test.setTimeout(60000)
|
||||
const errors = []
|
||||
const pageErrors = []
|
||||
const service = await startDevServer({ root: path.join(root, 'docs'), port: 0, onError: error => errors.push(error.message) })
|
||||
page.on('pageerror', error => pageErrors.push(error.message))
|
||||
await page.route('https://**/*', route => route.fulfill({ status: 200, body: '' }))
|
||||
await page.addInitScript((origin) => {
|
||||
if (location.origin === origin)
|
||||
localStorage.setItem('ts', 'true')
|
||||
}, service.url)
|
||||
try {
|
||||
const code = 'const typed: number = 17; window.devServerTyped = typed; var art = new Artplayer({container:".artplayer-app",url:"/assets/sample/video.mp4",muted:true});'
|
||||
await page.goto(`${service.url}/?code=${encodeURIComponent(code)}`)
|
||||
await page.waitForFunction(() => window.art?.isReady && window.devServerTyped === 17)
|
||||
await page.evaluate(() => window.art.play())
|
||||
await page.waitForFunction(() => window.art.currentTime > 0.1)
|
||||
const target = await page.evaluate(() => {
|
||||
window.art.pause()
|
||||
const target = Math.min(2, window.art.duration / 2)
|
||||
window.art.seek = target
|
||||
return target
|
||||
})
|
||||
await page.waitForFunction(target => Math.abs(window.art.currentTime - target) < 0.3 && !window.art.video.seeking, target)
|
||||
const frameAlpha = await page.evaluate(() => {
|
||||
const video = window.art.video
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
const context = canvas.getContext('2d')
|
||||
context.drawImage(video, 0, 0)
|
||||
return context.getImageData(0, 0, 1, 1).data[3]
|
||||
})
|
||||
expect(frameAlpha).toBe(255)
|
||||
const state = await page.evaluate(() => ({
|
||||
version: window.Artplayer.version,
|
||||
typed: window.devServerTyped,
|
||||
currentTime: window.art.currentTime,
|
||||
paused: window.art.video.paused,
|
||||
width: window.art.video.videoWidth,
|
||||
height: window.art.video.videoHeight,
|
||||
error: window.art.video.error?.code || 0,
|
||||
editorModels: window.monaco.editor.getModels().length,
|
||||
}))
|
||||
expect(state.typed).toBe(17)
|
||||
expect(state.paused).toBe(true)
|
||||
expect(state.width).toBeGreaterThan(0)
|
||||
expect(state.height).toBeGreaterThan(0)
|
||||
expect(state.error).toBe(0)
|
||||
expect(state.editorModels).toBeGreaterThan(1)
|
||||
const inputs = ['docs/uncompiled/artplayer/index.js', 'docs/assets/sample/video.mp4'].map(file => ({ file, sha256: createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') }))
|
||||
await testInfo.attach('actual-docs-server.json', { body: JSON.stringify({ browser: browser.version(), platform: process.platform, inputs, frameAlpha, state }, null, 2), contentType: 'application/json' })
|
||||
await page.screenshot({ path: testInfo.outputPath('actual-docs.png') })
|
||||
await page.goto('about:blank')
|
||||
expect(pageErrors).toEqual([])
|
||||
}
|
||||
finally {
|
||||
await service.close()
|
||||
expect(errors).toEqual([])
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { EventEmitter, once } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Verify real HTTP, shutdown and startup failure ownership.
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
import { startDevelopment } from '../scripts/library/development.ts'
|
||||
import { createReloadChannel } from '../scripts/library/reload.ts'
|
||||
import { startDevServer } from '../scripts/library/server.ts'
|
||||
|
||||
const repository = fileURLToPath(new URL('../', import.meta.url))
|
||||
const cache = path.join(repository, 'refactor/.cache')
|
||||
const cleanup = new WeakMap()
|
||||
|
||||
function fixture(t) {
|
||||
cleanup.set(t, [])
|
||||
const root = fs.mkdtempSync(path.join(cache, 'dev-server-'))
|
||||
fs.mkdirSync(path.join(root, 'docs'))
|
||||
fs.writeFileSync(path.join(root, 'docs/index.html'), '<!doctype html><title>Home</title>首页')
|
||||
const project = path.join(root, 'packages/artplayer-plugin-probe')
|
||||
fs.mkdirSync(path.join(project, 'src'), { recursive: true })
|
||||
fs.writeFileSync(path.join(project, 'package.json'), '{"name":"artplayer-plugin-probe","version":"1.0.0"}')
|
||||
fs.writeFileSync(path.join(project, 'src/index.ts'), 'export default () => 42')
|
||||
t.after(async () => {
|
||||
for (const close of cleanup.get(t).reverse())
|
||||
await close()
|
||||
assert.equal(fs.realpathSync(root), root)
|
||||
assert.equal(path.dirname(root), fs.realpathSync(cache))
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
return { root, docs: path.join(root, 'docs'), project }
|
||||
}
|
||||
|
||||
function request(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(url, options, (res) => {
|
||||
const chunks = []
|
||||
res.on('data', chunk => chunks.push(chunk))
|
||||
res.on('error', reject)
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }))
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
async function server(t, docs) {
|
||||
const failures = []
|
||||
const service = await startDevServer({ root: docs, port: 0, onError: error => failures.push(error) })
|
||||
cleanup.get(t).push(async () => {
|
||||
await service.close()
|
||||
assert.deepEqual(failures, [])
|
||||
})
|
||||
return service
|
||||
}
|
||||
|
||||
test('static pages, fallback, directory indexes, gzip negotiation and HEAD preserve actual content', async (t) => {
|
||||
const { docs } = fixture(t)
|
||||
fs.writeFileSync(path.join(docs, 'module.js'), 'window.label = "中文";')
|
||||
fs.writeFileSync(path.join(docs, 'empty.css'), '')
|
||||
fs.mkdirSync(path.join(docs, 'document'))
|
||||
fs.writeFileSync(path.join(docs, 'document/index.html'), '<h1>Docs</h1>')
|
||||
const { url } = await server(t, docs)
|
||||
const home = await request(url, { headers: { 'Accept-Encoding': 'gzip' } })
|
||||
assert.equal(home.status, 200)
|
||||
assert.equal(home.headers['content-encoding'], 'gzip')
|
||||
assert.match(gunzipSync(home.body).toString(), /首页/)
|
||||
assert.match(gunzipSync(home.body).toString(), /EventSource\('\/livereload'\)/)
|
||||
const identity = await request(`${url}/module.js`, { headers: { 'Accept-Encoding': 'gzip;q=0, *;q=1' } })
|
||||
assert.equal(identity.headers['content-encoding'], undefined)
|
||||
assert.equal(identity.body.toString(), 'window.label = "中文";')
|
||||
const zipped = await request(`${url}/module.js`, { headers: { 'Accept-Encoding': 'gzip' } })
|
||||
assert.equal(gunzipSync(zipped.body).toString(), identity.body.toString())
|
||||
const head = await request(`${url}/module.js`, { method: 'HEAD' })
|
||||
assert.equal(Number(head.headers['content-length']), identity.body.length)
|
||||
assert.equal(head.body.length, 0)
|
||||
const empty = await request(`${url}/empty.css`, { headers: { 'Accept-Encoding': 'gzip' } })
|
||||
assert.equal(empty.body.length, 0)
|
||||
assert.equal(empty.headers['content-encoding'], undefined)
|
||||
assert.equal((await request(`${url}/document?language=en`)).headers.location, '/document/?language=en')
|
||||
assert.match((await request(`${url}/document/`)).body.toString(), /<h1>Docs/)
|
||||
assert.match((await request(`${url}/unknown-route`)).body.toString(), /首页/)
|
||||
assert.equal((await request(`${url}/missing.js`)).status, 404)
|
||||
assert.equal((await request(url, { method: 'POST' })).status, 405)
|
||||
assert.equal((await request(url, { method: 'OPTIONS' })).headers['access-control-allow-origin'], '*')
|
||||
})
|
||||
|
||||
test('media ranges support exact zero, bounded, suffix, open-ended and unsatisfiable requests', async (t) => {
|
||||
const { docs } = fixture(t)
|
||||
fs.writeFileSync(path.join(docs, 'media.mp4'), '0123456789')
|
||||
const { url } = await server(t, docs)
|
||||
for (const [range, body, contentRange] of [
|
||||
['bytes=0-0', '0', 'bytes 0-0/10'],
|
||||
['bytes=2-5', '2345', 'bytes 2-5/10'],
|
||||
['bytes=7-999', '789', 'bytes 7-9/10'],
|
||||
['bytes=-3', '789', 'bytes 7-9/10'],
|
||||
['bytes=8-', '89', 'bytes 8-9/10'],
|
||||
]) {
|
||||
const response = await request(`${url}/media.mp4`, { headers: { 'Range': range, 'Accept-Encoding': 'gzip' } })
|
||||
assert.equal(response.status, 206)
|
||||
assert.equal(response.body.toString(), body)
|
||||
assert.equal(response.headers['content-range'], contentRange)
|
||||
assert.equal(response.headers['content-encoding'], undefined)
|
||||
}
|
||||
for (const range of ['bytes=10-', 'bytes=5-2', 'bytes=-0', 'bytes=1-2,5-7', 'bytes=-', 'invalid']) {
|
||||
const response = await request(`${url}/media.mp4`, { headers: { Range: range } })
|
||||
assert.equal(response.status, 416)
|
||||
assert.equal(response.headers['content-range'], 'bytes */10')
|
||||
}
|
||||
})
|
||||
|
||||
test('malformed paths and symlink escapes never serve files outside the docs root', async (t) => {
|
||||
const { root, docs } = fixture(t)
|
||||
fs.mkdirSync(path.join(root, 'private'))
|
||||
fs.writeFileSync(path.join(root, 'private/secret.txt'), 'private sentinel')
|
||||
fs.symlinkSync(path.join(root, 'private'), path.join(docs, 'outside'), 'junction')
|
||||
const { url } = await server(t, docs)
|
||||
assert.equal((await request(`${url}/outside/secret.txt`)).status, 403)
|
||||
assert.equal((await request(`${url}/%2e%2e%2fprivate/secret.txt`)).status, 403)
|
||||
assert.equal((await request(`${url}/%E0%A4%A`)).status, 400)
|
||||
fs.unlinkSync(path.join(docs, 'outside'))
|
||||
})
|
||||
|
||||
test('an occupied port rejects and the real CLI exits nonzero without stopping the existing service', async (t) => {
|
||||
const { root, docs } = fixture(t)
|
||||
const service = await server(t, docs)
|
||||
const port = Number(new URL(service.url).port)
|
||||
await assert.rejects(startDevServer({ root: docs, port, onError() {} }), { code: 'EADDRINUSE' })
|
||||
const result = spawnSync(process.execPath, [path.join(repository, 'scripts/dev.js'), 'artplayer-plugin-probe', '--no-open'], { cwd: root, env: { ...process.env, ARTPLAYER_DEV_PORT: String(port) }, encoding: 'utf8', timeout: 10000 })
|
||||
assert.equal(result.status, 1, result.stderr)
|
||||
assert.match(result.stderr, /EADDRINUSE/)
|
||||
assert.equal((await request(service.url)).status, 200)
|
||||
const invalid = spawnSync(process.execPath, [path.join(repository, 'scripts/dev.js'), '--help'], { cwd: root, env: { ...process.env, ARTPLAYER_DEV_PORT: 'invalid' }, encoding: 'utf8', timeout: 10000 })
|
||||
assert.equal(invalid.status, 1)
|
||||
assert.match(invalid.stderr, /ARTPLAYER_DEV_PORT/)
|
||||
})
|
||||
|
||||
test('SSE clients and HTTP sockets close before the same port is reused repeatedly', async (t) => {
|
||||
const { docs } = fixture(t)
|
||||
let port = 0
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const service = await startDevServer({ root: docs, port, onError: error => assert.fail(error) })
|
||||
cleanup.get(t).push(() => service.close())
|
||||
port = Number(new URL(service.url).port)
|
||||
const client = http.get(`${service.url}/livereload`)
|
||||
const [response] = await once(client, 'response')
|
||||
response.on('error', () => {})
|
||||
const [connected] = await once(response, 'data')
|
||||
assert.match(connected.toString(), /event: connected/)
|
||||
service.reload()
|
||||
const [reload] = await once(response, 'data')
|
||||
assert.match(reload.toString(), /data: reload/)
|
||||
const disconnected = new Promise(resolve => response.once('close', resolve))
|
||||
const close = service.close()
|
||||
assert.equal(service.close(), close)
|
||||
await Promise.all([close, disconnected])
|
||||
}
|
||||
})
|
||||
|
||||
test('disconnecting a large download does not retain a stream or block shutdown', async (t) => {
|
||||
const { docs } = fixture(t)
|
||||
const large = path.join(docs, 'large.bin')
|
||||
fs.closeSync(fs.openSync(large, 'w'))
|
||||
fs.truncateSync(large, 16 * 1024 * 1024)
|
||||
const service = await server(t, docs)
|
||||
const client = http.get(`${service.url}/large.bin`)
|
||||
const [response] = await once(client, 'response')
|
||||
response.on('error', () => {})
|
||||
await once(response, 'data')
|
||||
client.destroy()
|
||||
await service.close()
|
||||
fs.unlinkSync(large)
|
||||
})
|
||||
|
||||
test('development close settles a running build and abort during startup cannot leave a listening server', async (t) => {
|
||||
const { root, project } = fixture(t)
|
||||
const session = await startDevelopment(project, 'artplayer-plugin-probe', false, { root, port: 0 })
|
||||
await session.close()
|
||||
await session.done
|
||||
await assert.rejects(request(session.url), { code: 'ECONNREFUSED' })
|
||||
const abort = new AbortController()
|
||||
const starting = startDevelopment(project, 'artplayer-plugin-probe', false, { root, port: 0, signal: abort.signal })
|
||||
abort.abort()
|
||||
const aborted = await starting
|
||||
await aborted.done
|
||||
await assert.rejects(request(aborted.url), { code: 'ECONNREFUSED' })
|
||||
await assert.rejects(startDevelopment(path.join(root, 'missing'), 'missing', false, { root, port: 0 }), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('reload heartbeats share one clock and stop after disconnection or channel shutdown', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] })
|
||||
class Response extends EventEmitter {
|
||||
writes = []
|
||||
writeHead(status) {
|
||||
this.status = status
|
||||
return this
|
||||
}
|
||||
|
||||
write(value) {
|
||||
this.writes.push(value)
|
||||
}
|
||||
|
||||
end() {
|
||||
this.emit('close')
|
||||
}
|
||||
}
|
||||
const channel = createReloadChannel()
|
||||
const first = new Response()
|
||||
const second = new Response()
|
||||
channel.add(first)
|
||||
channel.add(second)
|
||||
t.mock.timers.tick(30000)
|
||||
assert.equal(first.writes.length, 2)
|
||||
assert.equal(second.writes.length, 2)
|
||||
first.emit('close')
|
||||
t.mock.timers.tick(30000)
|
||||
assert.equal(first.writes.length, 2)
|
||||
assert.equal(second.writes.length, 3)
|
||||
channel.close()
|
||||
t.mock.timers.tick(90000)
|
||||
channel.reload()
|
||||
assert.equal(second.writes.length, 3)
|
||||
const late = new Response()
|
||||
channel.add(late)
|
||||
assert.equal(late.status, 503)
|
||||
assert.deepEqual(late.writes, [])
|
||||
})
|
||||
Reference in new issue
Block a user