import test from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import { lstat, mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { parse } from 'dotenv'; const script = readFileSync(new URL('../deploy/up_next_h5.sh', import.meta.url), 'utf8'); const program = script.match(/# BEGIN preserve backend sitemaps[\s\S]*?<<'NODE'\n([\s\S]*?)\nNODE\n\)/)?.[1]; assert.ok(program, 'deploy script must expose the actual pre-switch sitemap preservation step'); async function fixture() { const root = await mkdtemp(join(tmpdir(), 'huakui-sitemap-deploy-')); const current = join(root, 'next'), staging = join(root, 'staging'), original = join(root, 'go'); for (const directory of [current, original, join(staging, 'config'), join(staging, 'node_modules')]) await mkdir(directory, { recursive: true }); await symlink(resolve('node_modules/dotenv'), join(staging, 'node_modules/dotenv'), 'dir'); await writeFile(join(staging, 'config/test.env'), 'SITEMAP_DIR=./template/pages/sitemaps\n'); const put = async (file: string, contents: string | Buffer) => { await mkdir(resolve(file, '..'), { recursive: true }); await writeFile(file, contents); }; const run = (configured?: string) => { const env = { ...process.env }; delete env.SITEMAP_DIR; if (configured !== undefined) env.SITEMAP_DIR = configured; const result = spawnSync(process.execPath, ['--input-type=module', '-', current, staging, original], { input: program, cwd: staging, env, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr); }; return { root, current, staging, original, put, run, close: () => rm(root, { recursive: true, force: true }) }; } test('development uses a local snapshot while deployed Next reads the backend parent directory', () => { for (const environment of ['development', 'test', 'production']) { const config = parse(readFileSync(new URL(`../config/${environment}.env`, import.meta.url))); assert.equal(config.SITEMAP_DIR, environment === 'development' ? './template/pages/sitemaps' : '../template/pages/sitemaps', environment); assert.equal(Object.hasOwn(config, 'PUBLIC_SITE_URL'), false, environment); assert.equal(Object.hasOwn(config, 'SITEMAP_UPSTREAM_URL'), false, environment); } }); test('deployment preserves original XML bytes and fallback index over packaged files', async () => { const f = await fixture(); try { const xml = Buffer.from('\ufeff\r\nhttps://old.test/play/a/0.80\r\n'); await f.put(join(f.current, 'template/pages/sitemaps/sitemap.xml'), xml); await f.put(join(f.current, 'template/pages/sitemaps/sitemap-playlet-1.xml'), xml); await f.put(join(f.current, 'template/pages/sitemaps/sitemap-empty.xml'), Buffer.alloc(0)); await f.put(join(f.current, 'template/pages/sitemap.xml'), 'original second index'); await f.put(join(f.current, 'template/pages/robots.txt'), 'User-agent: Custom\r\nDisallow: /private/\r\n'); await f.put(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'stale build copy'); f.run(); assert.deepEqual(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap.xml')), xml); assert.deepEqual(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap-playlet-1.xml')), xml); assert.equal((await readFile(join(f.staging, 'template/pages/sitemaps/sitemap-empty.xml'))).length, 0); assert.equal(await readFile(join(f.staging, 'template/pages/sitemap.xml'), 'utf8'), 'original second index'); assert.equal(await readFile(join(f.staging, 'template/pages/robots.txt'), 'utf8'), 'User-agent: Custom\r\nDisallow: /private/\r\n'); assert.deepEqual(await readFile(join(f.current, 'template/pages/sitemaps/sitemap.xml')), xml); } finally { await f.close(); } }); test('deployment migrates the previous data directory and can copy the original Go output on first release', async () => { for (const previous of ['legacy', 'go'] as const) { const f = await fixture(); try { const source = previous === 'legacy' ? join(f.current, 'data/sitemaps') : join(f.original, 'template/pages/sitemaps'); await f.put(join(source, 'sitemap.xml'), previous); await f.put(join(source, 'sitemap-series-1.xml'), previous + '-shard'); f.run(); assert.equal(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), previous); assert.equal(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap-series-1.xml'), 'utf8'), previous + '-shard'); assert.equal(await readFile(join(source, 'sitemap.xml'), 'utf8'), previous); } finally { await f.close(); } } }); test('an existing empty Go directory remains empty instead of reviving stale legacy or packaged XML', async () => { const f = await fixture(); try { await mkdir(join(f.current, 'template/pages/sitemaps'), { recursive: true }); await f.put(join(f.current, 'data/sitemaps/sitemap.xml'), 'old removed index'); await f.put(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'old packaged index'); f.run(); assert.equal(existsSync(join(f.staging, 'template/pages/sitemaps/sitemap.xml')), false); assert.equal(await readFile(join(f.current, 'data/sitemaps/sitemap.xml'), 'utf8'), 'old removed index'); } finally { await f.close(); } }); test('deployment preserves custom relative and in-release absolute directory mappings', async () => { for (const absolute of [false, true]) { const f = await fixture(); try { const source = join(f.current, 'runtime/xml'); await f.put(join(source, 'sitemap.xml'), 'custom source'); await f.put(join(source, 'application.js'), 'do not copy unrelated files'); const configured = absolute ? source : './runtime/xml'; await writeFile(join(f.staging, 'config/test.local.env'), 'SITEMAP_DIR=' + configured + '\n'); f.run(); assert.equal(await readFile(join(f.staging, 'runtime/xml/sitemap.xml'), 'utf8'), 'custom source'); assert.equal(existsSync(join(f.staging, 'runtime/xml/application.js')), false); } finally { await f.close(); } } }); test('deployment leaves external backend directories untouched and preserves default mount symlinks', async () => { const f = await fixture(); try { const external = join(f.root, 'backend-owned'); await f.put(join(external, 'sitemap.xml'), 'backend continues writing here'); await f.put(join(f.current, 'template/pages/sitemaps/sitemap.xml'), 'unused local snapshot'); f.run(external); assert.equal(existsSync(join(f.staging, 'template/pages/sitemaps')), false); assert.equal(await readFile(join(external, 'sitemap.xml'), 'utf8'), 'backend continues writing here'); await rm(join(f.current, 'template/pages/sitemaps'), { recursive: true }); await symlink(external, join(f.current, 'template/pages/sitemaps'), 'dir'); f.run(); assert.equal((await lstat(join(f.staging, 'template/pages/sitemaps'))).isSymbolicLink(), true); assert.equal(await readlink(join(f.staging, 'template/pages/sitemaps')), external); assert.equal(await readFile(join(external, 'sitemap.xml'), 'utf8'), 'backend continues writing here'); } finally { await f.close(); } }); test('the deployed parent-relative default keeps the live backend directory external to each frontend release', async () => { const f = await fixture(); try { const backend = join(f.root, 'template/pages/sitemaps'); const xml = Buffer.from('\ufeff\r\nhttps://original.test/play/backend/\r\n'); await f.put(join(backend, 'sitemap.xml'), xml); await f.put(join(backend, 'sitemap-pages.xml'), xml); await f.put(join(f.current, 'template/pages/sitemaps/sitemap.xml'), 'unused release-local copy'); await writeFile(join(f.staging, 'config/test.env'), readFileSync(new URL('../config/test.env', import.meta.url))); const config = parse(await readFile(join(f.staging, 'config/test.env'))); assert.equal(config.SITEMAP_DIR, '../template/pages/sitemaps'); assert.equal(resolve(f.current, config.SITEMAP_DIR!), backend); assert.equal(resolve(f.staging, config.SITEMAP_DIR!), backend); const before = await stat(join(backend, 'sitemap.xml')); f.run(); assert.deepEqual(await readFile(join(backend, 'sitemap.xml')), xml); assert.deepEqual(await readFile(join(backend, 'sitemap-pages.xml')), xml); assert.equal((await stat(join(backend, 'sitemap.xml'))).mtimeMs, before.mtimeMs); assert.equal(existsSync(join(f.staging, 'template/pages/sitemaps')), false); assert.equal(existsSync(join(f.staging, 'data/sitemaps')), false); assert.equal(await readFile(join(f.current, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), 'unused release-local copy'); } finally { await f.close(); } }); test('release-local symlink targets are preserved so the mounted sitemap does not become dangling', async () => { const f = await fixture(); try { await f.put(join(f.current, 'data/sitemaps/sitemap.xml'), 'linked index'); await mkdir(join(f.current, 'template/pages'), { recursive: true }); await symlink('../../data/sitemaps', join(f.current, 'template/pages/sitemaps'), 'dir'); f.run(); assert.equal(await readlink(join(f.staging, 'template/pages/sitemaps')), '../../data/sitemaps'); assert.equal(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), 'linked index'); assert.equal(await readFile(join(f.current, 'data/sitemaps/sitemap.xml'), 'utf8'), 'linked index'); } finally { await f.close(); } }); test('standalone packaging refuses to remove a configured backend sitemap inside replaceable output', async () => { const root = await mkdtemp(join(tmpdir(), 'huakui-sitemap-package-')); try { for (const directory of ['scripts', 'config', 'dist/test/backend']) await mkdir(join(root, directory), { recursive: true }); await symlink(resolve('node_modules'), join(root, 'node_modules'), 'dir'); await writeFile(join(root, 'package.json'), '{"type":"module"}'); await writeFile(join(root, 'scripts/package-standalone.ts'), readFileSync(new URL('../scripts/package-standalone.ts', import.meta.url))); await writeFile(join(root, 'config/test.env'), 'SITEMAP_DIR=./template/pages/sitemaps\n'); const backend = join(root, 'dist/test/backend'); await writeFile(join(backend, 'sitemap.xml'), 'must survive packaging'); const result = spawnSync(resolve('node_modules/.bin/tsx'), [join(root, 'scripts/package-standalone.ts'), 'test'], { cwd: root, env: { ...process.env, SITEMAP_DIR: backend }, encoding: 'utf8' }); assert.notEqual(result.status, 0); assert.match(result.stderr, /SITEMAP_DIR must live outside the replaceable standalone package directory/); assert.equal(await readFile(join(backend, 'sitemap.xml'), 'utf8'), 'must survive packaging'); } finally { await rm(root, { recursive: true, force: true }); } }); async function packageFixture() { const root = await mkdtemp(join(tmpdir(), 'huakui-sitemap-bundle-')); const standalone = join(root, '.next-test/standalone'), output = join(root, 'dist/test'), backend = join(root, 'backend'); const put = async (file: string, contents: string | Buffer) => { await mkdir(resolve(file, '..'), { recursive: true }); await writeFile(file, contents); }; for (const directory of ['scripts', 'config', 'public', '.next-test/static', '.next-test/standalone', 'backend']) await mkdir(join(root, directory), { recursive: true }); await symlink(resolve('node_modules'), join(root, 'node_modules'), 'dir'); await put(join(root, 'package.json'), '{"type":"module"}'); await put(join(root, 'scripts/package-standalone.ts'), readFileSync(new URL('../scripts/package-standalone.ts', import.meta.url))); await put(join(root, 'config/test.env'), 'APP_ENV=test\nSITEMAP_DIR=../template/pages/sitemaps\nUPSTREAM_API_URL=http://127.0.0.1:1\n'); await put(join(root, '.next-test/BUILD_ID'), 'fixture-build'); await put(join(root, '.next-test/static/app.js'), 'fixture-static'); await put(join(standalone, 'server.js'), '// fixture server, never executed'); const run = () => spawnSync(resolve('node_modules/.bin/tsx'), [join(root, 'scripts/package-standalone.ts'), 'test'], { cwd: root, env: { ...process.env, SITEMAP_DIR: backend }, encoding: 'utf8', }); return { root, standalone, output, backend, put, run, close: () => rm(root, { recursive: true, force: true }) }; } test('standalone packaging removes Proxy-traced XML copies while retaining backend bytes, robots, media keys and unknown files', async () => { const f = await packageFixture(); try { const xml = Buffer.from('\ufeff\r\nhttps://original.test/play/untouched/\r\n'); await f.put(join(f.backend, 'sitemap.xml'), xml); for (const path of ['template/pages/sitemaps/sitemap.xml', 'template/pages/sitemaps/sitemap-pages.xml', 'data/sitemaps/sitemap.xml', 'data/sitemaps/sitemap-seriesplay.xml', 'template/pages/sitemap.xml']) await f.put(join(f.standalone, path), xml); await symlink(join(f.backend, 'sitemap.xml'), join(f.standalone, 'data/sitemaps/sitemap-linked.xml')); await f.put(join(f.standalone, 'template/pages/robots.txt'), 'User-agent: Preserved\r\n'); await f.put(join(f.standalone, 'config/secrets/m3u8.key'), 'fixture-media-key'); await f.put(join(f.standalone, 'template/pages/sitemaps/README.md'), 'preserve documentation'); await f.put(join(f.standalone, 'data/sitemaps/custom.xml'), 'unknown XML is not generated sitemap'); await f.put(join(f.standalone, 'data/sitemaps/application.js'), 'preserve unknown application data'); const before = await stat(join(f.backend, 'sitemap.xml')); const result = f.run(); assert.equal(result.status, 0, result.stderr); for (const path of ['template/pages/sitemaps/sitemap.xml', 'template/pages/sitemaps/sitemap-pages.xml', 'data/sitemaps/sitemap.xml', 'data/sitemaps/sitemap-seriesplay.xml', 'data/sitemaps/sitemap-linked.xml', 'template/pages/sitemap.xml']) assert.equal(existsSync(join(f.output, path)), false, path); assert.deepEqual(await readFile(join(f.backend, 'sitemap.xml')), xml); assert.equal((await stat(join(f.backend, 'sitemap.xml'))).mtimeMs, before.mtimeMs); assert.deepEqual(await readFile(join(f.standalone, 'template/pages/sitemaps/sitemap.xml')), xml); assert.equal((await lstat(join(f.standalone, 'data/sitemaps/sitemap-linked.xml'))).isSymbolicLink(), true); assert.equal(await readFile(join(f.output, 'template/pages/robots.txt'), 'utf8'), 'User-agent: Preserved\r\n'); assert.equal(await readFile(join(f.output, 'config/secrets/m3u8.key'), 'utf8'), 'fixture-media-key'); assert.equal(await readFile(join(f.output, 'template/pages/sitemaps/README.md'), 'utf8'), 'preserve documentation'); assert.equal(await readFile(join(f.output, 'data/sitemaps/custom.xml'), 'utf8'), 'unknown XML is not generated sitemap'); assert.equal(await readFile(join(f.output, 'data/sitemaps/application.js'), 'utf8'), 'preserve unknown application data'); assert.ok(existsSync(join(f.output, '.next-test/static/app.js'))); assert.ok(existsSync(join(f.output, 'start.mjs'))); } finally { await f.close(); } }); test('standalone XML cleanup refuses every symlinked parent instead of following a backend mount', async () => { for (const suffix of ['template', 'template/pages', 'template/pages/sitemaps', 'data', 'data/sitemaps']) { const f = await packageFixture(); try { const bytes = Buffer.from('backend XML remains untouched: ' + suffix); await f.put(join(f.backend, 'sitemap.xml'), bytes); await mkdir(resolve(join(f.standalone, suffix), '..'), { recursive: true }); await symlink(f.backend, join(f.standalone, suffix), 'dir'); const result = f.run(); assert.notEqual(result.status, 0, suffix); assert.match(result.stderr, /Sitemap cleanup requires ordinary directories inside the standalone package/); assert.deepEqual(await readFile(join(f.backend, 'sitemap.xml')), bytes); assert.equal((await lstat(join(f.standalone, suffix))).isSymbolicLink(), true); } finally { await f.close(); } } }); test('standalone XML cleanup refuses sitemap-named directories before deleting any copied file', async () => { const f = await packageFixture(); try { await f.put(join(f.standalone, 'template/pages/sitemaps/sitemap.xml'), 'copied index'); await mkdir(join(f.standalone, 'data/sitemaps/sitemap-pages.xml'), { recursive: true }); const result = f.run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /Packaged sitemap XML must be a file/); assert.equal(await readFile(join(f.output, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), 'copied index'); assert.equal((await stat(join(f.output, 'data/sitemaps/sitemap-pages.xml'))).isDirectory(), true); } finally { await f.close(); } });