Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-27 16:23:48 +07:00
co-authored by Claude Opus 5.5
commit 8cdbfee08f
345 changed files with 20773 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {mkdtemp,mkdir,writeFile,readFile,cp,symlink,rm,stat} from 'node:fs/promises';
import {join} from 'node:path';
import {tmpdir} from 'node:os';
import {spawnSync} from 'node:child_process';
import {createHash} from 'node:crypto';
import {gzipSync} from 'node:zlib';
import {packageStandalone} from '../scripts/package-standalone.mjs';
const projectRoot=new URL('../',import.meta.url);
async function fixture(t) {
const root=await mkdtemp(join(tmpdir(),'91-release-'));t.after(()=>rm(root,{recursive:true,force:true}));
async function put(path,text='fixture') {await mkdir(join(root,path,'..'),{recursive:true});await writeFile(join(root,path),text);}
for(const [env,build] of [['test','.next-test'],['production','.next']]) {
await put(`${build}/standalone/server.js`,'module.exports=true;');
await put(`${build}/standalone/${build}/BUILD_ID`,'build-'+env);
await put(`${build}/standalone/${build}/cache/seo/private.json`);
await put(`${build}/standalone/.env.local`,'SENSITIVE=do-not-ship');
await put(`${build}/standalone/config/secrets/private-key`);
await put(`${build}/standalone/dist/production/config/production.local.env`);
await put(`${build}/standalone/tests/fixtures/private.json`);
await put(`${build}/standalone/${build==='.next'?'.next-test':'.next'}/cache/seo/old.json`);
await put(`${build}/static/chunks/app.js`);
await put(`config/${env}.env`,`API_BASE_URL=https://api.test\nPORT=21021\n`);
await put(`config/${env}.local.env`,'SENSITIVE=do-not-ship');
}
await put('public/assets/logo.svg');await put('public/._logo.svg');
await put('package.json','{}');await put('package-lock.json','{}');
await cp(new URL('scripts',projectRoot),join(root,'scripts'),{recursive:true});
await cp(new URL('node_modules/dotenv',projectRoot),join(root,'node_modules/dotenv'),{recursive:true});
return {root,put};
}
test('standalone releases include only their profile and assets, exclude local secrets/cache, and preserve other releases',async t=>{
const {root,put}=await fixture(t);
for(const [env,build] of [['test','.next-test'],['production','.next']]) {
const release=await packageStandalone(env,{root});
for(const file of ['start.mjs','server.js','package-lock.json','node_modules/dotenv/package.json','scripts/check-health.mjs',build+'/BUILD_ID',build+'/static/chunks/app.js','public/assets/logo.svg',`config/${env}.env`])assert.ok((await stat(join(release,file))).isFile(),file);
for(const file of ['.env.local','public/._logo.svg',`config/${env}.local.env`,'config/secrets','dist','tests',build+'/cache',(build==='.next'?'.next-test':'.next')])await assert.rejects(stat(join(release,file)),{code:'ENOENT'});
const launch=spawnSync(process.execPath,[join(release,'start.mjs')],{encoding:'utf8',env:{PATH:process.env.PATH}});
assert.equal(launch.status,0,launch.stderr);
}
await put('dist/production/keep','previous production');
await put('dist/test/stale','remove stale file');await packageStandalone('test',{root});
assert.equal(await readFile(join(root,'dist/production/keep'),'utf8'),'previous production');
await assert.rejects(stat(join(root,'dist/test/stale')),{code:'ENOENT'});
});
test('invalid environments and symlink destinations fail without deleting unrelated data',async t=>{
const {root,put}=await fixture(t);
await put('outside/keep','do not delete');await mkdir(join(root,'dist'));
await symlink(join(root,'outside'),join(root,'dist/test'));
await assert.rejects(packageStandalone('test',{root}),/Unsafe release directory/);
await assert.rejects(packageStandalone('../outside',{root}),/Usage/);
assert.equal(await readFile(join(root,'outside/keep'),'utf8'),'do not delete');
await rm(join(root,'dist'),{recursive:true});await symlink(join(root,'outside'),join(root,'dist'));
await assert.rejects(packageStandalone('production',{root}),/Unsafe release directory/);
assert.equal(await readFile(join(root,'outside/keep'),'utf8'),'do not delete');
});
test('dry runs reflect current configuration, allow overrides and never build/upload; invalid deployment targets fail early',async t=>{
const dir=await mkdtemp(join(tmpdir(),'91-deploy-stubs-'));t.after(()=>rm(dir,{recursive:true,force:true}));
const marker=join(dir,'executed');
for(const command of ['npm','ssh','scp'])await writeFile(join(dir,command),`#!/bin/bash\nprintf '%s\\n' '${command}' >> "$MARKER"\nexit 99\n`,{mode:0o755});
const env={...process.env,PATH:dir+':'+process.env.PATH,MARKER:marker,DEPLOY_DRY_RUN:'0'};
for(const key of ['DEPLOY_HOST','DEPLOY_DIR','DEPLOY_RUN_DIR','DEPLOY_JUMP_HOST','DEPLOY_JUMP_DIR','DEPLOY_SSH_PORT','DEPLOY_SCP_PORT','DEPLOY_CHECK_URL','DEPLOY_PROGRAM','DEPLOY_PACKAGE_NAME','DEPLOY_TARGET_ARCH','DEPLOY_TARGET_LIBC'])delete env[key];
// Deployment addresses/ports are operator configuration, not application invariants.
// Verify reporting consistency and explicit overrides rather than pinning a server.
for(const script of ['deploy_test.sh','deploy_prod.sh']) {
for(const args of [['--dry-run'],['--package-only','--dry-run']]) {
const result=spawnSync('bash',[script,...args],{encoding:'utf8',env});
assert.equal(result.status,0,result.stderr);assert.match(result.stdout,/API=(?:current|https?:\/\/\S+)\s+PORT=\d+/);
assert.doesNotMatch(result.stdout,/undefined/);
if(!args.includes('--package-only')) {
const help=spawnSync('bash',[script,'--help'],{encoding:'utf8',env});assert.equal(help.status,0,help.stderr);
if(script==='deploy_test.sh') {
assert.match(result.stdout,/上传 \S+\.tar\.gz、\S+\.tar\.gz\.sha256 和 up_next_h5\.sh/);
assert.doesNotMatch(result.stdout,/dist\.zip/);
const target=result.stdout.match(/发布目标:(\S+):(\/[^(\n]+)(SSH (\d+))。/);
assert.ok(target,`${script} 未报告有效上传目标:${result.stdout}`);
const [,host,directory,port]=target;
assert.ok(Number(port)>0&&Number(port)<=65535);
assert.ok(help.stdout.includes(`${host}:${port} ${directory.replace(/\/$/,'')}/`),help.stdout);
const execute=result.stdout.match(/上传成功后:ssh -p (\d+) (\S+) "cd (\/\S+) && bash run\.sh"/);
assert.ok(execute,result.stdout);assert.equal(execute[1],port);assert.equal(execute[2],host);
assert.ok(help.stdout.includes(`DEPLOY_RUN_DIR(${execute[3]})`),help.stdout);
}else {
const upload=result.stdout.match(/^\s+scp -P (\d+) (\S+) (\S+) ([^:\s]+):(\/\S*)$/m);
const execute=result.stdout.match(/^\s+ssh -p (\d+) (\S+) "cd (\/\S+) && bash rsupdate\.sh"$/m);
assert.ok(upload,`${script} 未报告有效上传命令:${result.stdout}`);
assert.ok(execute,`${script} 未报告有效执行命令:${result.stdout}`);
const [,scpPort,archive,checksum,host,directory]=upload;
const [,sshPort,sshHost,runDirectory]=execute;
assert.equal(sshHost,host);assert.equal(runDirectory,directory.replace(/\/$/,''));
assert.equal(checksum,archive.replace(/\.tar\.gz$/,'.md5'));
assert.ok(help.stdout.includes(`${host}:${directory}`),help.stdout);
for(const [key,value] of [['DEPLOY_SCP_PORT',scpPort],['DEPLOY_SSH_PORT',sshPort],['DEPLOY_PACKAGE_NAME',archive]])assert.ok(help.stdout.includes(`${key}(${value})`),help.stdout);
}
}
}
const customEnv={...env,DEPLOY_HOST:'user@custom-test',DEPLOY_DIR:'/srv/custom-test',DEPLOY_RUN_DIR:'/srv/custom-run',DEPLOY_JUMP_HOST:'user@custom-prod',DEPLOY_JUMP_DIR:'/srv/custom-prod/',DEPLOY_SSH_PORT:'2202',DEPLOY_SCP_PORT:'2203',DEPLOY_PACKAGE_NAME:'custom_package.tar.gz'};
const custom=spawnSync('bash',[script,'--dry-run'],{encoding:'utf8',env:customEnv});
assert.equal(custom.status,0,custom.stderr);
assert.ok(custom.stdout.includes(script==='deploy_test.sh'?'user@custom-test:/srv/custom-test':'user@custom-prod:/srv/custom-prod/'));
assert.ok(custom.stdout.includes('2202'));if(script==='deploy_prod.sh')assert.ok(custom.stdout.includes('2203'));
assert.ok(custom.stdout.includes('custom_package.tar.gz'));
if(script==='deploy_test.sh')assert.ok(custom.stdout.includes('custom_package.tar.gz.sha256'));
const customHelp=spawnSync('bash',[script,'--help'],{encoding:'utf8',env:customEnv});assert.equal(customHelp.status,0,customHelp.stderr);
assert.ok(customHelp.stdout.includes('2202'));assert.ok(customHelp.stdout.includes(script==='deploy_test.sh'?'/srv/custom-run':'custom_package.tar.gz'));
if(script==='deploy_test.sh')assert.match(custom.stdout,/ssh -p 2202 user@custom-test "cd \/srv\/custom-run && bash run\.sh"/);
else assert.match(custom.stdout,/ssh -p 2202 user@custom-prod "cd \/srv\/custom-prod && bash rsupdate\.sh"/);
assert.ok(customHelp.stdout.includes('custom_package.tar.gz'));
const invalidArchive=spawnSync('bash',[script,'--package-only'],{encoding:'utf8',env:{...env,DEPLOY_PACKAGE_NAME:'../invalid.zip'}});
assert.notEqual(invalidArchive.status,0);assert.match(invalidArchive.stderr,/\.tar\.gz 文件名/);
const invalidHost=spawnSync('bash',[script],{encoding:'utf8',env:{...env,DEPLOY_HOST:'bad;host',DEPLOY_JUMP_HOST:'bad;host'}});
assert.notEqual(invalidHost.status,0);assert.match(invalidHost.stderr,/格式无效/);
const invalid=spawnSync('bash',[script],{encoding:'utf8',env:{...env,DEPLOY_HOST:'user@server',DEPLOY_DIR:'/srv/../other',DEPLOY_JUMP_HOST:'user@server',DEPLOY_JUMP_DIR:'/srv/../other'}});
assert.notEqual(invalid.status,0);assert.match(invalid.stderr,/绝对路径/);
const invalidPort=spawnSync('bash',[script],{encoding:'utf8',env:{...env,DEPLOY_SSH_PORT:'0'}});
assert.notEqual(invalidPort.status,0);assert.match(invalidPort.stderr,/1–65535/);
if(script==='deploy_test.sh'){
const invalidRun=spawnSync('bash',[script],{encoding:'utf8',env:{...env,DEPLOY_RUN_DIR:'/srv/../other'}});
assert.notEqual(invalidRun.status,0);assert.match(invalidRun.stderr,/DEPLOY_RUN_DIR 必须是普通绝对路径/);
}
}
await assert.rejects(stat(marker),{code:'ENOENT'});
});
test('test deployment uploads tar.gz and checksum before run.sh, preserves its execution directory, and stops on failure',async t=>{
const {root}=await fixture(t),bin=join(root,'bin'),marker=join(root,'commands.jsonl');
await mkdir(bin);await mkdir(join(root,'deploy'));await mkdir(join(root,'node_modules/next'));
await cp(new URL('deploy_test.sh',projectRoot),join(root,'deploy_test.sh'));
await cp(new URL('deploy/up_next_h5.sh',projectRoot),join(root,'deploy/up_next_h5.sh'));
for(const name of ['npm','ssh','scp']) {
const code=`const fs=require('node:fs'),args=process.argv.slice(2);fs.appendFileSync(process.env.MARKER,JSON.stringify({command:${JSON.stringify(name)},args,testEnvironment:process.env.TEST_APP_ENV})+'\\n');`+
(name==='npm'?`if(args.join(' ')==='run bundle:test'){const r=require('node:child_process').spawnSync(process.execPath,['scripts/package-standalone.mjs','test'],{stdio:'inherit'});process.exit(r.status??1);}`:
name==='scp'?`process.exit(Number(process.env.SCP_EXIT||0));`:
`if(args.at(-1).includes('bash run.sh'))process.exit(Number(process.env.RUN_EXIT||0));`);
await writeFile(join(bin,name),'#!'+process.execPath+'\n'+code+'\n',{mode:0o755});
}
const archive='fixture_test.tar.gz';
const env={...process.env,PATH:bin+':'+process.env.PATH,MARKER:marker,DEPLOY_DRY_RUN:'0',DEPLOY_HOST:'fixture-host',DEPLOY_DIR:'/srv/fixture',DEPLOY_SSH_PORT:'2202',DEPLOY_PACKAGE_NAME:archive,DEPLOY_CHECK_URL:'',SCP_EXIT:'0',RUN_EXIT:'0'};
delete env.DEPLOY_RUN_DIR;
const run=(options={},args=[])=>spawnSync('bash',['deploy_test.sh',...args],{cwd:root,encoding:'utf8',env:{...env,...options}});
const commands=async()=>(await readFile(marker,'utf8')).trim().split('\n').filter(Boolean).map(line=>JSON.parse(line));
// Server directories are operator configuration. Compare actual execution with
// the script's advertised default rather than pinning a historical path.
const defaultRunDirectory=()=>{
const help=run({},['--help']);assert.equal(help.status,0,help.stderr);
const configured=help.stdout.match(/DEPLOY_RUN_DIR(([^)]+))/);
assert.ok(configured,help.stdout);return configured[1];
};
const runDirectory=defaultRunDirectory();
const result=run();
assert.equal(result.status,0,result.stdout+result.stderr);
const calls=await commands();
const npm=calls.filter(call=>call.command==='npm');assert.deepEqual(npm.map(call=>call.args),[['run','build:test'],['test'],['run','bundle:test']]);assert.equal(npm[1].testEnvironment,'test');
assert.deepEqual(calls.filter(call=>call.command==='scp').map(call=>call.args),[['-P','2202',archive,archive+'.sha256','deploy/up_next_h5.sh','fixture-host:/srv/fixture/']]);
assert.deepEqual(calls.filter(call=>call.command==='ssh').map(call=>call.args),[['-p','2202','fixture-host',"mkdir -p '/srv/fixture'"],['-p','2202','fixture-host',`cd '${runDirectory}' && bash run.sh`]]);
assert.deepEqual(calls.map(call=>call.command),['npm','npm','npm','ssh','scp','ssh']);
assert.match(result.stdout,/测试服 run\.sh 执行完成/);
const bytes=await readFile(join(root,archive));assert.equal(bytes.subarray(0,2).toString('hex'),'1f8b');
assert.equal(await readFile(join(root,archive+'.sha256'),'utf8'),createHash('sha256').update(bytes).digest('hex')+' '+archive+'\n');
assert.equal(await readFile(join(root,archive.replace(/\.tar\.gz$/,'.md5')),'utf8'),createHash('md5').update(bytes).digest('hex')+'\n');
const contents=spawnSync('tar',['-tzf',join(root,archive)],{encoding:'utf8'});assert.equal(contents.status,0,contents.stderr);
assert.match(contents.stdout,/\.next-test\/BUILD_ID/);assert.match(contents.stdout,/config\/test\.env/);
assert.doesNotMatch(contents.stdout,/node_modules|\.local\.env|\/cache\//);
for(const [name,options,args,status] of [
['custom-run-directory',{DEPLOY_RUN_DIR:'/srv/custom-run'},[],0],
['upload-failed',{SCP_EXIT:'23'},[],23],
['run-failed',{RUN_EXIT:'42'},[],42],
['package-only',{},['--package-only'],0],
])await t.test(name,async()=>{
await writeFile(marker,'');const result=run(options,args);assert.equal(result.status,status,result.stdout+result.stderr);
const calls=await commands(),ssh=calls.filter(call=>call.command==='ssh');
if(name==='package-only')assert.ok(!calls.some(call=>['ssh','scp'].includes(call.command)));
else if(name==='upload-failed'){
assert.equal(ssh.length,1);assert.doesNotMatch(ssh[0].args.at(-1),/run\.sh/);assert.doesNotMatch(result.stdout,/上传完成|执行完成/);
}else{
assert.equal(ssh.length,2);assert.equal(calls.at(-1).command,'ssh');
assert.equal(ssh[1].args.at(-1),`cd '${options.DEPLOY_RUN_DIR||runDirectory}' && bash run.sh`);
if(name==='run-failed')assert.doesNotMatch(result.stdout,/执行完成/);
}
});
await t.test('changing the configured default directory does not require updating path assertions',async()=>{
const script=join(root,'deploy_test.sh'),original=await readFile(script,'utf8');
const changed=original.replace(/^RUN_DIR=.*$/m,'RUN_DIR="${DEPLOY_RUN_DIR:-/srv/operator-default}"');
assert.notEqual(changed,original);
try{
await writeFile(script,changed);await writeFile(marker,'');
const directory=defaultRunDirectory();assert.equal(directory,'/srv/operator-default');
const result=run();assert.equal(result.status,0,result.stdout+result.stderr);
const calls=await commands();assert.equal(calls.at(-1).command,'ssh');
assert.equal(calls.at(-1).args.at(-1),`cd '${directory}' && bash run.sh`);
}finally{await writeFile(script,original);}
});
});
test('production uploads before running the exact allowed SSH command, and stops on failure',async t=>{
const {root}=await fixture(t),bin=join(root,'bin'),marker=join(root,'production-commands.jsonl');
await mkdir(bin);await mkdir(join(root,'node_modules/next'));
await cp(new URL('deploy_prod.sh',projectRoot),join(root,'deploy_prod.sh'));
for(const name of ['npm','ssh','scp']){
const code=`const fs=require('node:fs'),args=process.argv.slice(2);fs.appendFileSync(process.env.MARKER,JSON.stringify({command:${JSON.stringify(name)},args})+'\\n');`+
(name==='npm'?`if(args.join(' ')==='run bundle'){const result=require('node:child_process').spawnSync(process.execPath,['scripts/package-standalone.mjs','production'],{stdio:'inherit'});process.exit(result.status??1);}if(args[1]==='bundle:linux'){const destination='dist/production-linux-x64-glibc';fs.rmSync(destination,{recursive:true,force:true});fs.cpSync('dist/production',destination,{recursive:true});fs.writeFileSync(destination+'/runtime-target.json',JSON.stringify({platform:'linux',arch:'x64',libc:'glibc'}));}`:
name==='scp'?`process.exit(Number(process.env.SCP_EXIT||0));`:
`if(args.at(-1)!==process.env.ALLOWED_REMOTE_COMMAND){console.error('权限不足');process.exit(1);}process.stdout.write(process.env.RUN_OUTPUT||'');process.exit(Number(process.env.RUN_EXIT||0));`);
await writeFile(join(bin,name),'#!'+process.execPath+'\n'+code+'\n',{mode:0o755});
}
const archive='fixture_production.tar.gz';
const env={...process.env,PATH:bin+':'+process.env.PATH,MARKER:marker,DEPLOY_DRY_RUN:'0',DEPLOY_JUMP_HOST:'fixture-host',DEPLOY_JUMP_DIR:'/srv/production/',DEPLOY_SCP_PORT:'2203',DEPLOY_SSH_PORT:'2202',DEPLOY_TARGET_ARCH:'x64',DEPLOY_TARGET_LIBC:'glibc',DEPLOY_PACKAGE_NAME:archive,ALLOWED_REMOTE_COMMAND:'cd /srv/production && bash rsupdate.sh',SCP_EXIT:'0',RUN_EXIT:'0',RUN_OUTPUT:''};
const run=(options={},args=[])=>spawnSync('bash',['deploy_prod.sh',...args],{cwd:root,encoding:'utf8',env:{...env,...options}});
const commands=async()=>(await readFile(marker,'utf8')).trim().split('\n').filter(Boolean).map(line=>JSON.parse(line));
const result=run();assert.equal(result.status,0,result.stdout+result.stderr);
const calls=await commands();
assert.deepEqual(calls.map(call=>call.command),['npm','npm','npm','scp','ssh']);
assert.deepEqual(calls.filter(call=>call.command==='npm').map(call=>call.args),[['run','check'],['run','bundle'],['run','bundle:linux','--','x64','glibc']]);
assert.deepEqual(calls.find(call=>call.command==='scp').args,['-P','2203',archive,'fixture_production.md5','fixture-host:/srv/production/']);
assert.deepEqual(calls.find(call=>call.command==='ssh').args,['-p','2202','fixture-host',env.ALLOWED_REMOTE_COMMAND]);
assert.match(result.stdout,/正式服 rsupdate\.sh 执行完成/);
assert.equal(await readFile(join(root,'fixture_production.md5'),'utf8'),createHash('md5').update(await readFile(join(root,archive))).digest('hex')+'\n');
for(const [name,options,args,status] of [
['upload-failed',{SCP_EXIT:'23'},[],23],
['remote-failed',{RUN_EXIT:'42'},[],1],
['restricted-ssh-denied',{ALLOWED_REMOTE_COMMAND:'a different command'},[],1],
['remote-error-output',{RUN_OUTPUT:'FATAL: service did not start\n'},[],1],
['remote-curl-401',{RUN_OUTPUT:'curl: (22) The requested URL returned error: 401\n发布成功\n'},[],1],
['custom-directory',{DEPLOY_JUMP_DIR:'/srv/custom-production/',ALLOWED_REMOTE_COMMAND:'cd /srv/custom-production && bash rsupdate.sh'},[],0],
['package-only',{},['--package-only'],0],
])await t.test(name,async()=>{
await writeFile(marker,'');const result=run(options,args);assert.equal(result.status,status,result.stdout+result.stderr);
const calls=await commands();
if(name==='package-only')assert.ok(!calls.some(call=>['scp','ssh'].includes(call.command)));
else if(name==='upload-failed')assert.ok(!calls.some(call=>call.command==='ssh'));
else assert.equal(calls.at(-1).command,'ssh');
if(name==='restricted-ssh-denied')assert.match(result.stdout,/权限不足/);
if(name==='custom-directory'){
assert.equal(calls.at(-1).args.at(-1),options.ALLOWED_REMOTE_COMMAND);
assert.equal(calls.find(call=>call.command==='scp').args.at(-1),'fixture-host:/srv/custom-production/');
const preview=run(options,['--dry-run']);assert.equal(preview.status,0,preview.stderr);
assert.ok(preview.stdout.includes('"'+options.ALLOWED_REMOTE_COMMAND+'"'),preview.stdout);
}
if(status)assert.doesNotMatch(result.stdout,/执行完成/);
});
});
test('tar.gz server updates reject invalid archives before stopping services and roll back unhealthy replacements',async t=>{
for(const scenario of ['checksum','archive','link','traversal','install','health','success','custom-name'])await t.test(scenario,async t=>{
const root=await mkdtemp(join(tmpdir(),'91-server-update-'));t.after(()=>rm(root,{recursive:true,force:true}));
const bin=join(root,'bin'),upload=join(root,'package');await mkdir(bin);await mkdir(upload);
async function put(base,path,text='fixture') {await mkdir(join(base,path,'..'),{recursive:true});await writeFile(join(base,path),text);}
await cp(new URL('deploy/up_next_h5.sh',projectRoot),join(root,'up_next_h5.sh'));
await put(root,'next/version','previous');await put(root,'next/config/test.local.env','PORT=21022\n');
await put(root,'next/.next-test/cache/seo/keep.json','previous public SEO');
for(const file of ['start.mjs','server.js','.next-test/BUILD_ID','.next-test/static/app.js','public/logo.svg','package-lock.json'])await put(upload,file);
await put(upload,'package.json','{}');await put(upload,'version','replacement');
await put(upload,'config/test.env','API_BASE_URL=https://fixture.test\nPORT=21021\n');
await cp(new URL('scripts/next-env.mjs',projectRoot),join(upload,'scripts/next-env.mjs'));
await put(upload,'scripts/check-health.mjs','process.exit(Number(process.env.HEALTH_EXIT||0));');
const packageName=scenario==='custom-name'?'custom_release.tar.gz':'web_91dj_h5.tar.gz';
if(scenario==='link')await symlink(join(root,'untouched'),join(upload,'public/unsafe-link'));
const packed=spawnSync('tar',['--no-xattrs','-czf',join(root,packageName),'-C',upload,'.'],{encoding:'utf8',env:{...process.env,COPYFILE_DISABLE:'1'}});assert.equal(packed.status,0,packed.stderr);
if(scenario==='archive')await writeFile(join(root,packageName),'not a gzip archive');
if(scenario==='traversal') {
const header=Buffer.alloc(512);
for(const [offset,value]of [[0,'../escaped-file'],[100,'0000644\0'],[108,'0000000\0'],[116,'0000000\0'],[124,'00000000000\0'],[136,'00000000000\0'],[257,'ustar\0'],[263,'00']])header.write(value,offset);
header.fill(32,148,156);header[156]=48;
header.write([...header].reduce((total,byte)=>total+byte,0).toString(8).padStart(6,'0')+'\0 ',148);
await writeFile(join(root,packageName),gzipSync(Buffer.concat([header,Buffer.alloc(1024)])));
}
const hash=createHash('sha256').update(await readFile(join(root,packageName))).digest('hex');
await writeFile(join(root,packageName+'.sha256'),(scenario==='checksum'?'0'.repeat(64):hash)+' '+packageName+'\n');
const stubs={
npm:`const fs=require('node:fs');fs.appendFileSync(process.env.MARKER,'npm\\n');if(process.env.INSTALL_FAIL==='1')process.exit(1);fs.mkdirSync('node_modules',{recursive:true});fs.cpSync(process.env.DOTENV_SOURCE,'node_modules/dotenv',{recursive:true});`,
supervisorctl:`const fs=require('node:fs');fs.appendFileSync(process.env.MARKER,process.argv.slice(2).join(' ')+'\\n');if(process.argv[2]==='status')console.log('91dj_next_h5 RUNNING pid 100');`,
sha256sum:`const fs=require('node:fs'),crypto=require('node:crypto');const expected=fs.readFileSync(process.argv[3],'utf8').split(/\\s/)[0],actual=crypto.createHash('sha256').update(fs.readFileSync(process.argv[3].replace(/\\.sha256$/,''))).digest('hex');process.exit(expected===actual?0:1);`,
};
for(const [name,code]of Object.entries(stubs))await writeFile(join(bin,name),'#!'+process.execPath+'\n'+code+'\n',{mode:0o755});
const marker=join(root,'commands');
// The fake Supervisor has its own service; caller deployment overrides must not select a real service here.
const result=spawnSync('bash',[join(root,'up_next_h5.sh')],{encoding:'utf8',env:{...process.env,DEPLOY_PROGRAM:'91dj_next_h5',DEPLOY_PACKAGE_NAME:packageName,PATH:bin+':'+process.env.PATH,MARKER:marker,DOTENV_SOURCE:new URL('node_modules/dotenv',projectRoot).pathname,INSTALL_FAIL:scenario==='install'?'1':'0',HEALTH_EXIT:scenario==='health'?'1':'0'}});
const commands=await readFile(marker,'utf8');
if(['success','custom-name'].includes(scenario)) {
assert.equal(result.status,0,result.stderr);assert.equal(await readFile(join(root,'next/version'),'utf8'),'replacement');
assert.equal(await readFile(join(root,'next/config/test.local.env'),'utf8'),'PORT=21022\n');
assert.equal(await readFile(join(root,'next/.next-test/cache/seo/keep.json'),'utf8'),'previous public SEO');
}else {
assert.notEqual(result.status,0);assert.equal(await readFile(join(root,'next/version'),'utf8'),'previous');
if(scenario==='health')assert.equal(commands.match(/start 91dj_next_h5/g)?.length,2,commands);
else assert.doesNotMatch(commands,/stop |start /);
if(['checksum','archive','link','traversal'].includes(scenario))assert.doesNotMatch(commands,/npm/);
if(scenario==='archive')assert.match(result.stderr,/tar.gz 压缩包损坏/);
if(scenario==='link')assert.match(result.stderr,/不允许链接/);
if(scenario==='traversal')assert.match(result.stderr,/非法 tar.gz 路径/);
}
await assert.rejects(stat(join(root,'escaped-file')),{code:'ENOENT'});
await assert.rejects(stat(join(root,'.91dj_next_h5.lock')),{code:'ENOENT'});
});
});