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'}); }); });