ci(pages): [CI-02] validate staged artifacts and preserve legacy demo paths

This commit is contained in:
Harvey Zhao committed 2026-09-14 16:43:33 +08:00
1 parent b1e0b976b0
commit 72d2b9c36b
20 files changed
+6120 -13

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
// eslint-disable-next-line test/no-import-node-test -- Exercise real filesystem and workflow rejection paths.
import test from 'node:test'
import YAML from 'yaml'
import { validateCIWorkflow, validatePagesWorkflow } from '../refactor/scripts/ci-workflow.mjs'
import { compareCompiled, inside, inventory, validateArtifact } from '../scripts/pages/artifact.ts'
function fixture(t) {
const root = fs.mkdtempSync(path.resolve('refactor/.cache/pages-test-'))
t.after(() => {
assert.equal(path.dirname(root), path.resolve('refactor/.cache'))
assert(path.basename(root).startsWith('pages-test-'))
fs.rmSync(root, { recursive: true, force: true })
})
fs.writeFileSync(path.join(root, 'CNAME'), 'github.artplayer.org\n')
fs.writeFileSync(path.join(root, '.nojekyll'), '')
fs.writeFileSync(path.join(root, 'index.html'), '<!doctype html><title>Player</title>')
return root
}
const contract = { domain: 'github.artplayer.org', requiredPaths: ['index.html', '.nojekyll', 'CNAME'] }
test('Pages preserves hidden files and rejects missing routes, empty pages and changed domains', (t) => {
const root = fixture(t)
const files = validateArtifact(root, contract)
assert.equal(files['.nojekyll'].bytes, 0)
assert.match(files['index.html'].sha256, /^[a-f0-9]{64}$/)
assert.throws(() => validateArtifact(root, { ...contract, requiredPaths: [...contract.requiredPaths, 'mobile.html'] }), /Missing required/)
fs.writeFileSync(path.join(root, 'CNAME'), 'example.com')
assert.throws(() => validateArtifact(root, contract), /domain changed/)
fs.writeFileSync(path.join(root, 'index.html'), '')
assert.throws(() => validateArtifact(root, contract), /Empty Pages/)
})
test('Pages rejects directory escape, symbolic directories and hard-linked files', (t) => {
const root = fixture(t)
for (const file of ['../outside', '/outside', 'nested/../../outside', 'nested\\outside', '.'])
assert.throws(() => inside(root, file))
fs.linkSync(path.join(root, 'index.html'), path.join(root, 'hard.html'))
assert.throws(() => inventory(root), /hard links/)
fs.unlinkSync(path.join(root, 'hard.html'))
const directory = path.join(root, 'assets')
fs.mkdirSync(directory)
fs.symlinkSync(directory, path.join(root, 'redirect'), 'junction')
assert.throws(() => inventory(root), /symbolic links/)
})
test('Pages cannot upload stale compiled output or omit one distribution format', (t) => {
const root = fixture(t)
fs.mkdirSync(path.join(root, 'packages/artplayer/dist'), { recursive: true })
fs.mkdirSync(path.join(root, 'compiled'))
for (const suffix of ['.js', '.legacy.js', '.mjs']) {
fs.writeFileSync(path.join(root, `packages/artplayer/dist/artplayer${suffix}`), suffix)
fs.writeFileSync(path.join(root, `compiled/artplayer${suffix}`), suffix)
}
assert.equal(compareCompiled(root, root, ['artplayer']).length, 3)
fs.writeFileSync(path.join(root, 'compiled/artplayer.js'), 'old')
assert.throws(() => compareCompiled(root, root, ['artplayer']), /Stale compiled/)
fs.unlinkSync(path.join(root, 'compiled/artplayer.js'))
assert.throws(() => compareCompiled(root, root, ['artplayer']), /ENOENT/)
})
test('Pages deployment guard rejects untrusted, bypassed and rebuilding deployments', () => {
const source = fs.readFileSync('.github/workflows/pages.yml', 'utf8')
validatePagesWorkflow(source)
for (const mutate of [
w => w.on.push = {},
w => w.jobs.validate.if = 'true',
w => w.jobs.deploy.needs = [],
w => w.jobs.deploy.permissions.contents = 'write',
w => w.jobs.deploy.steps[0].with = { 'artifact-name': 'arbitrary' },
w => w.jobs.deploy.steps.push({ run: 'yarn build' }),
w => w.concurrency['cancel-in-progress'] = true,
]) {
const workflow = YAML.parse(source)
mutate(workflow)
assert.throws(() => validatePagesWorkflow(YAML.stringify(workflow)))
}
})
test('Pages upload cannot bypass preflight or upload the working directory', () => {
const source = fs.readFileSync('.github/workflows/nodejs.yml', 'utf8')
for (const mutate of [
w => w.jobs.checks.steps.find(s => s.id === 'pages').if = 'false',
w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/upload-pages-artifact@')).with.path = 'docs',
w => w.jobs.checks.steps.find(s => s.id === 'pages').run = 'echo passed',
w => w.jobs.checks.steps.find(s => s.run?.startsWith('yarn test:pages:browser')).if = 'false',
]) {
const workflow = YAML.parse(source)
mutate(workflow)
assert.throws(() => validateCIWorkflow(YAML.stringify(workflow)))
}
})
+92
View File
@@ -0,0 +1,92 @@
/* eslint-disable antfu/no-top-level-await -- Sequential standalone browser command; always closes its own server and browsers. */
import assert from 'node:assert/strict'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import process from 'node:process'
import { chromium, firefox, webkit } from 'playwright'
import { serveAsset } from '../scripts/library/assets.ts'
import { digest } from '../scripts/pages/artifact.ts'
const { output } = JSON.parse(fs.readFileSync('refactor/.cache/pages/latest.json', 'utf8'))
assert.equal(path.dirname(path.resolve(output)), path.resolve('refactor/.cache/pages'))
assert(path.basename(output).startsWith('run-'))
const prepared = JSON.parse(fs.readFileSync(path.join(output, 'report.json'), 'utf8'))
assert.equal(prepared.status, 'validated')
const site = path.join(output, 'site')
const reports = []
const failures = []
const server = http.createServer((request, response) => {
const pathname = new URL(request.url, 'http://localhost').pathname
const media = pathname === '/__media.mp4'
serveAsset(media ? path.resolve('test/browser/media') : site, media ? '/pattern.mp4' : pathname, request, response).catch((error) => {
failures.push(String(error))
response.destroy()
})
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const serverUrl = `http://127.0.0.1:${server.address().port}`
try {
for (const [engine, launcher] of Object.entries({ chromium, firefox, webkit })) {
const browser = await launcher.launch()
try {
for (const format of ['demo', 'module']) {
const page = await browser.newPage()
page.setDefaultTimeout(10000)
page.setDefaultNavigationTimeout(10000)
const errors = []
const network = []
page.on('pageerror', error => errors.push(String(error)))
page.on('requestfailed', request => network.push({ url: request.url(), type: request.resourceType(), error: request.failure()?.errorText }))
process.stdout.write(`Checking ${engine}/${format}\n`)
const core = format === 'demo' ? 'uncompiled/artplayer/index.js' : 'compiled/artplayer.mjs'
const chapter = format === 'demo' ? 'uncompiled/artplayer-plugin-chapter/index.js' : 'compiled/artplayer-plugin-chapter.mjs'
for (const file of [core, chapter]) assert.equal(digest(path.join(site, file)).sha256, prepared.files[file].sha256)
const setup = `window.art = new window.Artplayer({ container: '.player', url: '/__media.mp4', muted: true, fullscreenWeb: true, plugins: [window.artplayerPluginChapter({chapters:[{start:0,end:Infinity,title:'Pages staging'}]})] }); document.querySelector('button').onclick = () => art.play();`
const scripts = format === 'demo'
? `<script src="/${core}"></script><script src="/${chapter}"></script><script>${setup}</script>`
: `<script type="module">import Artplayer from '/${core}'; import chapter from '/${chapter}'; window.Artplayer=Artplayer; window.artplayerPluginChapter=chapter; ${setup}</script>`
await page.route('**/__pages_probe.html', route => route.fulfill({ contentType: 'text/html', body: `<!doctype html><button>Play</button><div class="player" style="width:640px;height:360px"></div>${scripts}` }))
try {
await page.goto(`${serverUrl}/__pages_probe.html`)
await page.waitForFunction(() => window.art?.isReady)
await page.locator('button').click()
await page.waitForFunction(() => window.art.playing && window.art.currentTime > 0.2)
assert.equal(await page.locator('.art-chapter').count(), 1)
await page.evaluate(() => {
window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: 0, end: Infinity, title: 'Updated' }] })
window.art.fullscreenWeb = true
})
await page.waitForFunction(() => window.art.fullscreenWeb)
assert.equal(await page.locator('.art-chapter').getAttribute('data-title'), 'Updated')
const state = await page.evaluate(() => ({ time: window.art.currentTime, width: window.art.video.videoWidth, readyState: window.art.video.readyState, mediaError: window.art.video.error?.code ?? null }))
assert(state.width > 0)
assert.equal(state.mediaError, null)
await page.evaluate(() => window.art.destroy())
assert.equal(await page.locator('.art-video-player').count(), 0)
assert.deepEqual(errors, [])
assert.deepEqual(network.filter(entry => entry.type === 'script'), [])
reports.push({ engine, version: browser.version(), platform: process.platform, format, passed: true, state, network, inputs: { core: prepared.files[core], chapter: prepared.files[chapter] } })
}
catch (error) {
process.stderr.write(`${engine}/${format}: ${String(error)}; ${JSON.stringify(errors)}\n`)
reports.push({ engine, version: browser.version(), format, passed: false, error: String(error), errors, network })
failures.push(`${engine}/${format}: ${String(error)}`)
}
finally {
await page.close()
}
}
}
finally {
await browser.close()
}
}
}
finally {
server.closeAllConnections()
await new Promise(resolve => server.close(resolve))
fs.writeFileSync(path.join(output, 'browser.json'), `${JSON.stringify({ prepared: digest(path.join(output, 'report.json')), reports, failures, limitation: 'Controlled probe page and fixed native MP4; scripts served from exact staged directory. Not full live editor, external SDK, physical device or remote Pages verification.' }, null, 2)}\n`)
}
assert.deepEqual(failures, [])
process.stdout.write(`Staged Pages browser checks passed: ${reports.length}. Evidence: ${output}/browser.json\n`)