refactor(build): [SITE-BUILD-01] stage outputs and stabilize documentation builds

This commit is contained in:
Harvey Zhao committed 2026-09-14 08:51:09 +08:00
1 parent 078cd9ef72
commit a468cb2c39
72 files changed
+2396 -748

No files matched your search

+11
View File
@@ -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.
+53
View File
@@ -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
View File
@@ -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 {
+297
View File
@@ -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)
}
})
+41
View File
@@ -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)
})