mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-15 00:56:16 -08:00
refactor(build): [SITE-BUILD-01] stage outputs and stabilize documentation builds
This commit is contained in:
1 parent
078cd9ef72
commit
a468cb2c39
72 files changed
+2396
-748
No files matched your search
@@ -157,3 +157,14 @@ round-trips and the offline source corpus are checked against actual inputs.
|
||||
A loopback HTTP server verifies a stalled response body times out. Mock responses
|
||||
test bounded retries and invalid data; no paid translation is performed and these
|
||||
tests do not certify English prose quality. Included in `test:node`.
|
||||
|
||||
## Site build regressions
|
||||
|
||||
`yarn test:site-build` covers i18n compile failure, staged replacement and rollback,
|
||||
concurrent build/output conflicts, retained backups, actual language dictionaries
|
||||
and globals in four module modes, and a real Yarn documentation fixture child.
|
||||
Use this Yarn script instead of `yarn node`, which does not supply the lifecycle
|
||||
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.
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
for (const language of ['zh', 'en']) {
|
||||
test(`built documentation loads ${language} deep pages and opens Run Code with encoded source`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.route('https://**/*', route =>
|
||||
route.fulfill({ status: 200, body: '' }))
|
||||
await context.route('http://127.0.0.1:8082/**', route =>
|
||||
route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: '<title>Run Code destination</title>',
|
||||
}))
|
||||
await page.addInitScript(() => localStorage.setItem('lang-init', 'true'))
|
||||
const failedAssets = []
|
||||
page.on('response', (response) => {
|
||||
if (response.url().includes('/document/') && response.status() >= 400)
|
||||
failedAssets.push(response.url())
|
||||
})
|
||||
const prefix = language === 'en' ? '/document/en' : '/document'
|
||||
await page.goto(`${prefix}/start/option.html`, {
|
||||
waitUntil: 'networkidle',
|
||||
})
|
||||
await expect(page.locator('h1')).toContainText(
|
||||
language === 'en' ? 'Basic Options' : '基础选项',
|
||||
)
|
||||
const button = page.locator('[classname="run-code"]').first()
|
||||
await expect(button).toBeVisible()
|
||||
const popupPromise = context.waitForEvent('page')
|
||||
await button.click()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState('domcontentloaded')
|
||||
const target = new URL(popup.url())
|
||||
expect(target.hostname).toBe('127.0.0.1')
|
||||
expect(target.port).toBe('8082')
|
||||
expect(target.searchParams.get('code')).toContain('new Artplayer')
|
||||
await popup.close()
|
||||
await page.goto(`${prefix}/advanced/event.html`, {
|
||||
waitUntil: 'networkidle',
|
||||
})
|
||||
await expect(page.locator('h1')).toContainText(
|
||||
language === 'en' ? 'Instance Events' : '实例事件',
|
||||
)
|
||||
expect(failedAssets).toEqual([])
|
||||
await page.goto(`${prefix}/`, { waitUntil: 'networkidle' })
|
||||
const group = page.locator('.vp-code-group').first()
|
||||
await group.locator('.tabs label').nth(1).click()
|
||||
await expect(group.locator('input[type="radio"]').nth(1)).toBeChecked()
|
||||
await expect(group.locator('.blocks > div').nth(1)).toBeVisible()
|
||||
await expect(group.locator('.blocks > div').first()).toBeHidden()
|
||||
})
|
||||
}
|
||||
+36
-9
@@ -9,23 +9,50 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url))
|
||||
|
||||
test('documentation build forwards child failure and success exit codes', () => {
|
||||
test('documentation build uses pinned Yarn, forwards exit codes and retains old output until success', () => {
|
||||
assert(process.env.npm_execpath, 'Run this test through Yarn')
|
||||
const cache = path.join(root, 'refactor/.cache')
|
||||
fs.mkdirSync(cache, { recursive: true })
|
||||
const fixture = fs.mkdtempSync(path.join(cache, 'docs-exit-'))
|
||||
try {
|
||||
const pkg = path.join(fixture, 'packages/artplayer-vitepress')
|
||||
fs.mkdirSync(pkg, { recursive: true })
|
||||
const output = path.join(fixture, 'docs/document')
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
fs.writeFileSync(path.join(output, 'index.html'), 'Old site')
|
||||
fs.writeFileSync(
|
||||
path.join(pkg, 'fixture.cjs'),
|
||||
`
|
||||
const fs = require('node:fs'); const path = require('node:path');
|
||||
if (!process.env.npm_config_user_agent.startsWith('yarn/1.22.22')) process.exit(91);
|
||||
const outDir = process.argv[process.argv.indexOf('--outDir') + 1];
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, 'index.html'), 'New site');
|
||||
process.exit(Number(process.env.DOCS_TEST_EXIT));
|
||||
`,
|
||||
)
|
||||
for (const code of [23, 0]) {
|
||||
fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({
|
||||
private: true,
|
||||
scripts: { build: `node -e "process.exit(${code})"` },
|
||||
}))
|
||||
const result = spawnSync(process.execPath, [path.join(root, 'scripts/build-docs.js')], {
|
||||
cwd: fixture,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
fs.writeFileSync(
|
||||
path.join(pkg, 'package.json'),
|
||||
JSON.stringify({
|
||||
private: true,
|
||||
scripts: { build: 'node fixture.cjs' },
|
||||
}),
|
||||
)
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(root, 'scripts/build-docs.js')],
|
||||
{
|
||||
cwd: fixture,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DOCS_TEST_EXIT: String(code) },
|
||||
},
|
||||
)
|
||||
assert.equal(result.status, code, result.stderr)
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(output, 'index.html'), 'utf8'),
|
||||
code ? 'Old site' : 'New site',
|
||||
)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Exercise real build outputs and failure recovery.
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import vm from 'node:vm'
|
||||
import ts from 'typescript'
|
||||
import {
|
||||
stageArtifacts,
|
||||
treeFingerprint,
|
||||
} from '../scripts/site-build/artifacts.ts'
|
||||
import { buildLanguages, languageEntries } from '../scripts/site-build/i18n.ts'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const cache = path.resolve('refactor/.cache')
|
||||
const outputs = ['packages/artplayer/dist/i18n', 'docs/compiled/i18n']
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(cache, 'site-build-test-'))
|
||||
for (const output of outputs) {
|
||||
fs.mkdirSync(path.join(root, output), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, output, 'old.js'), 'Old complete output')
|
||||
}
|
||||
return root
|
||||
}
|
||||
function remove(root) {
|
||||
assert.equal(path.dirname(fs.realpathSync(root)), cache)
|
||||
assert(path.basename(root).startsWith('site-build-test-'))
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
function snapshot(root) {
|
||||
return outputs.map(output => treeFingerprint(path.join(root, output)))
|
||||
}
|
||||
|
||||
test('old i18n builder destroys the existing distribution before a compile failure', async () => {
|
||||
const root = fixture()
|
||||
try {
|
||||
const source = execFileSync(
|
||||
'git',
|
||||
[
|
||||
'show',
|
||||
'078cd9ef72d8b908ef178d650fe0b8c87f867f2a:scripts/build-i18n.js',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
)
|
||||
const ast = ts.createSourceFile(
|
||||
'old.js',
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
)
|
||||
const code = ast.statements
|
||||
.find(
|
||||
node =>
|
||||
ts.isFunctionDeclaration(node) && node.name.text === 'buildI18n',
|
||||
)
|
||||
.getText(ast)
|
||||
const distDir = path.join(root, outputs[0])
|
||||
const before = snapshot(root)
|
||||
await assert.rejects(
|
||||
vm.runInNewContext(`${code}; buildI18n()`, {
|
||||
fs: {
|
||||
...fs,
|
||||
rmSync(file, options) {
|
||||
assert.equal(file, distDir)
|
||||
fs.rmSync(file, options)
|
||||
},
|
||||
},
|
||||
path,
|
||||
distDir,
|
||||
entries: ['ar.ts'],
|
||||
toPascalCase: value => value,
|
||||
getViteBuildConfig: () => ({}),
|
||||
viteBuild: async () => {
|
||||
throw new Error('Compile failure')
|
||||
},
|
||||
}),
|
||||
/Compile failure/,
|
||||
)
|
||||
assert.notEqual(snapshot(root)[0], before[0])
|
||||
assert.equal(snapshot(root)[1], before[1])
|
||||
}
|
||||
finally {
|
||||
remove(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('staging isolates failed builds, rejects empty output and rolls back a later rename failure', async () => {
|
||||
const root = fixture()
|
||||
try {
|
||||
const before = snapshot(root)
|
||||
for (const mode of ['compile', 'empty', 'rename']) {
|
||||
let moves = 0
|
||||
await assert.rejects(
|
||||
stageArtifacts(
|
||||
root,
|
||||
'i18n',
|
||||
async (stages) => {
|
||||
assert.deepEqual(snapshot(root), before)
|
||||
if (mode === 'empty')
|
||||
return
|
||||
for (const stage of stages)
|
||||
fs.writeFileSync(path.join(stage, 'new.js'), 'New output')
|
||||
if (mode === 'compile')
|
||||
throw new Error('Compile failure')
|
||||
},
|
||||
(source, target) => {
|
||||
if (++moves === 4)
|
||||
throw new Error('Fourth rename failed')
|
||||
fs.renameSync(source, target)
|
||||
},
|
||||
),
|
||||
)
|
||||
assert.deepEqual(snapshot(root), before)
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(path.join(root, 'refactor/.cache/site-build')),
|
||||
[],
|
||||
)
|
||||
}
|
||||
await stageArtifacts(root, 'i18n', async (stages) => {
|
||||
for (const stage of stages)
|
||||
fs.writeFileSync(path.join(stage, 'new.js'), 'New output')
|
||||
})
|
||||
for (const output of outputs)
|
||||
assert.deepEqual(fs.readdirSync(path.join(root, output)), ['new.js'])
|
||||
}
|
||||
finally {
|
||||
remove(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('a competing build is rejected and an output edited during compile is preserved', async () => {
|
||||
const root = fixture()
|
||||
try {
|
||||
await assert.rejects(
|
||||
stageArtifacts(root, 'i18n', async (stages) => {
|
||||
await assert.rejects(
|
||||
stageArtifacts(root, 'i18n', async () => {}),
|
||||
/EEXIST/,
|
||||
)
|
||||
for (const stage of stages)
|
||||
fs.writeFileSync(path.join(stage, 'new.js'), 'New')
|
||||
fs.writeFileSync(
|
||||
path.join(root, outputs[0], 'manual.js'),
|
||||
'Concurrent author',
|
||||
)
|
||||
}),
|
||||
/changed while building/,
|
||||
)
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(root, outputs[0], 'manual.js'), 'utf8'),
|
||||
'Concurrent author',
|
||||
)
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(root, outputs[1], 'old.js'), 'utf8'),
|
||||
'Old complete output',
|
||||
)
|
||||
}
|
||||
finally {
|
||||
remove(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('rollback conflict retains original backup instead of overwriting an external artifact edit', async () => {
|
||||
const root = fixture()
|
||||
try {
|
||||
let moves = 0
|
||||
await assert.rejects(
|
||||
stageArtifacts(
|
||||
root,
|
||||
'i18n',
|
||||
async (stages) => {
|
||||
for (const stage of stages)
|
||||
fs.writeFileSync(path.join(stage, 'new.js'), 'Candidate')
|
||||
},
|
||||
(source, target) => {
|
||||
if (++moves === 4) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, outputs[0], 'new.js'),
|
||||
'Concurrent edit',
|
||||
)
|
||||
throw new Error('Injected later replacement failure')
|
||||
}
|
||||
fs.renameSync(source, target)
|
||||
},
|
||||
),
|
||||
/retained backups/,
|
||||
)
|
||||
const runs = fs.readdirSync(path.join(root, 'refactor/.cache/site-build'))
|
||||
assert.equal(runs.length, 1)
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
root,
|
||||
'refactor/.cache/site-build',
|
||||
runs[0],
|
||||
'backup-0/old.js',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
'Old complete output',
|
||||
)
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(root, outputs[0], 'new.js'), 'utf8'),
|
||||
'Concurrent edit',
|
||||
)
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(root, outputs[1], 'old.js'), 'utf8'),
|
||||
'Old complete output',
|
||||
)
|
||||
}
|
||||
finally {
|
||||
remove(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('actual language builds preserve every UMD/CJS/AMD/ESM dictionary and historical window alias', async () => {
|
||||
const root = fixture()
|
||||
try {
|
||||
const sourceDir = path.join(root, 'packages/artplayer/src/i18n')
|
||||
fs.cpSync(path.resolve('packages/artplayer/src/i18n'), sourceDir, {
|
||||
recursive: true,
|
||||
})
|
||||
const entries = languageEntries(root)
|
||||
assert.equal(entries.length, 11)
|
||||
await buildLanguages(root)
|
||||
assert.equal(snapshot(root)[0], snapshot(root)[1])
|
||||
for (const entry of entries) {
|
||||
const name = path.basename(entry, '.ts')
|
||||
const { language: source } = await loadModules(
|
||||
{ language: `packages/artplayer/src/i18n/${name}` },
|
||||
root,
|
||||
)
|
||||
const code = fs.readFileSync(
|
||||
path.join(root, outputs[0], `${name}.js`),
|
||||
'utf8',
|
||||
)
|
||||
const globalName = `artplayerI18n${name.replace(/(^|-)([a-z])/g, (_match, _prefix, char) => char.toUpperCase())}`
|
||||
for (const mode of ['global', 'cjs', 'amd']) {
|
||||
let amd
|
||||
const define = (factory) => {
|
||||
amd = factory()
|
||||
}
|
||||
define.amd = {}
|
||||
const context = {
|
||||
window: {},
|
||||
...(mode === 'cjs' ? { exports: {}, module: { exports: {} } } : {}),
|
||||
...(mode === 'amd' ? { define } : {}),
|
||||
}
|
||||
vm.runInNewContext(code, context)
|
||||
const result
|
||||
= mode === 'amd'
|
||||
? amd
|
||||
: mode === 'cjs'
|
||||
? context.module.exports
|
||||
: context[globalName]
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(result)),
|
||||
source,
|
||||
`${name}/${mode}`,
|
||||
)
|
||||
assert.equal(context.window[`artplayer-i18n-${name}`], result)
|
||||
}
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'window',
|
||||
)
|
||||
try {
|
||||
globalThis.window = {}
|
||||
const esm = await import(
|
||||
pathToFileURL(path.join(root, outputs[0], `${name}.mjs`)).href,
|
||||
)
|
||||
assert.deepEqual(esm.default, source)
|
||||
assert.equal(window[`artplayer-i18n-${name}`], esm.default)
|
||||
}
|
||||
finally {
|
||||
if (previousWindow)
|
||||
Object.defineProperty(globalThis, 'window', previousWindow)
|
||||
else delete globalThis.window
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(sourceDir, 'ar.js'), 'export default {}')
|
||||
assert.throws(() => languageEntries(root), /Duplicate JS\/TS/)
|
||||
fs.unlinkSync(path.join(sourceDir, 'ar.js'))
|
||||
fs.writeFileSync(
|
||||
path.join(sourceDir, 'ar.ts'),
|
||||
'export default invalid syntax',
|
||||
)
|
||||
const before = snapshot(root)
|
||||
await assert.rejects(buildLanguages(root))
|
||||
assert.deepEqual(snapshot(root), before)
|
||||
}
|
||||
finally {
|
||||
remove(root)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Verify the installed VitePress renderer, not a substitute implementation.
|
||||
import test from 'node:test'
|
||||
import { parseHTML } from 'linkedom'
|
||||
import { createMarkdownRenderer } from 'vitepress'
|
||||
import { stableCodeGroups } from '../packages/artplayer-vitepress/build/markdown.ts'
|
||||
|
||||
const group
|
||||
= '::: code-group\n\n```js [One]\nconsole.log(1)\n```\n\n```js [Two]\nconsole.log(2)\n```\n\n:::\n'
|
||||
|
||||
test('installed VitePress random code-group IDs become stable, distinct and label-associated', async () => {
|
||||
const original = await createMarkdownRenderer(process.cwd())
|
||||
const before = original.render(group, { relativePath: 'start/i18n.md' })
|
||||
assert.notEqual(
|
||||
before,
|
||||
original.render(group, { relativePath: 'start/i18n.md' }),
|
||||
)
|
||||
stableCodeGroups(original)
|
||||
const source = `${group}\n${group}`
|
||||
const first = original.render(source, { relativePath: 'start/i18n.md' })
|
||||
assert.equal(
|
||||
first,
|
||||
original.render(source, { relativePath: 'start/i18n.md' }),
|
||||
)
|
||||
assert.notEqual(
|
||||
first,
|
||||
original.render(source, { relativePath: 'en/start/i18n.md' }),
|
||||
)
|
||||
const { document } = parseHTML(first)
|
||||
const inputs = [...document.querySelectorAll('input[type="radio"]')]
|
||||
assert.equal(inputs.length, 4)
|
||||
assert.equal(new Set(inputs.map(input => input.id)).size, 4)
|
||||
assert.equal(
|
||||
new Set(inputs.map(input => input.getAttribute('name'))).size,
|
||||
2,
|
||||
)
|
||||
for (const input of inputs)
|
||||
assert(document.querySelector(`label[for="${input.id}"]`))
|
||||
assert.equal(document.querySelectorAll('input[checked]').length, 2)
|
||||
assert.equal(document.querySelectorAll('.blocks > .active').length, 2)
|
||||
})
|
||||
Reference in new issue
Block a user