blob: b2407dafa87873f6ced3e05e1693cf41c59b1a0e [file]
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const {optimize} = require('../optimize.cjs');
const redirect = require('@docusaurus/plugin-client-redirects/lib/createRedirectPageContent').default;
const locales = ['en','zh-CN','ja'].map(locale => ({locale,directory:locale === 'en' ? '.' : locale}));
function fixture(t, selectedLocales = locales) {
const site = fs.mkdtempSync(path.join(os.tmpdir(),'doris-publish-test-'));
t.after(() => fs.rmSync(site,{recursive:true,force:true}));
const write = (name,content) => {
const file = path.join(site,name);
fs.mkdirSync(path.dirname(file),{recursive:true});
fs.writeFileSync(file,content);
};
write('.htaccess','# root controls\n');
for (const locale of selectedLocales) {
const prefix = locale.directory === '.' ? '' : locale.directory+'/';
write(prefix+'index.html','<main>Real homepage</main>');
write(prefix+'assets/shared/same.png',Buffer.alloc(65536,42));
write(prefix+'images/old.png',Buffer.alloc(65536,42));
write(prefix+'docs/dev/current/index.html','<main>Current document</main>');
write(prefix+'docs/dev/current.html/index.html',redirect({toUrl:`/${prefix}docs/dev/current/`}));
write(prefix+'docs/dev/current.htm/index.html',redirect({toUrl:`/${prefix}docs/dev/current/`}));
write(prefix+'docs/dev/old/index.html',redirect({toUrl:`/${prefix}docs/dev/current/`}));
}
return {site,write};
}
test('shares binary assets without changing bundles, and retains every legacy redirect target', t => {
const {site,write} = fixture(t);
write('ja/assets/js/ja.hash.js','const url="/assets/shared/same.png";');
write('assets/js/en.hash.js','const locale="en";');
write('ja/.htaccess','# root controls\n');
const result = optimize({site,locales});
assert(result.after.bytes < result.before.bytes / 3);
assert.equal(result.redirectPages,9);
assert(result.apacheRootRules < result.redirectPages);
assert.equal(fs.readFileSync(path.join(site,'assets/js/ja.hash.js'),'utf8'),'const url="/assets/shared/same.png";');
assert(!fs.existsSync(path.join(site,'ja/assets')));
assert(!fs.existsSync(path.join(site,'ja/.htaccess')));
assert(!fs.existsSync(path.join(site,'images/old.png')));
const manifest = JSON.parse(fs.readFileSync(path.join(site,'.publish/redirects.json'),'utf8'));
assert(manifest.redirects.some(([from,to]) => from === '/ja/images/old.png' && to === '/assets/shared/same.png'));
assert(manifest.redirects.some(([from,to]) => from === '/ja/docs/dev/current.html/' && to === '/ja/docs/dev/current/'));
assert(fs.readFileSync(path.join(site,'.htaccess'),'utf8').endsWith('# root controls\n'));
});
test('a locale-only artifact promotes its assets while preserving language-specific differences', t => {
const {site,write} = fixture(t);
fs.rmSync(site,{recursive:true});
write('ja/index.html','<main>Japanese</main>');
write('ja/images/example.png','Japanese image');
write('zh-CN/images/example.png','Chinese image');
optimize({site,locales:locales.slice(1)});
const manifest = JSON.parse(fs.readFileSync(path.join(site,'.publish/redirects.json'),'utf8'));
for (const locale of ['ja','zh-CN']) {
const target = manifest.redirects.find(([from]) => from === `/${locale}/images/example.png`)[1];
assert.equal(fs.readFileSync(path.join(site,target),'utf8'),locale === 'ja' ? 'Japanese image' : 'Chinese image');
}
assert(!fs.existsSync(path.join(site,'index.html')));
});
test('hash filename collisions fail instead of silently mixing languages', t => {
const {site,write} = fixture(t);
write('assets/js/same.hash.js','English');
write('ja/assets/js/same.hash.js','Japanese');
assert.throws(() => optimize({site,locales}),/filename collision/);
});
test('legacy search URLs retain locale, context and query without copying the index', async t => {
const {site,write} = fixture(t);
const target = 'ja/search-index-docs-dev-aabbccddeeff0011.json';
write(target, '[{"documents":[]}]');
optimize({site,locales});
assert(!fs.existsSync(path.join(site,'ja/search-index-docs-dev.json')));
const server = require('../serve.cjs').createServer(site);
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
t.after(()=>new Promise(resolve=>{server.closeAllConnections?.();server.close(resolve);}));
const response = await fetch(`http://127.0.0.1:${server.address().port}/ja/search-index-docs-dev.json?old=1`,{redirect:'manual'});
assert.equal(response.status,302);
assert.equal(response.headers.get('location'),'/'+target+'?old=1');
require('../verify.cjs').verify(site);
});
test('retains distinct compiled URLs even when their binary contents match', t => {
const {site,write} = fixture(t);
write('assets/shared/other-name.PNG',Buffer.alloc(65536,42));
optimize({site,locales});
assert(fs.existsSync(path.join(site,'assets/shared/same.png')));
assert(fs.existsSync(path.join(site,'assets/shared/other-name.PNG')));
});
test('verifies homepage redirects and unquoted resource attributes from the faster minifier', t => {
const {site,write} = fixture(t);
write('vendors/index.html',redirect({toUrl:'/'}));
write('assets/js/entry.hash.js','console.log("entry");');
write('index.html','<script src=/assets/js/entry.hash.js></script>');
optimize({site,locales});
const result = require('../verify.cjs').verify(site);
assert.equal(result.compiledAssetReferences,1);
assert.equal(result.compiledJavaScriptFiles,1);
});
test('rejects malformed client entries, runtimes and unreferenced lazy chunks before publication', t => {
const {site,write} = fixture(t);
optimize({site,locales});
for (const name of ['main.hash.js', 'runtime~main.hash.js', 'lazy.hash.js']) {
write(`assets/js/${name}`, 'self.registry={929b5da3:()=>42};');
assert.throws(() => require('../verify.cjs').verify(site),
error => error instanceof SyntaxError && error.message.includes(`assets/js/${name}`));
// Verification parses browser bundles without running them in Node.
write(`assets/js/${name}`, 'self.registry={"929b5da3":()=>document.title};');
}
assert.equal(require('../verify.cjs').verify(site).compiledJavaScriptFiles,3);
});
test('SVGs with relative references retain their original base URLs', t => {
const {site,write} = fixture(t);
for (const directory of ['one','two']) {
write(`images/${directory}/image.svg`,'<svg xmlns="http://www.w3.org/2000/svg"><image href="nested.png"/></svg>');
write(`images/${directory}/nested.png`,directory);
}
optimize({site,locales});
for (const directory of ['one','two']) assert(fs.existsSync(path.join(site,`images/${directory}/image.svg`)));
});
test('keeps custom HTML, preserves query/fragment semantics, and flattens redirect chains', t => {
const {site,write} = fixture(t);
write('slack/index.html','<script>window.location.href="tracked"</script>');
write('first/index.html',redirect({toUrl:'/second/'}));
write('second/index.html',redirect({toUrl:'/docs/dev/current/?fixed=1#usage'}));
optimize({site,locales});
assert(fs.existsSync(path.join(site,'slack/index.html')));
const manifest = JSON.parse(fs.readFileSync(path.join(site,'.publish/redirects.json'),'utf8'));
assert.deepEqual(manifest.redirects.find(([from]) => from === '/first/'),['/first/','/docs/dev/current/?fixed=1#usage',false,301]);
assert(fs.readFileSync(path.join(site,'.htaccess'),'utf8').includes(',QSD]'));
});
test('rejects redirect cycles and artifact symlinks', t => {
const {site,write} = fixture(t);
write('one/index.html',redirect({toUrl:'/two/'}));
write('two/index.html',redirect({toUrl:'/one/'}));
assert.throws(() => optimize({site,locales}),/redirect cycle/);
fs.symlinkSync('/tmp',path.join(site,'outside'));
assert.throws(() => optimize({site,locales}),/symlink/);
});
test('Apache serves compacted legacy prefixes, Unicode paths, assets and query/fragment redirects',
{skip:process.env.DORIS_TEST_APACHE !== 'true'}, async t => {
const {site,write} = fixture(t);
write('docs/3.x/prefix/index.html','<main>Legacy destination</main>');
write('docs/3.0/prefix/index.html',redirect({toUrl:'/docs/3.x/prefix/'}));
write('docs/prefix/index.html',redirect({toUrl:'/docs/3.x/prefix/'}));
write('releases/v4.0/release-4.0.1/index.html','<main>Release</main>');
write('docs/4.x/releasenotes/v4.0/release-4.0.1/index.html',redirect({toUrl:'/releases/v4.0/release-4.0.1/'}));
write('legacy-4.0/index.html',redirect({toUrl:'/docs/dev/current/?fixed=1#usage'}));
write('한글 alias/index.html',redirect({toUrl:'/docs/dev/current/#한글'}));
write('docs/한글/index.html','<main>Unicode target</main>');
write('docs/한글.html/index.html',redirect({toUrl:'/docs/한글/'}));
write('docs/real/index.html','<main>Short route</main>');
write('docs/real.html/index.html','<main>Distinct real HTML-suffix route</main>');
optimize({site,locales});
const server = await require('../apache.cjs').apache(site);
t.after(() => server.close());
const manifest = JSON.parse(fs.readFileSync(path.join(site,'.publish/redirects.json'),'utf8'));
for (const [from,to,preserveQuery,status] of manifest.redirects) {
for (const source of from.endsWith('/') ? [from,from.slice(0,-1),from+'index.html'] : [from]) {
const response = await fetch(server.origin+source+'?probe=1',{redirect:'manual'});
assert.equal(response.status,status,source);
if (status === 302) assert.equal(response.headers.get('cache-control'),'no-store');
const expected = new URL(to,server.origin);
if (preserveQuery) expected.search = '?probe=1';
const normalize = value => value.replace(/%[\da-f]{2}/ig,part => part.toUpperCase());
assert.equal(normalize(new URL(response.headers.get('location'),server.origin).href),normalize(expected.href),source);
}
}
assert.equal((await fetch(server.origin+'/docs/3.0/missing/')).status,404);
const actualPage = await fetch(server.origin+'/docs/real.html/',{redirect:'manual'});
assert.equal(actualPage.status,200);
assert((await actualPage.text()).includes('Distinct real HTML-suffix route'));
assert.equal((await fetch(server.origin+'/ja/assets/shared/same.png')).status,200);
});
module.exports = {fixture,locales};
for (const locale of ['ko','ru']) test(`five-language publication preserves ${locale} indexes, workers and redirects`, async t => {
const selected = ['en','zh-CN','ja','ko','ru'].map(locale => ({locale,directory:locale === 'en' ? '.' : locale}));
const {site,write} = fixture(t, selected);
const worker = 'self.onmessage = event => self.postMessage(event.data);';
write(`${locale}/assets/js/${locale}.worker.0123abcd.js`,worker);
write(`${locale}/index.html`,`<html lang="${locale}"><script src="/assets/js/${locale}.worker.0123abcd.js"></script></html>`);
const index = JSON.stringify([{documents:[{u:`/${locale}/docs/dev/current/`,t:locale === 'ru' ? 'Русский документ' : '한국어 문서'}]}]);
write(`${locale}/search-index-docs-dev.json`,index);
const result = optimize({site,locales:selected});
assert.equal(result.redirectPages,15);
assert.equal(fs.readFileSync(path.join(site,`assets/js/${locale}.worker.0123abcd.js`),'utf8'),worker);
assert.equal(fs.readFileSync(path.join(site,`${locale}/search-index-docs-dev.json`),'utf8'),index);
assert(!fs.existsSync(path.join(site,`${locale}/assets`)));
require('../verify.cjs').verify(site);
const server = require('../serve.cjs').createServer(site);
await new Promise(resolve => server.listen(0,'127.0.0.1',resolve));
t.after(() => new Promise(resolve => server.close(resolve)));
const origin = `http://127.0.0.1:${server.address().port}`;
const response = await fetch(origin+`/${locale}/docs/dev/old/?from=test`,{redirect:'manual'});
assert.equal(response.status,301);
assert.equal(new URL(response.headers.get('location'),origin).pathname,`/${locale}/docs/dev/current/`);
assert.equal(new URL(response.headers.get('location'),origin).search,'?from=test');
assert.equal(await (await fetch(origin+`/assets/js/${locale}.worker.0123abcd.js`)).text(),worker);
assert.equal(await (await fetch(origin+`/${locale}/search-index-docs-dev.json`)).text(),index);
});
test('a fifth-language compiled filename collision rejects the artifact', t => {
const selected = ['en','zh-CN','ja','ko','ru'].map(locale => ({locale,directory:locale === 'en' ? '.' : locale}));
const {site,write} = fixture(t, selected);
write('assets/js/collision.0123abcd.js','English');
write('ru/assets/js/collision.0123abcd.js','Russian');
assert.throws(() => optimize({site,locales:selected}),/filename collision/);
});