test(installer): cover Windows release script regressions

This commit is contained in:
yiliang114 2026-05-18 10:33:13 +08:00
parent bab96dc094
commit 3caf3e2f2a
2 changed files with 232 additions and 20 deletions

View file

@ -3054,6 +3054,70 @@ describe('Windows installer end-to-end', () => {
});
});
describe('Windows PowerShell uninstaller end-to-end', () => {
itOnWindows('prints help without deleting standalone files', () => {
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-'));
try {
const installRoot = path.join(tmpDir, 'install');
const installDir = path.join(installRoot, 'qwen-code');
const home = path.join(tmpDir, 'home');
createFakeWindowsStandaloneInstall(installRoot);
const output = runWindowsPowerShellScript(
'scripts/installation/uninstall-qwen-standalone.ps1',
['-Help'],
{
USERPROFILE: home,
QWEN_INSTALL_ROOT: installRoot,
},
).toString();
expect(output).toContain('Usage:');
expect(output).toContain('-Purge');
expect(existsSync(installDir)).toBe(true);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
itOnWindows('purges the source marker while preserving other config', () => {
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-'));
try {
const installRoot = path.join(tmpDir, 'install');
const installDir = path.join(installRoot, 'qwen-code');
const installBinDir = path.join(installRoot, 'bin');
const home = path.join(tmpDir, 'home');
const qwenConfigDir = path.join(home, '.qwen');
const sourceMarker = path.join(qwenConfigDir, 'source.json');
const settingsFile = path.join(qwenConfigDir, 'settings.json');
createFakeWindowsStandaloneInstall(installRoot);
mkdirSync(qwenConfigDir, { recursive: true });
writeFileSync(sourceMarker, '{"source":"smoke"}\n');
writeFileSync(settingsFile, '{"theme":"dark"}\n');
const output = runWindowsPowerShellScript(
'scripts/installation/uninstall-qwen-standalone.ps1',
['-Purge'],
{
USERPROFILE: home,
QWEN_INSTALL_ROOT: installRoot,
},
).toString();
expect(output).toContain('Removed');
expect(existsSync(installDir)).toBe(false);
expect(existsSync(path.join(installBinDir, 'qwen.cmd'))).toBe(false);
expect(existsSync(sourceMarker)).toBe(false);
expect(readScript(settingsFile)).toContain('"theme":"dark"');
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
});
function ensureMinimalDist() {
const distPath = path.resolve('dist');
const backupPath = existsSync(distPath)
@ -3185,6 +3249,30 @@ function createFakeWindowsStandaloneArchive(tmpDir) {
return archive;
}
function createFakeWindowsStandaloneInstall(installRoot) {
const installDir = path.join(installRoot, 'qwen-code');
const installBinDir = path.join(installRoot, 'bin');
mkdirSync(path.join(installDir, 'bin'), { recursive: true });
mkdirSync(path.join(installDir, 'node'), { recursive: true });
mkdirSync(installBinDir, { recursive: true });
writeFileSync(
path.join(installDir, 'manifest.json'),
JSON.stringify({ name: '@qwen-code/qwen-code', target: 'win-x64' }),
);
writeFileSync(
path.join(installDir, 'bin', 'qwen.cmd'),
['@echo off', 'echo 0.0.0-smoke', ''].join('\r\n'),
);
writeFileSync(path.join(installDir, 'node', 'node.exe'), 'fake node.exe\n');
writeFileSync(
path.join(installBinDir, 'qwen.cmd'),
['@echo off', `"${path.join(installDir, 'bin', 'qwen.cmd')}" %*`, ''].join(
'\r\n',
),
);
}
function createFakeWindowsNpmTools(fakeBin) {
mkdirSync(fakeBin, { recursive: true });
writeFileSync(
@ -3222,7 +3310,19 @@ function createFakeWindowsCurlCommand(fakeBin) {
'if "%~1"=="" goto done_parse',
'set "arg=%~1"',
'if "!arg:~0,1!"=="-" (',
' echo(!arg! | findstr /C:"o" >nul && (',
' if /i "!arg!"=="-o" (',
' shift',
' set "destination=%~1"',
' shift',
' goto parse_args',
' )',
' if /i "!arg!"=="--output" (',
' shift',
' set "destination=%~1"',
' shift',
' goto parse_args',
' )',
' if not "!arg:~0,2!"=="--" if /i "!arg:~-1!"=="o" (',
' shift',
' set "destination=%~1"',
' shift',
@ -3457,18 +3557,61 @@ function runWindowsInstaller(
function runWindowsCommand(command, env = {}) {
const prepared = prepareWindowsCommand(command, env);
return execFileSync(
process.env.ComSpec || 'cmd.exe',
['/d', '/c', prepared.command],
{
env: {
...prepared.env,
try {
return execFileSync(
process.env.ComSpec || 'cmd.exe',
['/d', '/c', prepared.command],
{
env: {
...prepared.env,
},
stdio: 'pipe',
// cmd.exe parses the command string itself; preserve quoted paths.
windowsVerbatimArguments: true,
},
stdio: 'pipe',
// cmd.exe parses the command string itself; preserve quoted paths.
windowsVerbatimArguments: true,
},
);
);
} catch (error) {
const processError = error;
throw new Error(
[
processError.message,
processError.stdout?.toString() || '',
processError.stderr?.toString() || '',
].join('\n'),
);
}
}
function runWindowsPowerShellScript(scriptPath, args = [], env = {}) {
try {
return execFileSync(
'powershell',
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
path.resolve(scriptPath),
...args,
],
{
env: {
...process.env,
...env,
},
stdio: 'pipe',
},
);
} catch (error) {
const processError = error;
throw new Error(
[
processError.message,
processError.stdout?.toString() || '',
processError.stderr?.toString() || '',
].join('\n'),
);
}
}
const WINDOWS_COMMAND_ENV_OVERRIDES = [

View file

@ -83,10 +83,76 @@ describe('parseUploadArgs', () => {
});
describe('uploadAssets (integration)', () => {
function prependProcessPath(directory) {
const pathKeys = Object.keys(process.env).filter(
(key) => key.toLowerCase() === 'path',
);
const pathKey = pathKeys[0] || 'PATH';
const previousValues = new Map(
pathKeys.map((key) => [key, process.env[key]]),
);
const nextValue = `${directory}${path.delimiter}${process.env[pathKey] || ''}`;
if (pathKeys.length === 0) {
process.env[pathKey] = nextValue;
} else {
for (const key of pathKeys) {
process.env[key] = nextValue;
}
}
return () => {
if (previousValues.size === 0) {
delete process.env[pathKey];
return;
}
for (const key of pathKeys) {
const previousValue = previousValues.get(key);
if (previousValue === undefined) {
delete process.env[key];
} else {
process.env[key] = previousValue;
}
}
};
}
function makeOssutilShim(workDir, behavior = 'success') {
fs.mkdirSync(workDir, { recursive: true });
const ossutilPath = path.join(workDir, 'ossutil');
const ossutilPath = path.join(
workDir,
process.platform === 'win32' ? 'ossutil.cmd' : 'ossutil',
);
const logPath = path.join(workDir, 'ossutil.log');
if (process.platform === 'win32') {
const successScript = [
'@echo off',
':log_args',
'if "%~1"=="" goto done_log_args',
`>>"${logPath}" echo(%~1`,
'shift',
'goto log_args',
':done_log_args',
'exit /b 0',
'',
].join('\r\n');
const failScript = [
'@echo off',
':log_args',
'if "%~1"=="" goto done_log_args',
`>>"${logPath}" echo(%~1`,
'shift',
'goto log_args',
':done_log_args',
'exit /b 1',
'',
].join('\r\n');
fs.writeFileSync(
ossutilPath,
behavior === 'fail' ? failScript : successScript,
);
return { ossutilPath, logPath };
}
const successScript = `#!/usr/bin/env bash
printf '%s\\n' "$@" >> "${logPath}"
exit 0
@ -115,8 +181,7 @@ exit 1
const configPath = path.join(tmp, '.ossutilconfig');
fs.writeFileSync(configPath, '[Credentials]\n');
const previousPath = process.env.PATH;
process.env.PATH = `${tmp}${path.delimiter}${previousPath}`;
const restorePath = prependProcessPath(tmp);
try {
uploadAssets({
assets,
@ -125,7 +190,7 @@ exit 1
prefix: 'releases/qwen-code/v0.0.0',
});
} finally {
process.env.PATH = previousPath;
restorePath();
}
const log = fs.readFileSync(logPath, 'utf8');
@ -145,14 +210,13 @@ exit 1
it('aggregates failures from ossutil non-zero exits', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-upload-fail-'));
try {
makeOssutilShim(tmp, 'fail');
const { logPath } = makeOssutilShim(tmp, 'fail');
const assetPath = path.join(tmp, 'asset.tar.gz');
fs.writeFileSync(assetPath, 'asset');
const configPath = path.join(tmp, '.ossutilconfig');
fs.writeFileSync(configPath, '[Credentials]\n');
const previousPath = process.env.PATH;
process.env.PATH = `${tmp}${path.delimiter}${previousPath}`;
const restorePath = prependProcessPath(tmp);
try {
expect(() =>
uploadAssets({
@ -162,8 +226,13 @@ exit 1
prefix: 'releases/qwen-code/v0.0.0',
}),
).toThrow(/ossutil failed after 3 attempts/);
const uploadAttempts = fs
.readFileSync(logPath, 'utf8')
.split(/\r?\n/)
.filter((line) => line === assetPath);
expect(uploadAttempts).toHaveLength(3);
} finally {
process.env.PATH = previousPath;
restorePath();
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });