mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-27 09:33:27 +00:00
* fix(desktop): bridge Electron users on Windows and Linux * test(desktop): satisfy bridge contract lint * fix(desktop): harden Electron bridge migration * chore(desktop): note sibling-script regex sync for bridge artifacts * fix(desktop): satisfy release manifest lint * fix(desktop): validate Electron uninstaller path before migration The migration ExecWait target is assembled from the HKCU InstallLocation registry value; require the uninstaller executable to exist before running it, and pin the conjunct in the release contract suite.
1089 lines
34 KiB
JavaScript
Executable file
1089 lines
34 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { resolveLogRoot, sliceNewLog } from './resolve-log-root.js';
|
|
|
|
const packageDir = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
);
|
|
const repoRoot = path.resolve(packageDir, '../..');
|
|
const manifestScript = path.join(
|
|
repoRoot,
|
|
'.github',
|
|
'scripts',
|
|
'create-desktop-update-manifest.mjs',
|
|
);
|
|
const electronBridgeScript = path.join(
|
|
repoRoot,
|
|
'.github',
|
|
'scripts',
|
|
'create-electron-bridge-manifest.mjs',
|
|
);
|
|
const versionScript = path.join(packageDir, 'scripts', 'version.js');
|
|
const tauriConfig = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(packageDir, 'src-tauri', 'tauri.conf.json'),
|
|
'utf8',
|
|
),
|
|
);
|
|
|
|
const root = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'qwen-desktop-release-test-'),
|
|
);
|
|
try {
|
|
testBootstrapBridgeConfiguration();
|
|
await testBootstrapWorkspaceVisibility();
|
|
testLegacyApplicationIdentity();
|
|
testElectronBridgeWorkflow();
|
|
testDesktopReleaseSigningWorkflow();
|
|
testDesktopReleaseHardening();
|
|
testUpdaterMirrorConfiguration();
|
|
testResolveLogRoot();
|
|
testSliceNewLog();
|
|
testUpdateManifest(path.join(root, 'manifest'));
|
|
testElectronBridgeManifest(path.join(root, 'electron-bridge'));
|
|
testVersionSynchronization(path.join(root, 'version'));
|
|
testRuntimePreparation(path.join(root, 'runtime'));
|
|
console.log('Desktop release helper checks passed.');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
|
|
async function testBootstrapWorkspaceVisibility() {
|
|
const bootstrapHtml = fs.readFileSync(
|
|
path.join(packageDir, 'bootstrap', 'index.html'),
|
|
'utf8',
|
|
);
|
|
assert.match(bootstrapHtml, /class="mark" src="qwen-code-logo\.svg"/);
|
|
assert.ok(
|
|
fs.existsSync(path.join(packageDir, 'bootstrap', 'qwen-code-logo.svg')),
|
|
'The bootstrap splash mark must ship with the frontendDist directory.',
|
|
);
|
|
assert.doesNotMatch(bootstrapHtml, /class="mark">Q</);
|
|
const reducedMotionBlock = bootstrapHtml.match(
|
|
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)(?:@media|<\/style>)/,
|
|
);
|
|
assert.ok(
|
|
reducedMotionBlock,
|
|
'The bootstrap splash must keep a reduced-motion media block.',
|
|
);
|
|
for (const centeringRule of [
|
|
/body\[data-state='starting'\] \.brand \{[^}]*justify-content: center;[^}]*\}/,
|
|
/body\[data-state='starting'\] \.status \{[^}]*text-align: center;[^}]*\}/,
|
|
]) {
|
|
assert.match(
|
|
reducedMotionBlock[1],
|
|
centeringRule,
|
|
'The reduced-motion startup view must keep the logo and status text on the same horizontal center.',
|
|
);
|
|
}
|
|
const runtimeSource = fs.readFileSync(
|
|
path.join(packageDir, 'src-tauri', 'src', 'runtime.rs'),
|
|
'utf8',
|
|
);
|
|
assert.match(
|
|
runtimeSource,
|
|
/let mut child = spawn_runtime_group\(&mut command\)/,
|
|
'DesktopRuntime::start must spawn the runtime through the hidden-console helper.',
|
|
);
|
|
const primary = await createBootstrapHarness();
|
|
const { body, commands, element, listeners, resolveBootstrapState } = primary;
|
|
|
|
listeners['runtime-starting']({
|
|
payload: '/Users/example/Projects/qwen-code',
|
|
});
|
|
assert.equal(body.dataset.state, 'starting');
|
|
assert.equal(element('#workspace').hidden, true);
|
|
|
|
listeners['runtime-failed']({ payload: 'runtime failed' });
|
|
assert.equal(body.dataset.state, 'error');
|
|
assert.equal(element('#workspace').hidden, false);
|
|
assert.equal(
|
|
element('#workspace').textContent,
|
|
'/Users/example/Projects/qwen-code',
|
|
);
|
|
await element('#logs').listeners.click();
|
|
assert.equal(element('#workspace').hidden, false);
|
|
|
|
element('#retry').listeners.click();
|
|
assert.equal(commands.at(-1), 'restart_runtime');
|
|
assert.equal(body.dataset.state, 'starting');
|
|
assert.equal(element('#workspace').hidden, true);
|
|
|
|
resolveBootstrapState({
|
|
desktopVersion: '0.2.0',
|
|
status: 'starting',
|
|
workspace: '/Users/example/Documents',
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(element('#title').textContent, 'Restarting Qwen Code');
|
|
assert.equal(
|
|
element('#workspace').hidden,
|
|
true,
|
|
'A stale bootstrap snapshot must not overwrite a newer recovery action.',
|
|
);
|
|
|
|
const failed = await createBootstrapHarness();
|
|
failed.listeners['runtime-failed']({ payload: 'runtime failed' });
|
|
failed.resolveBootstrapState({
|
|
desktopVersion: '0.2.0',
|
|
status: 'idle',
|
|
workspace: '/Users/example/Documents/Qwen',
|
|
error: 'runtime failed',
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(failed.element('#workspace').hidden, false);
|
|
assert.equal(
|
|
failed.element('#workspace').textContent,
|
|
'/Users/example/Documents/Qwen',
|
|
);
|
|
|
|
const cancelled = await createBootstrapHarness();
|
|
cancelled.listeners['runtime-starting']({
|
|
payload: '/Users/example/Documents/Qwen',
|
|
});
|
|
cancelled.listeners['runtime-failed']({ payload: 'runtime failed' });
|
|
await cancelled.element('#choose').listeners.click();
|
|
assert.equal(cancelled.body.dataset.state, 'idle');
|
|
assert.equal(cancelled.element('#workspace').hidden, false);
|
|
assert.equal(
|
|
cancelled.element('#workspace').textContent,
|
|
'/Users/example/Documents/Qwen',
|
|
);
|
|
}
|
|
|
|
async function createBootstrapHarness() {
|
|
const elements = {};
|
|
const element = (selector) => {
|
|
elements[selector] ??= {
|
|
addEventListener(event, listener) {
|
|
this.listeners ??= {};
|
|
this.listeners[event] = listener;
|
|
},
|
|
style: {},
|
|
};
|
|
return elements[selector];
|
|
};
|
|
const listeners = {};
|
|
const commands = [];
|
|
const body = { dataset: {} };
|
|
let resolveBootstrapState;
|
|
const tauri = {
|
|
core: {
|
|
invoke: async (command) => {
|
|
commands.push(command);
|
|
if (command === 'bootstrap_state') {
|
|
return new Promise((resolve) => {
|
|
resolveBootstrapState = resolve;
|
|
});
|
|
}
|
|
if (command === 'open_logs') throw new Error('no file handler');
|
|
if (command === 'choose_workspace') return null;
|
|
if (command === 'restart_runtime') return new Promise(() => {});
|
|
throw new Error(`Unexpected desktop command: ${command}`);
|
|
},
|
|
},
|
|
event: {
|
|
listen: async (event, listener) => {
|
|
listeners[event] = listener;
|
|
},
|
|
},
|
|
};
|
|
vm.runInNewContext(
|
|
fs.readFileSync(path.join(packageDir, 'bootstrap', 'bootstrap.js'), 'utf8'),
|
|
{
|
|
document: { body, querySelector: element },
|
|
window: { __TAURI__: tauri },
|
|
},
|
|
{ timeout: 5000 },
|
|
);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
return {
|
|
body,
|
|
commands,
|
|
element,
|
|
listeners,
|
|
resolveBootstrapState: (state) => resolveBootstrapState(state),
|
|
};
|
|
}
|
|
|
|
function testLegacyApplicationIdentity() {
|
|
const config = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(packageDir, 'src-tauri', 'tauri.conf.json'),
|
|
'utf8',
|
|
),
|
|
);
|
|
assert.equal(config.productName, 'Qwen Code Desktop');
|
|
assert.equal(config.identifier, 'com.alibaba.qwen-code');
|
|
assert.equal(
|
|
config.bundle.windows.nsis.installerHooks,
|
|
'windows/electron-migration.nsh',
|
|
);
|
|
const migrationHook = fs.readFileSync(
|
|
path.join(packageDir, 'src-tauri', 'windows', 'electron-migration.nsh'),
|
|
'utf8',
|
|
);
|
|
assert.match(migrationHook, /Software\\821b18a9-7c63-5bb4-9e20-51ba63d5ecc3/);
|
|
assert.match(migrationHook, /!macro NSIS_HOOK_PREINSTALL/);
|
|
assert.match(
|
|
migrationHook,
|
|
/StrCpy \$R1 \$R1 17\s*\n\s*\$\{If\} \$R0 != ""\s*\n\s*\$\{AndIf\} \$R1 == "Qwen Code Desktop"/,
|
|
);
|
|
assert.match(
|
|
migrationHook,
|
|
/\$\{AndIf\} \$\{FileExists\} "\$R0\\Uninstall Qwen Code Desktop\.exe"/,
|
|
);
|
|
assert.match(
|
|
migrationHook,
|
|
/ExecWait '"\$R0\\Uninstall Qwen Code Desktop\.exe" \/currentuser \/S --updated _\?=\$R0'/,
|
|
);
|
|
assert.match(migrationHook, /\$\{If\} \$R2 != 0\s*\n\s*Abort/);
|
|
}
|
|
|
|
function testElectronBridgeWorkflow() {
|
|
const workflow = fs.readFileSync(
|
|
path.join(repoRoot, '.github', 'workflows', 'desktop-release.yml'),
|
|
'utf8',
|
|
);
|
|
assert.match(workflow, /^ {6}electron_bridge:$/m);
|
|
assert.match(workflow, /create-electron-bridge-manifest\.mjs/);
|
|
assert.match(workflow, /macos:latest-mac\.yml/);
|
|
assert.match(workflow, /windows:latest\.yml/);
|
|
assert.match(workflow, /linux:latest-linux\.yml/);
|
|
assert.match(
|
|
workflow,
|
|
/windows_installers=\(release-assets\/\*-setup\.exe\)/,
|
|
);
|
|
assert.match(workflow, /linux_appimages=\(release-assets\/\*\.AppImage\)/);
|
|
assert.match(workflow, /^\s+release-assets\/latest\.yml$/m);
|
|
assert.match(workflow, /^\s+release-assets\/latest-linux\.yml$/m);
|
|
assert.match(workflow, /^\s+"\$\{windows_installers\[0\]\}"$/m);
|
|
assert.match(workflow, /^\s+"\$\{linux_appimages\[0\]\}"$/m);
|
|
assert.match(
|
|
workflow,
|
|
/if \[ "\$ELECTRON_BRIDGE" = 'true' \]; then\s+echo "::error::Electron bridge \$RELEASE_VERSION cannot replace newer stable feed \$current\."\s+exit 1/,
|
|
);
|
|
for (const artifact of [
|
|
'Qwen-Code-Desktop-arm64.zip',
|
|
'Qwen-Code-Desktop-x64.zip',
|
|
'Qwen-Code-Desktop-arm64.dmg',
|
|
'Qwen-Code-Desktop-x64.dmg',
|
|
]) {
|
|
assert.match(workflow, new RegExp(artifact.replaceAll('.', '\\.')));
|
|
}
|
|
}
|
|
|
|
function testDesktopReleaseSigningWorkflow() {
|
|
const workflow = fs.readFileSync(
|
|
path.join(repoRoot, '.github', 'workflows', 'desktop-release.yml'),
|
|
'utf8',
|
|
);
|
|
const primaryIncomplete =
|
|
'$primaryIncomplete = ([bool]$env:WINDOWS_CERTIFICATE) -ne ' +
|
|
'([bool]$env:WINDOWS_CERTIFICATE_PASSWORD)';
|
|
const legacyIncomplete =
|
|
'$legacyIncomplete = ([bool]$env:LEGACY_WIN_CSC_LINK) -ne ' +
|
|
'([bool]$env:LEGACY_WIN_CSC_KEY_PASSWORD)';
|
|
assert.ok(
|
|
workflow.includes(primaryIncomplete),
|
|
'Windows signing must fail closed when the primary certificate pair is incomplete',
|
|
);
|
|
assert.ok(
|
|
workflow.includes(legacyIncomplete),
|
|
'Windows signing must fail closed when the legacy certificate pair is incomplete',
|
|
);
|
|
assert.ok(
|
|
workflow.includes(
|
|
'elif [ "$RUNNER_OS" = \'Windows\' ] && [ -n "$WINDOWS_CONFIG" ]; then',
|
|
),
|
|
'Windows builds must only pass a Tauri config when signing config exists',
|
|
);
|
|
assert.ok(
|
|
workflow.includes(
|
|
"$signature.Status -eq 'NotSigned' -and -not $env:WINDOWS_CONFIG",
|
|
),
|
|
'Unsigned Windows installers are only allowed when no signing config exists',
|
|
);
|
|
const ripgrepStart = workflow.indexOf('# ripgrep vendor binaries');
|
|
const ripgrepEnd = workflow.indexOf('# Node.js runtime binary');
|
|
assert.ok(
|
|
ripgrepStart !== -1 && ripgrepEnd > ripgrepStart,
|
|
'the vendor signing step must keep its ripgrep/Node section markers',
|
|
);
|
|
const ripgrepSigningBlock = workflow.slice(ripgrepStart, ripgrepEnd);
|
|
assert.doesNotMatch(
|
|
ripgrepSigningBlock,
|
|
/--entitlements/,
|
|
'ripgrep must not inherit the app entitlements',
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/--options runtime --timestamp \\\n\s+\{\} \+/,
|
|
'ripgrep codesign failures must fail the signing step',
|
|
);
|
|
assert.ok(
|
|
workflow.includes(
|
|
'--entitlements src-tauri/NodeEntitlements.plist "$node_bin"',
|
|
),
|
|
'Node.js must use its minimal helper entitlements',
|
|
);
|
|
const nodeEntitlements = fs.readFileSync(
|
|
path.join(packageDir, 'src-tauri', 'NodeEntitlements.plist'),
|
|
'utf8',
|
|
);
|
|
const appEntitlements = fs.readFileSync(
|
|
path.join(packageDir, 'src-tauri', 'Entitlements.plist'),
|
|
'utf8',
|
|
);
|
|
assert.match(
|
|
appEntitlements,
|
|
/<key>com\.apple\.security\.device\.audio-input<\/key>\s*<true\/>/,
|
|
'the app bundle must keep microphone access for voice dictation',
|
|
);
|
|
const infoPlist = fs.readFileSync(
|
|
path.join(packageDir, 'src-tauri', 'Info.plist'),
|
|
'utf8',
|
|
);
|
|
assert.match(
|
|
infoPlist,
|
|
/NSMicrophoneUsageDescription<\/key>\s*<string>.+<\/string>/,
|
|
'the app bundle must declare a non-empty microphone usage description',
|
|
);
|
|
assert.match(
|
|
nodeEntitlements,
|
|
/<key>com\.apple\.security\.cs\.allow-jit<\/key>\s*<true\/>/,
|
|
'the bundled Node.js runtime must keep its JIT entitlement',
|
|
);
|
|
assert.doesNotMatch(
|
|
nodeEntitlements,
|
|
/com\.apple\.security\.device\.audio-input/,
|
|
'Node.js must not receive microphone access',
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/Ripgrep vendor directory not found at \$rg_dir/,
|
|
'missing ripgrep binaries must be visible in release logs',
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/Node\.js runtime binary not found at \$node_bin/,
|
|
'missing Node.js runtime binary must be visible in release logs',
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/Print :com\.apple\.security\.device\.audio-input/,
|
|
'the macOS signature check must keep verifying the audio-input entitlement',
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/Print :NSMicrophoneUsageDescription/,
|
|
'the packaged smoke must keep verifying the microphone usage description',
|
|
);
|
|
assert.ok(
|
|
workflow.indexOf("name: 'Prepare bundled runtime'") <
|
|
workflow.indexOf("name: 'Sign bundled vendor binaries (macOS)'"),
|
|
'vendor binaries must be signed after the runtime is prepared',
|
|
);
|
|
assert.ok(
|
|
workflow.indexOf("name: 'Sign bundled vendor binaries (macOS)'") <
|
|
workflow.indexOf("name: 'Build desktop installers'"),
|
|
'vendor binaries must be signed before Tauri builds installers',
|
|
);
|
|
}
|
|
|
|
function testDesktopReleaseHardening() {
|
|
const workflow = fs.readFileSync(
|
|
path.join(repoRoot, '.github', 'workflows', 'desktop-release.yml'),
|
|
'utf8',
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/IS_PRERELEASE" = 'true' \] && \[\[ ! "\$version" =~ \^\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+-/,
|
|
'prerelease builds must not reuse a stable Desktop version',
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/\*-setup\.exe\|\*-setup\.exe\.sig/,
|
|
'Windows release collection must allow only installer executables',
|
|
);
|
|
assert.doesNotMatch(
|
|
workflow.slice(
|
|
workflow.indexOf('elif [ "$RUNNER_OS" = \'Windows\' ]'),
|
|
workflow.indexOf('elif [ "$RUNNER_OS" = \'Linux\' ]'),
|
|
),
|
|
/\*\.exe\)/,
|
|
'Windows release collection must not include embedded executables',
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/\*\.AppImage\|\*\.AppImage\.sig\|\*\.deb\|\*\.deb\.sig/,
|
|
'Linux release collection must allow only installers and updater signatures',
|
|
);
|
|
|
|
const prepareRuntime = fs.readFileSync(
|
|
path.join(packageDir, 'scripts', 'prepare-runtime.js'),
|
|
'utf8',
|
|
);
|
|
assert.match(
|
|
prepareRuntime,
|
|
/QWEN_DESKTOP_NODE_CACHE_DIR/,
|
|
'runtime preparation must cache verified Node.js archives',
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/desktop-node-v2-\$\{\{ matrix\.rust_target \}\}-\$\{\{ env\.NODE_VERSION \}\}-\$\{\{ inputs\.dry_run \}\}/,
|
|
'release builds must persist the bundled Node.js archive cache',
|
|
);
|
|
assert.match(workflow, /actions\/cache\/restore@/);
|
|
assert.match(workflow, /actions\/cache\/save@/);
|
|
assert.equal(
|
|
(
|
|
workflow.match(
|
|
/path: '\$\{\{ steps\.node-cache-path\.outputs\.path \}\}'/g,
|
|
) ?? []
|
|
).length,
|
|
2,
|
|
'cache restore and save must share the configured cache path',
|
|
);
|
|
assert.ok(
|
|
prepareRuntime.indexOf('replaceRuntime();') >
|
|
prepareRuntime.indexOf('writeChecksums();'),
|
|
'runtime replacement must happen only after assembly and checksums finish',
|
|
);
|
|
}
|
|
|
|
function testRuntimePreparation(directory) {
|
|
const testPackageDir = path.join(directory, 'packages', 'desktop-shell');
|
|
const testScript = path.join(testPackageDir, 'scripts', 'prepare-runtime.js');
|
|
const sourceRoot = path.join(directory, 'source');
|
|
const runtimeDir = path.join(testPackageDir, 'runtime');
|
|
const cacheRoot = path.join(directory, 'cache');
|
|
const nodeVersion = process.versions.node;
|
|
const archiveName = `node-v${nodeVersion}-darwin-arm64.tar.gz`;
|
|
const cacheDir = path.join(cacheRoot, `v${nodeVersion}`);
|
|
const cachedArchivePath = path.join(cacheDir, archiveName);
|
|
const archivePath = path.join(directory, archiveName);
|
|
const checksumsPath = path.join(directory, 'SHASUMS256.txt');
|
|
const fetchLog = path.join(directory, 'fetch.log');
|
|
const fetchMock = path.join(directory, 'mock-fetch.mjs');
|
|
const extractedRoot = path.join(
|
|
directory,
|
|
`node-v${nodeVersion}-darwin-arm64`,
|
|
);
|
|
|
|
fs.mkdirSync(path.join(sourceRoot, 'dist', 'web-shell', 'assets'), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(sourceRoot, 'package.json'),
|
|
JSON.stringify({ version: '0.0.0-test' }),
|
|
);
|
|
fs.mkdirSync(path.dirname(testScript), { recursive: true });
|
|
fs.copyFileSync(
|
|
path.join(packageDir, 'scripts', 'prepare-runtime.js'),
|
|
testScript,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(directory, '.nvmrc'),
|
|
`${process.versions.node.split('.')[0]}\n`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(testPackageDir, 'package.json'),
|
|
JSON.stringify({ version: '0.0.0-test' }),
|
|
);
|
|
fs.writeFileSync(path.join(testPackageDir, 'NOTICE'), 'test notice');
|
|
fs.writeFileSync(path.join(sourceRoot, 'LICENSE'), 'test license');
|
|
for (const file of [
|
|
'cli.js',
|
|
'cli-entry.js',
|
|
path.join('web-shell', 'index.html'),
|
|
path.join('web-shell', 'assets', 'app.js'),
|
|
]) {
|
|
fs.writeFileSync(path.join(sourceRoot, 'dist', file), 'test');
|
|
}
|
|
fs.mkdirSync(path.join(extractedRoot, 'bin'), { recursive: true });
|
|
fs.writeFileSync(path.join(extractedRoot, 'bin', 'node'), 'node');
|
|
fs.writeFileSync(path.join(extractedRoot, 'LICENSE'), 'node license');
|
|
execFileSync('tar', [
|
|
'-czf',
|
|
archivePath,
|
|
'-C',
|
|
directory,
|
|
path.basename(extractedRoot),
|
|
]);
|
|
const archiveHash = crypto
|
|
.createHash('sha256')
|
|
.update(fs.readFileSync(archivePath))
|
|
.digest('hex');
|
|
fs.writeFileSync(checksumsPath, `${archiveHash} ${archiveName}\n`);
|
|
fs.writeFileSync(
|
|
fetchMock,
|
|
`import fs from 'node:fs';
|
|
globalThis.fetch = async (url) => {
|
|
const value = String(url);
|
|
const source = value.endsWith('/SHASUMS256.txt')
|
|
? process.env.QWEN_TEST_NODE_CHECKSUMS
|
|
: process.env.QWEN_TEST_NODE_ARCHIVE;
|
|
fs.appendFileSync(process.env.QWEN_TEST_FETCH_LOG, value + '\\n');
|
|
return new Response(fs.readFileSync(source), { status: 200 });
|
|
};
|
|
`,
|
|
);
|
|
|
|
const env = {
|
|
...process.env,
|
|
QWEN_CODE_COMMIT: 'test-commit',
|
|
QWEN_CODE_ROOT: sourceRoot,
|
|
QWEN_DESKTOP_NODE_CACHE_DIR: cacheRoot,
|
|
QWEN_DESKTOP_SKIP_BUILD: '1',
|
|
QWEN_DESKTOP_TARGET: 'darwin-arm64',
|
|
QWEN_TEST_FETCH_LOG: fetchLog,
|
|
QWEN_TEST_NODE_ARCHIVE: archivePath,
|
|
QWEN_TEST_NODE_CHECKSUMS: checksumsPath,
|
|
NODE_OPTIONS: [
|
|
process.env.NODE_OPTIONS,
|
|
`--import=${pathToFileURL(fetchMock).href}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' '),
|
|
npm_execpath: process.env.npm_execpath || process.argv[1],
|
|
};
|
|
const first = spawnSync(process.execPath, [testScript], {
|
|
encoding: 'utf8',
|
|
env,
|
|
});
|
|
assert.equal(first.status, 0, first.stderr);
|
|
assert.doesNotMatch(first.stdout, /Using cached Node\.js runtime/);
|
|
assert.ok(fs.existsSync(cachedArchivePath));
|
|
assert.ok(
|
|
fs.existsSync(path.join(runtimeDir, 'qwen-code', 'checksums.json')),
|
|
);
|
|
|
|
const second = spawnSync(process.execPath, [testScript], {
|
|
encoding: 'utf8',
|
|
env,
|
|
});
|
|
assert.equal(second.status, 0, second.stderr);
|
|
assert.match(second.stdout, /Using cached Node\.js runtime/);
|
|
|
|
fs.appendFileSync(cachedArchivePath, 'tampered');
|
|
const poisonedHash = crypto
|
|
.createHash('sha256')
|
|
.update(fs.readFileSync(cachedArchivePath))
|
|
.digest('hex');
|
|
fs.writeFileSync(
|
|
path.join(cacheDir, 'SHASUMS256.txt'),
|
|
`${poisonedHash} ${archiveName}\n`,
|
|
);
|
|
const recoveredCache = spawnSync(process.execPath, [testScript], {
|
|
encoding: 'utf8',
|
|
env,
|
|
});
|
|
assert.equal(recoveredCache.status, 0, recoveredCache.stderr);
|
|
assert.doesNotMatch(recoveredCache.stdout, /Using cached Node\.js runtime/);
|
|
const fetches = fs.readFileSync(fetchLog, 'utf8').trim().split('\n');
|
|
assert.equal(fetches.filter((url) => url.endsWith(archiveName)).length, 2);
|
|
assert.equal(
|
|
fetches.filter((url) => url.endsWith('SHASUMS256.txt')).length,
|
|
3,
|
|
);
|
|
assert.equal(fs.existsSync(path.join(cacheDir, 'SHASUMS256.txt')), false);
|
|
|
|
const marker = path.join(runtimeDir, 'qwen-code', 'complete-marker');
|
|
fs.writeFileSync(marker, 'preserve me');
|
|
const strandedRoot = path.join(runtimeDir, '.prepare-stranded');
|
|
fs.mkdirSync(strandedRoot);
|
|
fs.renameSync(
|
|
path.join(runtimeDir, 'qwen-code'),
|
|
path.join(strandedRoot, 'previous'),
|
|
);
|
|
fs.rmSync(path.join(sourceRoot, 'LICENSE'));
|
|
const failed = spawnSync(process.execPath, [testScript], {
|
|
encoding: 'utf8',
|
|
env,
|
|
});
|
|
assert.notEqual(failed.status, 0);
|
|
assert.equal(fs.readFileSync(marker, 'utf8'), 'preserve me');
|
|
assert.deepEqual(
|
|
fs.readdirSync(runtimeDir).filter((entry) => entry.startsWith('.prepare-')),
|
|
[],
|
|
);
|
|
}
|
|
|
|
function testUpdaterMirrorConfiguration() {
|
|
assert.deepEqual(tauriConfig.plugins?.updater?.endpoints, [
|
|
'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/desktop/latest/desktop-latest.json',
|
|
'https://github.com/QwenLM/qwen-code/releases/download/desktop-latest/desktop-latest.json',
|
|
]);
|
|
const main = fs.readFileSync(
|
|
path.join(packageDir, 'src-tauri', 'src', 'main.rs'),
|
|
'utf8',
|
|
);
|
|
assert.match(
|
|
main,
|
|
/const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs\(3\);/,
|
|
);
|
|
assert.match(
|
|
main,
|
|
/app\.updater_builder\(\)\s*\.timeout\(UPDATE_CHECK_TIMEOUT\)/,
|
|
);
|
|
assert.equal((main.match(/check_for_update\(&app\)/g) ?? []).length, 2);
|
|
}
|
|
|
|
function testBootstrapBridgeConfiguration() {
|
|
assert.equal(
|
|
tauriConfig.app?.withGlobalTauri,
|
|
true,
|
|
'The Bootstrap UI requires window.__TAURI__ for desktop commands.',
|
|
);
|
|
assert.deepEqual(
|
|
tauriConfig.app?.security?.capabilities,
|
|
['bootstrap', 'web-shell-external-url'],
|
|
'The local bootstrap and remote Web Shell capabilities must be enabled.',
|
|
);
|
|
const capability = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(packageDir, 'src-tauri', 'capabilities', 'bootstrap.json'),
|
|
'utf8',
|
|
),
|
|
);
|
|
assert.deepEqual(capability.windows, ['main', 'local-control']);
|
|
assert.equal(
|
|
capability.remote,
|
|
undefined,
|
|
'The bootstrap capability must not grant remote IPC access.',
|
|
);
|
|
assert.deepEqual(capability.permissions, [
|
|
'core:event:allow-listen',
|
|
'core:event:allow-unlisten',
|
|
]);
|
|
|
|
const webShellCapability = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(
|
|
packageDir,
|
|
'src-tauri',
|
|
'capabilities',
|
|
'web-shell-external-url.json',
|
|
),
|
|
'utf8',
|
|
),
|
|
);
|
|
assert.equal(webShellCapability.local, false);
|
|
assert.deepEqual(webShellCapability.remote, {
|
|
urls: ['http://127.0.0.1:*'],
|
|
});
|
|
assert.deepEqual(webShellCapability.windows, ['main']);
|
|
assert.deepEqual(webShellCapability.permissions, [
|
|
{
|
|
identifier: 'opener:allow-open-url',
|
|
allow: [{ url: 'http://*' }, { url: 'https://*' }, { url: 'mailto:*' }],
|
|
},
|
|
]);
|
|
}
|
|
|
|
function testResolveLogRoot() {
|
|
const paths = {
|
|
isolatedHome: path.join('/', 'home'),
|
|
isolatedState: path.join('/', 'state'),
|
|
appId: tauriConfig.identifier,
|
|
};
|
|
|
|
assert.equal(
|
|
resolveLogRoot('darwin', {}, paths),
|
|
path.join('/', 'home', 'Library', 'Logs', tauriConfig.identifier),
|
|
);
|
|
assert.equal(
|
|
resolveLogRoot('linux', {}, paths),
|
|
path.join('/', 'state', tauriConfig.identifier, 'logs'),
|
|
);
|
|
assert.equal(
|
|
resolveLogRoot('win32', { LOCALAPPDATA: path.join('C:', 'x') }, paths),
|
|
path.join('C:', 'x', tauriConfig.identifier, 'logs'),
|
|
);
|
|
assert.throws(
|
|
() => resolveLogRoot('win32', {}, paths),
|
|
/LOCALAPPDATA is required/,
|
|
);
|
|
|
|
// Structural invariants that cannot be tested through the exported helper:
|
|
// the smoke must not override LOCALAPPDATA in the child env, and the
|
|
// pre-spawn snapshot must precede the spawn call.
|
|
const smoke = fs.readFileSync(
|
|
path.join(packageDir, 'scripts', 'smoke-packaged.js'),
|
|
'utf8',
|
|
);
|
|
assert.doesNotMatch(smoke, /^\s*LOCALAPPDATA:/m);
|
|
assert.match(
|
|
smoke,
|
|
/QWEN_DESKTOP_DISABLE_SETTINGS_PERSISTENCE: '1'/,
|
|
'Windows packaged smoke must not persist its temporary desktop state',
|
|
);
|
|
assert.match(
|
|
smoke,
|
|
/const logRoot = resolveLogRoot\(process\.platform, process\.env, \{/,
|
|
'smoke must resolve log root via resolveLogRoot',
|
|
);
|
|
assert.match(
|
|
smoke,
|
|
/const appId = JSON\.parse\(\s*fs\.readFileSync\(\s*path\.join\(packageDir, 'src-tauri', 'tauri\.conf\.json'\),\s*'utf8',\s*\),\s*\)\.identifier;/,
|
|
'smoke appId must be derived from the tauri.conf.json identifier',
|
|
);
|
|
const previousLogIndex = smoke.indexOf(
|
|
'let previousLog = fs.readFileSync(logPath',
|
|
);
|
|
const spawnIndex = smoke.indexOf('const child = spawn(executable');
|
|
assert.notEqual(previousLogIndex, -1, 'smoke must capture previousLog');
|
|
assert.notEqual(spawnIndex, -1, 'smoke must spawn the child');
|
|
assert.ok(
|
|
previousLogIndex < spawnIndex,
|
|
'previousLog must be captured before the child is spawned',
|
|
);
|
|
const readNewLogCalls = smoke.match(/const contents = readNewLog\(\)/g);
|
|
assert.ok(
|
|
readNewLogCalls && readNewLogCalls.length === 1,
|
|
'the polling loop must be the only incremental readNewLog() call site',
|
|
);
|
|
assert.match(
|
|
smoke,
|
|
/console\.warn\([\s\S]*?previousLog = contents;/,
|
|
'a rewritten log must warn and rebase the slice baseline',
|
|
);
|
|
assert.match(
|
|
smoke,
|
|
/const contents = fs\.readFileSync\(logPath, \{\s*encoding: 'utf8',\s*flag: 'a\+',\s*\}\);\s*throw smokeError\('Timed out waiting for packaged desktop runtime\.', contents\);/,
|
|
'the timeout error must embed the full log, not the incremental delta',
|
|
);
|
|
assert.match(
|
|
smoke,
|
|
/sliceNewLog\(/,
|
|
'smoke must slice the log via the tested sliceNewLog helper',
|
|
);
|
|
assert.match(
|
|
smoke,
|
|
/function smokeError[\s\S]*?Log: \$\{logPath\}/,
|
|
'smokeError must embed the log path like the timeout error does',
|
|
);
|
|
}
|
|
|
|
function testSliceNewLog() {
|
|
assert.deepEqual(sliceNewLog('hello', ''), {
|
|
text: 'hello',
|
|
baseline: '',
|
|
});
|
|
assert.deepEqual(sliceNewLog('hello world', 'hello'), {
|
|
text: ' world',
|
|
baseline: 'hello',
|
|
});
|
|
assert.deepEqual(sliceNewLog('new', 'old'), {
|
|
text: 'new',
|
|
baseline: '',
|
|
});
|
|
}
|
|
|
|
function testUpdateManifest(directory) {
|
|
const assets = path.join(directory, 'assets');
|
|
fs.mkdirSync(assets, { recursive: true });
|
|
const artifacts = [
|
|
'Qwen-Code-aarch64-apple-darwin.app.tar.gz',
|
|
'Qwen-Code-x86_64-apple-darwin.app.tar.gz',
|
|
'Qwen-Code_0.1.0_x64-setup.exe',
|
|
'Qwen-Code_0.1.0_amd64.AppImage',
|
|
];
|
|
for (const artifact of artifacts) {
|
|
assert.ok(
|
|
!artifact.includes(' '),
|
|
`Artifact name must not contain spaces: ${artifact}`,
|
|
);
|
|
}
|
|
for (const artifact of artifacts) {
|
|
fs.writeFileSync(path.join(assets, artifact), artifact);
|
|
fs.writeFileSync(
|
|
path.join(assets, `${artifact}.sig`),
|
|
`signature:${artifact}\n`,
|
|
);
|
|
}
|
|
const output = path.join(directory, 'desktop-latest.json');
|
|
execFileSync(process.execPath, [
|
|
manifestScript,
|
|
'--assets',
|
|
assets,
|
|
'--repository',
|
|
'QwenLM/qwen-code',
|
|
'--tag',
|
|
'desktop-v0.1.0',
|
|
'--version',
|
|
'0.1.0',
|
|
'--output',
|
|
output,
|
|
]);
|
|
const manifest = JSON.parse(fs.readFileSync(output, 'utf8'));
|
|
assert.equal(manifest.version, '0.1.0');
|
|
assert.deepEqual(Object.keys(manifest.platforms).sort(), [
|
|
'darwin-aarch64',
|
|
'darwin-x86_64',
|
|
'linux-x86_64',
|
|
'windows-x86_64',
|
|
]);
|
|
for (const [platform, artifact] of [
|
|
['darwin-aarch64', artifacts[0]],
|
|
['darwin-x86_64', artifacts[1]],
|
|
['windows-x86_64', artifacts[2]],
|
|
['linux-x86_64', artifacts[3]],
|
|
]) {
|
|
assert.equal(
|
|
manifest.platforms[platform].signature,
|
|
`signature:${artifact}`,
|
|
);
|
|
assert.equal(
|
|
manifest.platforms[platform].url,
|
|
`https://github.com/QwenLM/qwen-code/releases/download/desktop-v0.1.0/${encodeURIComponent(artifact)}`,
|
|
);
|
|
}
|
|
|
|
execFileSync(process.execPath, [
|
|
manifestScript,
|
|
'--assets',
|
|
assets,
|
|
'--repository',
|
|
'QwenLM/qwen-code',
|
|
'--tag',
|
|
'desktop-v0.1.0',
|
|
'--version',
|
|
'0.1.0',
|
|
'--base-url',
|
|
'https://mirror.example/desktop/v0.1.0/',
|
|
'--output',
|
|
output,
|
|
]);
|
|
const mirrorManifest = JSON.parse(fs.readFileSync(output, 'utf8'));
|
|
for (const [platform, artifact] of [
|
|
['darwin-aarch64', artifacts[0]],
|
|
['darwin-x86_64', artifacts[1]],
|
|
['windows-x86_64', artifacts[2]],
|
|
['linux-x86_64', artifacts[3]],
|
|
]) {
|
|
assert.equal(
|
|
mirrorManifest.platforms[platform].url,
|
|
`https://mirror.example/desktop/v0.1.0/${encodeURIComponent(artifact)}`,
|
|
);
|
|
}
|
|
|
|
fs.rmSync(path.join(assets, `${artifacts[3]}.sig`));
|
|
const failure = spawnSync(
|
|
process.execPath,
|
|
[
|
|
manifestScript,
|
|
'--assets',
|
|
assets,
|
|
'--repository',
|
|
'QwenLM/qwen-code',
|
|
'--tag',
|
|
'desktop-v0.1.0',
|
|
'--version',
|
|
'0.1.0',
|
|
'--output',
|
|
output,
|
|
],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
assert.notEqual(failure.status, 0);
|
|
assert.match(failure.stderr, /Missing updater signature/);
|
|
}
|
|
|
|
function testElectronBridgeManifest(directory) {
|
|
const assets = path.join(directory, 'assets');
|
|
fs.mkdirSync(assets, { recursive: true });
|
|
const artifacts = [
|
|
'Qwen-Code-Desktop-arm64.zip',
|
|
'Qwen-Code-Desktop-x64.zip',
|
|
'Qwen-Code-Desktop-arm64.dmg',
|
|
'Qwen-Code-Desktop-x64.dmg',
|
|
];
|
|
for (const artifact of artifacts) {
|
|
fs.writeFileSync(path.join(assets, artifact), `contents:${artifact}`);
|
|
}
|
|
artifacts.push(
|
|
'Qwen-Code-Desktop_0.1.0_x64-setup.exe',
|
|
'Qwen-Code-Desktop_0.1.0_amd64.AppImage',
|
|
);
|
|
for (const artifact of artifacts.slice(4)) {
|
|
fs.writeFileSync(path.join(assets, artifact), `contents:${artifact}`);
|
|
}
|
|
const macOutput = path.join(directory, 'latest-mac.yml');
|
|
for (const [platform, filename, selected] of [
|
|
['macos', 'latest-mac.yml', artifacts.slice(0, 4)],
|
|
['windows', 'latest.yml', artifacts.slice(4, 5)],
|
|
['linux', 'latest-linux.yml', artifacts.slice(5, 6)],
|
|
]) {
|
|
const output = path.join(directory, filename);
|
|
execFileSync(process.execPath, [
|
|
electronBridgeScript,
|
|
'--assets',
|
|
assets,
|
|
'--platform',
|
|
platform,
|
|
'--version',
|
|
'0.1.0',
|
|
'--output',
|
|
output,
|
|
]);
|
|
const manifest = fs.readFileSync(output, 'utf8');
|
|
assert.match(manifest, /^version: 0\.1\.0$/m);
|
|
assert.match(
|
|
manifest,
|
|
/^releaseDate: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'$/m,
|
|
);
|
|
for (const artifact of selected) {
|
|
const contents = fs.readFileSync(path.join(assets, artifact));
|
|
const sha512 = crypto
|
|
.createHash('sha512')
|
|
.update(contents)
|
|
.digest('base64');
|
|
assert.match(
|
|
manifest,
|
|
new RegExp(
|
|
`^ - url: ${artifact.replaceAll('.', '\\.')}\\n sha512: ${sha512.replaceAll('+', '\\+')}\\n size: ${contents.length}$`,
|
|
'm',
|
|
),
|
|
);
|
|
if (artifact === selected[0]) {
|
|
assert.match(
|
|
manifest,
|
|
new RegExp(`^path: ${artifact.replaceAll('.', '\\.')}$`, 'm'),
|
|
);
|
|
assert.match(
|
|
manifest,
|
|
new RegExp(`^sha512: ${sha512.replaceAll('+', '\\+')}$`, 'm'),
|
|
);
|
|
}
|
|
}
|
|
assert.equal(
|
|
(manifest.match(/^ {2}- url:/gm) ?? []).length,
|
|
selected.length,
|
|
);
|
|
}
|
|
const duplicateWindowsArtifact = path.join(
|
|
assets,
|
|
'Qwen-Code-Desktop_0.1.0_arm64-setup.exe',
|
|
);
|
|
fs.writeFileSync(duplicateWindowsArtifact, 'duplicate');
|
|
const ambiguousWindows = spawnSync(
|
|
process.execPath,
|
|
[
|
|
electronBridgeScript,
|
|
'--assets',
|
|
assets,
|
|
'--platform',
|
|
'windows',
|
|
'--version',
|
|
'0.1.0',
|
|
'--output',
|
|
path.join(directory, 'ambiguous-windows.yml'),
|
|
],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
assert.notEqual(ambiguousWindows.status, 0);
|
|
assert.match(ambiguousWindows.stderr, /found 2/);
|
|
fs.rmSync(duplicateWindowsArtifact);
|
|
|
|
fs.rmSync(path.join(assets, artifacts[1]));
|
|
const failure = spawnSync(
|
|
process.execPath,
|
|
[
|
|
electronBridgeScript,
|
|
'--assets',
|
|
assets,
|
|
'--platform',
|
|
'macos',
|
|
'--version',
|
|
'0.1.0',
|
|
'--output',
|
|
macOutput,
|
|
],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
assert.notEqual(failure.status, 0);
|
|
assert.match(failure.stderr, /Expected one Electron bridge artifact/);
|
|
|
|
const invalidVersion = spawnSync(
|
|
process.execPath,
|
|
[
|
|
electronBridgeScript,
|
|
'--assets',
|
|
assets,
|
|
'--platform',
|
|
'macos',
|
|
'--version',
|
|
'0.1',
|
|
'--output',
|
|
macOutput,
|
|
],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
assert.notEqual(invalidVersion.status, 0);
|
|
assert.match(invalidVersion.stderr, /Invalid --version/);
|
|
|
|
const missingOutput = spawnSync(
|
|
process.execPath,
|
|
[
|
|
electronBridgeScript,
|
|
'--assets',
|
|
assets,
|
|
'--platform',
|
|
'macos',
|
|
'--version',
|
|
'0.1.0',
|
|
],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
assert.notEqual(missingOutput.status, 0);
|
|
assert.match(missingOutput.stderr, /Missing --output/);
|
|
}
|
|
|
|
function testVersionSynchronization(directory) {
|
|
fs.mkdirSync(path.join(directory, 'src-tauri'), { recursive: true });
|
|
fs.copyFileSync(
|
|
path.join(packageDir, 'package.json'),
|
|
path.join(directory, 'package.json'),
|
|
);
|
|
fs.copyFileSync(
|
|
path.join(packageDir, 'src-tauri', 'Cargo.toml'),
|
|
path.join(directory, 'src-tauri', 'Cargo.toml'),
|
|
);
|
|
fs.copyFileSync(
|
|
path.join(packageDir, 'src-tauri', 'tauri.conf.json'),
|
|
path.join(directory, 'src-tauri', 'tauri.conf.json'),
|
|
);
|
|
execFileSync(process.execPath, [versionScript, '1.2.3'], {
|
|
cwd: directory,
|
|
env: { ...process.env, QWEN_DESKTOP_PACKAGE_DIR: directory },
|
|
});
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'))
|
|
.version,
|
|
'1.2.3',
|
|
);
|
|
assert.equal(
|
|
JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(directory, 'src-tauri', 'tauri.conf.json'),
|
|
'utf8',
|
|
),
|
|
).version,
|
|
'1.2.3',
|
|
);
|
|
assert.match(
|
|
fs.readFileSync(path.join(directory, 'src-tauri', 'Cargo.toml'), 'utf8'),
|
|
/^version = "1\.2\.3"$/m,
|
|
);
|
|
}
|