diff --git a/build/azure-pipelines/common/sanity-tests.yml b/build/azure-pipelines/common/sanity-tests.yml index d95bbe8351a..42232b12e6a 100644 --- a/build/azure-pipelines/common/sanity-tests.yml +++ b/build/azure-pipelines/common/sanity-tests.yml @@ -17,9 +17,6 @@ parameters: - name: baseImage type: string default: "" - - name: pageSize - type: string - default: "" - name: args type: string default: "" @@ -43,11 +40,46 @@ jobs: sparseCheckoutDirectories: test/sanity .nvmrc displayName: Checkout test/sanity - - task: NodeTool@0 - inputs: - versionSource: fromFile - versionFilePath: .nvmrc - displayName: Install Node.js + - ${{ if and(eq(parameters.os, 'windows'), eq(parameters.arch, 'arm64')) }}: + - script: | + @echo off + setlocal enabledelayedexpansion + + set "NODE_VERSION=v22.22.0" + set "EXPECTED_HASH=5b44fd410df7b4cd0a1891a05a7b606f8fb7d8786a94997b996a372e82478d7a" + set "NODE_ROOT=$(Agent.TempDirectory)\nodejs" + set "NODE_EXE=!NODE_ROOT!\node.exe" + + if not exist "!NODE_EXE!" ( + if exist "!NODE_ROOT!" rmdir /s /q "!NODE_ROOT!" + + set "NODE_ZIP=$(Agent.TempDirectory)\node.zip" + curl.exe -fsSL "https://nodejs.org/dist/!NODE_VERSION!/node-!NODE_VERSION!-win-arm64.zip" -o "!NODE_ZIP!" + + set "ACTUAL_HASH=" + for /f "skip=1" %%A in ('certutil -hashfile "!NODE_ZIP!" SHA256') do if not defined ACTUAL_HASH set "ACTUAL_HASH=%%A" + if /I not "!ACTUAL_HASH!"=="!EXPECTED_HASH!" ( + echo Hash mismatch for node.zip + echo expected: !EXPECTED_HASH! + echo actual: !ACTUAL_HASH! + del "!NODE_ZIP!" + exit /b 1 + ) + + tar -xf "!NODE_ZIP!" -C "$(Agent.TempDirectory)" + ren "$(Agent.TempDirectory)\node-!NODE_VERSION!-win-arm64" nodejs + del "!NODE_ZIP!" + ) + + echo ##vso[task.prependpath]%NODE_ROOT% + displayName: Install Node.js (Windows ARM64) + + - ${{ else }}: + - task: NodeTool@0 + inputs: + versionSource: fromFile + versionFilePath: .nvmrc + displayName: Install Node.js - script: npm config set registry "$(NPM_REGISTRY)" --location=project workingDirectory: $(TEST_DIR) @@ -88,9 +120,9 @@ jobs: - ${{ if ne(parameters.container, '') }}: - task: Cache@2 inputs: - key: 'docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "${{ parameters.pageSize }}" | "$(Agent.OS)" | $(TEST_DIR)/containers/${{ parameters.container }}.dockerfile' + key: 'docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "$(Agent.OS)" | $(TEST_DIR)/containers/${{ parameters.container }}.dockerfile' path: $(DOCKER_CACHE_DIR) - restoreKeys: docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "${{ parameters.pageSize }}" | "$(Agent.OS)" + restoreKeys: docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "$(Agent.OS)" cacheHitVar: DOCKER_CACHE_HIT displayName: Download Docker Image @@ -105,7 +137,6 @@ jobs: --container "${{ parameters.container }}" \ --arch "${{ parameters.arch }}" \ --base-image "${{ parameters.baseImage }}" \ - --page-size "${{ parameters.pageSize }}" \ --quality "$(BUILD_QUALITY)" \ --commit "$(BUILD_COMMIT)" \ --test-results "/root/results.xml" \ diff --git a/build/azure-pipelines/product-sanity-tests.yml b/build/azure-pipelines/product-sanity-tests.yml index 9a612b43ee2..ade0b96878b 100644 --- a/build/azure-pipelines/product-sanity-tests.yml +++ b/build/azure-pipelines/product-sanity-tests.yml @@ -112,6 +112,7 @@ extends: displayName: Windows arm64 poolName: 1es-windows-2022-arm64 os: windows + arch: arm64 # Alpine 3.22 - template: build/azure-pipelines/common/sanity-tests.yml@self @@ -332,14 +333,3 @@ extends: container: ubuntu baseImage: ubuntu:24.04 arch: arm64 - - - template: build/azure-pipelines/common/sanity-tests.yml@self - parameters: - name: ubuntu_24_04_arm64_64k - displayName: Ubuntu 24.04 arm64 (64K page) - poolName: 1es-ubuntu-22.04-x64 - container: ubuntu - baseImage: ubuntu:24.04 - arch: arm64 - pageSize: 64k - args: --grep "desktop-linux-arm64" diff --git a/test/sanity/containers/debian-10.dockerfile b/test/sanity/containers/debian-10.dockerfile index 61d8e713eb0..174e17e7885 100644 --- a/test/sanity/containers/debian-10.dockerfile +++ b/test/sanity/containers/debian-10.dockerfile @@ -1,6 +1,5 @@ ARG MIRROR ARG BASE_IMAGE=debian:10 -ARG TARGETARCH FROM ${MIRROR}${BASE_IMAGE} # Update to archive repos since Debian 10 is EOL @@ -17,7 +16,9 @@ RUN apt-get update && \ RUN apt-get install -y -t bullseye libstdc++6 # Node.js (arm32/arm64 use official builds, others use NodeSource) +ARG TARGETARCH RUN if [ "$TARGETARCH" = "arm" ]; then \ + apt-get install -y libatomic1 && \ curl -fsSL https://nodejs.org/dist/v20.18.3/node-v20.18.3-linux-armv7l.tar.gz | tar -xz -C /usr/local --strip-components=1; \ elif [ "$TARGETARCH" = "arm64" ]; then \ curl -fsSL https://nodejs.org/dist/v22.21.1/node-v22.21.1-linux-arm64.tar.gz | tar -xz -C /usr/local --strip-components=1; \ diff --git a/test/sanity/containers/debian-12.dockerfile b/test/sanity/containers/debian-12.dockerfile index 3163d9d8d92..8c5ac782729 100644 --- a/test/sanity/containers/debian-12.dockerfile +++ b/test/sanity/containers/debian-12.dockerfile @@ -6,9 +6,15 @@ FROM ${MIRROR}${BASE_IMAGE} RUN apt-get update && \ apt-get install -y curl -# Node.js 22 -RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y nodejs +# Node.js (arm32 uses official tarball since NodeSource dropped armhf support) +ARG TARGETARCH +RUN if [ "$TARGETARCH" = "arm" ]; then \ + apt-get install -y libatomic1 && \ + curl -fsSL https://nodejs.org/dist/v20.18.3/node-v20.18.3-linux-armv7l.tar.gz | tar -xz -C /usr/local --strip-components=1; \ + else \ + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs; \ + fi # Chromium RUN apt-get install -y chromium diff --git a/test/sanity/containers/ubuntu.dockerfile b/test/sanity/containers/ubuntu.dockerfile index 028f916ff22..949dbbd797d 100644 --- a/test/sanity/containers/ubuntu.dockerfile +++ b/test/sanity/containers/ubuntu.dockerfile @@ -22,9 +22,15 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \ RUN apt-get update && \ apt-get install -y curl iproute2 -# Node.js 22 -RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y nodejs +# Node.js (arm32 uses official tarball since NodeSource dropped armhf support) +ARG TARGETARCH +RUN if [ "$TARGETARCH" = "arm" ]; then \ + apt-get install -y libatomic1 && \ + curl -fsSL https://nodejs.org/dist/v20.18.3/node-v20.18.3-linux-armv7l.tar.gz | tar -xz -C /usr/local --strip-components=1; \ + else \ + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs; \ + fi # No UI on arm32 on Ubuntu 24.04 ARG BASE_IMAGE diff --git a/test/sanity/scripts/qemu-init.sh b/test/sanity/scripts/qemu-init.sh deleted file mode 100755 index c4c95755d42..00000000000 --- a/test/sanity/scripts/qemu-init.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh -set -e - -# Mount kernel filesystems (proc for process info, sysfs for device info) -echo "Mounting kernel filesystems" -mount -t proc proc /proc -mount -t sysfs sys /sys - -# Mount pseudo-terminal and shared memory filesystems -echo "Mounting PTY and shared memory" -mkdir -p /dev/pts -mount -t devpts devpts /dev/pts -mkdir -p /dev/shm -mount -t tmpfs tmpfs /dev/shm - -# Mount temporary directories with proper permissions -echo "Mounting temporary directories" -mount -t tmpfs tmpfs /tmp -chmod 1777 /tmp -mount -t tmpfs tmpfs /var/tmp - -# Mount runtime directory for services (D-Bus, XDG) -echo "Mounting runtime directories" -mount -t tmpfs tmpfs /run -mkdir -p /run/dbus -mkdir -p /run/user/0 -chmod 700 /run/user/0 - -echo "Setting up machine-id for D-Bus" -cat /proc/sys/kernel/random/uuid | tr -d '-' > /etc/machine-id - -echo "Setting system clock" -date -s "$(cat /host-time)" - -echo "Setting up networking" -ip link set lo up -ip link set eth0 up -ip addr add 10.0.2.15/24 dev eth0 -ip route add default via 10.0.2.2 -echo "nameserver 10.0.2.3" > /etc/resolv.conf - -export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -export XDG_RUNTIME_DIR=/run/user/0 - -echo "Starting entrypoint" -sh /root/containers/entrypoint.sh $(cat /test-args) -echo $? > /exit-code -sync - -echo "Powering off" -echo o > /proc/sysrq-trigger diff --git a/test/sanity/scripts/run-docker.sh b/test/sanity/scripts/run-docker.sh index 0007f9b98f0..8b3da44b1f7 100755 --- a/test/sanity/scripts/run-docker.sh +++ b/test/sanity/scripts/run-docker.sh @@ -5,7 +5,6 @@ CONTAINER="" ARCH="amd64" MIRROR="mcr.microsoft.com/mirror/docker/library/" BASE_IMAGE="" -PAGE_SIZE="" ARGS="" while [ $# -gt 0 ]; do @@ -13,7 +12,6 @@ while [ $# -gt 0 ]; do --container) CONTAINER="$2"; shift 2 ;; --arch) ARCH="$2"; shift 2 ;; --base-image) BASE_IMAGE="$2"; shift 2 ;; - --page-size) PAGE_SIZE="$2"; shift 2 ;; *) ARGS="$ARGS $1"; shift ;; esac done @@ -28,11 +26,6 @@ ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) # Only build if image doesn't exist (i.e., not loaded from cache) if ! docker image inspect "$CONTAINER" > /dev/null 2>&1; then - if [ "$PAGE_SIZE" != "" ]; then - echo "Setting up QEMU user-mode emulation for $ARCH" - docker run --privileged --rm tonistiigi/binfmt --install "$ARCH" - fi - echo "Building container image: $CONTAINER" docker buildx build \ --platform "linux/$ARCH" \ @@ -45,18 +38,11 @@ else echo "Using cached container image: $CONTAINER" fi -# For 64K page size, use QEMU system emulation with a 64K kernel -if [ "$PAGE_SIZE" = "64k" ]; then - exec "$SCRIPT_DIR/run-qemu-64k.sh" \ - --container "$CONTAINER" \ - -- $ARGS -else - echo "Running sanity tests in container" - docker run \ - --rm \ - --platform "linux/$ARCH" \ - --volume "$ROOT_DIR:/root" \ - --entrypoint sh \ - "$CONTAINER" \ - /root/containers/entrypoint.sh $ARGS -fi +echo "Running sanity tests in container" +docker run \ + --rm \ + --platform "linux/$ARCH" \ + --volume "$ROOT_DIR:/root" \ + --entrypoint sh \ + "$CONTAINER" \ + /root/containers/entrypoint.sh $ARGS diff --git a/test/sanity/scripts/run-qemu-64k.sh b/test/sanity/scripts/run-qemu-64k.sh deleted file mode 100755 index 55198489922..00000000000 --- a/test/sanity/scripts/run-qemu-64k.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/sh -set -e - -CONTAINER="" -ARGS="" - -while [ $# -gt 0 ]; do - case "$1" in - --container) CONTAINER="$2"; shift 2 ;; - --) shift; ARGS="$*"; break ;; - *) echo "Unknown option: $1"; exit 1 ;; - esac -done - -if [ -z "$CONTAINER" ]; then - echo "Usage: $0 --container CONTAINER [-- ARGS...]" - exit 1 -fi - -echo "Installing QEMU system emulation and tools" -sudo apt-get update && sudo apt-get install -y qemu-system-arm binutils - -echo "Exporting container filesystem" -CONTAINER_ID=$(docker create --platform linux/arm64 "$CONTAINER") -ROOTFS_DIR=$(mktemp -d) -docker export "$CONTAINER_ID" | sudo tar -xf - -C "$ROOTFS_DIR" -docker rm -f "$CONTAINER_ID" - -# echo "Removing container image to free disk space" -# docker rmi "$CONTAINER" || true -docker system prune -f || true - -echo "Copying test files into root filesystem" -TEST_DIR=$(cd "$(dirname "$0")/.." && pwd) -sudo cp -r "$TEST_DIR"/* "$ROOTFS_DIR/root/" - -echo "Downloading Ubuntu 24.04 generic-64k kernel for ARM64" -KERNEL_URL="https://ports.ubuntu.com/ubuntu-ports/pool/main/l/linux/linux-image-unsigned-6.8.0-90-generic-64k_6.8.0-90.91_arm64.deb" -KERNEL_DIR=$(mktemp -d) -curl -fL "$KERNEL_URL" -o "$KERNEL_DIR/kernel.deb" - -echo "Extracting kernel" -cd "$KERNEL_DIR" && ar x kernel.deb && rm kernel.deb -tar xf data.tar* && rm -f debian-binary control.tar* data.tar* -VMLINUZ="$KERNEL_DIR/boot/vmlinuz-6.8.0-90-generic-64k" -if [ ! -f "$VMLINUZ" ]; then - echo "Error: Could not find kernel at $VMLINUZ" - exit 1 -fi - -echo "Storing test arguments and installing init script" -echo "$ARGS" > "$ROOTFS_DIR/test-args" -date -u '+%Y-%m-%d %H:%M:%S' > "$ROOTFS_DIR/host-time" -sudo mv "$ROOTFS_DIR/root/scripts/qemu-init.sh" "$ROOTFS_DIR/init" -sudo chmod +x "$ROOTFS_DIR/init" - -echo "Creating disk image with root filesystem" -DISK_IMG=$(mktemp) -dd if=/dev/zero of="$DISK_IMG" bs=1M count=2048 status=none -sudo mkfs.ext4 -q -d "$ROOTFS_DIR" "$DISK_IMG" -sudo rm -rf "$ROOTFS_DIR" - -echo "Starting QEMU VM with 64K page size kernel" -timeout 1800 qemu-system-aarch64 \ - -M virt \ - -cpu max,pauth-impdef=on \ - -accel tcg,thread=multi \ - -m 4096 \ - -smp 2 \ - -kernel "$VMLINUZ" \ - -append "console=ttyAMA0 root=/dev/vda rw init=/init net.ifnames=0" \ - -drive file="$DISK_IMG",format=raw,if=virtio \ - -netdev user,id=net0 \ - -device virtio-net-pci,netdev=net0 \ - -nographic \ - -no-reboot - -echo "Extracting test results from disk image" -MOUNT_DIR=$(mktemp -d) -sudo mount -o loop "$DISK_IMG" "$MOUNT_DIR" -sudo cp "$MOUNT_DIR/root/results.xml" "$TEST_DIR/results.xml" -sudo chown "$(id -u):$(id -g)" "$TEST_DIR/results.xml" - -EXIT_CODE=$(sudo cat "$MOUNT_DIR/exit-code" 2>/dev/null || echo 1) -sudo umount "$MOUNT_DIR" -exit $EXIT_CODE diff --git a/test/sanity/src/cli.test.ts b/test/sanity/src/cli.test.ts index 0b813085d39..8732acde0b7 100644 --- a/test/sanity/src/cli.test.ts +++ b/test/sanity/src/cli.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Page } from 'playwright'; +import { Browser, Page } from 'playwright'; import { TestContext } from './context.js'; import { GitHubAuth } from './githubAuth.js'; import { UITest } from './uiTest.js'; @@ -57,6 +57,7 @@ export function setup(context: TestContext) { context.test('cli-win32-arm64', ['windows', 'arm64'], async () => { const dir = await context.downloadAndUnpack('cli-win32-arm64'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getCliEntryPoint(dir); await testCliApp(entryPoint); }); @@ -64,6 +65,7 @@ export function setup(context: TestContext) { context.test('cli-win32-x64', ['windows', 'x64'], async () => { const dir = await context.downloadAndUnpack('cli-win32-x64'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getCliEntryPoint(dir); await testCliApp(entryPoint); }); @@ -84,6 +86,7 @@ export function setup(context: TestContext) { const cliDataDir = context.createTempDir(); const test = new UITest(context); const auth = new GitHubAuth(context); + let browser: Browser | undefined; let page: Page | undefined; context.log('Logging out of Dev Tunnel to ensure fresh authentication'); @@ -103,8 +106,8 @@ export function setup(context: TestContext) { const deviceCode = /To grant access .* use code ([A-Z0-9-]+)/.exec(line)?.[1]; if (deviceCode) { context.log(`Device code detected: ${deviceCode}, starting device flow authentication`); - const browser = await context.launchBrowser(); - page = await browser.newPage(); + browser = await context.launchBrowser(); + page = await context.getPage(browser.newPage()); await auth.runDeviceCodeFlow(page, deviceCode); return; } @@ -115,7 +118,7 @@ export function setup(context: TestContext) { const url = context.getTunnelUrl(tunnelUrl, test.workspaceDir); context.log(`CLI started successfully with tunnel URL: ${url}`); - if (!page) { + if (!browser || !page) { throw new Error('Browser instance is not available'); } @@ -129,9 +132,10 @@ export function setup(context: TestContext) { await page.locator('span.monaco-highlighted-label', { hasText: 'GitHub' }).click(); context.log('Clicking Allow on confirmation dialog'); + const popup = page.waitForEvent('popup'); await page.getByRole('button', { name: 'Allow' }).click(); - await auth.runAuthorizeFlow(page); + await auth.runAuthorizeFlow(await popup); context.log('Waiting for connection to be established'); await page.getByRole('button', { name: `remote ${tunnelId}` }).waitFor({ timeout: 5 * 60 * 1000 }); @@ -139,7 +143,7 @@ export function setup(context: TestContext) { await test.run(page); context.log('Closing browser'); - await page.context().browser()?.close(); + await browser.close(); test.validate(); return true; diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 8ee3cd5bb0d..eae2f68809b 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -32,6 +32,7 @@ interface ITargetMetadata { */ export class TestContext { private static readonly authenticodeInclude = /^.+\.(exe|dll|sys|cab|cat|msi|jar|ocx|ps1|psm1|psd1|ps1xml|pssc1)$/i; + private static readonly versionInfoInclude = /^.+\.(exe|dll|node|msi)$/i; private readonly tempDirs = new Set(); private readonly wslTempDirs = new Set(); @@ -61,15 +62,12 @@ export class TestContext { /** * Returns the OS temp directory with expanded long names on Windows. */ - public readonly osTempDir = (function () { + public readonly osTempDir = (() => { let tempDir = fs.realpathSync(os.tmpdir()); // On Windows, expand short 8.3 file names to long names if (os.platform() === 'win32') { - const result = spawnSync('powershell', ['-Command', `(Get-Item "${tempDir}").FullName`], { encoding: 'utf-8' }); - if (result.status === 0 && result.stdout) { - tempDir = result.stdout.trim(); - } + tempDir = fs.realpathSync.native(tempDir); } return tempDir; @@ -298,39 +296,12 @@ export class TestContext { const { url, sha256hash } = await this.fetchMetadata(target); const filePath = path.join(this.createTempDir(), path.basename(url)); - const maxRetries = 5; - let lastError: Error | undefined; + this.log(`Downloading ${url} to ${filePath}`); + this.runNoErrors('curl', '-fSL', '--retry', '5', '--retry-delay', '2', '--retry-all-errors', '-o', filePath, url); + this.log(`Downloaded ${url} to ${filePath}`); - for (let attempt = 0; attempt < maxRetries; attempt++) { - if (attempt > 0) { - const delay = Math.pow(2, attempt - 1) * 1000; - this.log(`Retrying download (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`); - await new Promise(resolve => setTimeout(resolve, delay)); - } - - try { - this.log(`Downloading ${url} to ${filePath}`); - const { body } = await this.fetchNoErrors(url); - - const stream = fs.createWriteStream(filePath); - await new Promise((resolve, reject) => { - body.on('error', reject); - stream.on('error', reject); - stream.on('finish', resolve); - body.pipe(stream); - }); - - this.log(`Downloaded ${url} to ${filePath}`); - this.validateSha256Hash(filePath, sha256hash); - - return filePath; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - this.log(`Download attempt ${attempt + 1} failed: ${lastError.message}`); - } - } - - this.error(`Failed to download ${url} after ${maxRetries} attempts: ${lastError?.message}`); + this.validateSha256Hash(filePath, sha256hash); + return filePath; } /** @@ -360,15 +331,50 @@ export class TestContext { } this.log(`Validating Authenticode signature for ${filePath}`); + this.validateAuthenticodeSignaturesForFiles([filePath]); + } - const result = this.run('powershell', '-Command', `Get-AuthenticodeSignature "${filePath}" | Select-Object -ExpandProperty Status`); - if (result.error !== undefined) { - this.error(`Failed to run Get-AuthenticodeSignature: ${result.error.message}`); + /** + * Collects all files matching the Authenticode include pattern from the specified directory recursively. + */ + private collectAuthenticodeFiles(dir: string, files: string[]): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const filePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + this.collectAuthenticodeFiles(filePath, files); + } else if (TestContext.authenticodeInclude.test(entry.name)) { + files.push(filePath); + } + } + } + + /** + * Validates Authenticode signatures for the specified list of files in a single PowerShell call. + */ + private validateAuthenticodeSignaturesForFiles(files: string[]): void { + if (files.length === 0) { + return; } - const status = result.stdout.trim(); - if (status !== 'Valid') { - this.error(`Authenticode signature is not valid for ${filePath}: ${status}`); + const fileList = files.map(file => `"${file}"`).join(','); + const command = `@(${fileList}) | ForEach-Object { $sig = Get-AuthenticodeSignature $_; "$($sig.Path)|$($sig.Status)" }`; + const result = this.runNoErrors('powershell', '-NoProfile', '-Command', command); + + const invalid: string[] = []; + for (const line of result.stdout.trim().split('\n')) { + const [, filePath, status] = /^(.+)\|(\w+)$/.exec(line.trim()) ?? []; + if (filePath) { + if (status === 'Valid') { + this.log(`Authenticode signature is valid for ${filePath}`); + } else { + invalid.push(`${filePath}: ${status}`); + } + } + } + + if (invalid.length > 0) { + this.error(`Authenticode signatures are not valid for:\n${invalid.join('\n')}`); } } @@ -382,17 +388,86 @@ export class TestContext { return; } - const files = fs.readdirSync(dir, { withFileTypes: true }); - for (const file of files) { - const filePath = path.join(dir, file.name); - if (file.isDirectory()) { - this.validateAllAuthenticodeSignatures(filePath); - } else if (TestContext.authenticodeInclude.test(file.name)) { - this.validateAuthenticodeSignature(filePath); + const files: string[] = []; + this.collectAuthenticodeFiles(dir, files); + this.log(`Found ${files.length} file(s) to validate Authenticode signatures`); + this.validateAuthenticodeSignaturesForFiles(files); + } + + /** + * Collects all files matching the VersionInfo include pattern from the specified directory recursively. + */ + private collectVersionInfoFiles(dir: string, files: string[]): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const filePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + this.collectVersionInfoFiles(filePath, files); + } else if (TestContext.versionInfoInclude.test(entry.name)) { + files.push(filePath); } } } + /** + * Validates that a Windows binary has a non-empty ProductName in VersionInfo. + * @param filePath The path to the file to validate. + */ + public validateVersionInfo(filePath: string) { + if (!this.options.checkSigning || !this.capabilities.has('windows')) { + this.log(`Skipping VersionInfo validation for ${filePath} (signing checks disabled)`); + return; + } + + this.log(`Validating VersionInfo for ${filePath}`); + this.validateVersionInfoForFiles([filePath]); + } + + /** + * Validates VersionInfo ProductName for the specified list of files in a single PowerShell call. + */ + private validateVersionInfoForFiles(files: string[]): void { + if (files.length === 0) { + return; + } + + const fileList = files.map(file => `"${file}"`).join(','); + const command = `@(${fileList}) | ForEach-Object { $vi = (Get-Item $_).VersionInfo; "$($_.ToString())|$($vi.ProductName)" }`; + const result = this.runNoErrors('powershell', '-NoProfile', '-Command', command); + + const invalid: string[] = []; + for (const line of result.stdout.trim().split('\n')) { + const [, filePath, productName] = /^(.+)\|(.*)$/.exec(line.trim()) ?? []; + if (filePath) { + if (productName && productName.trim().length > 0) { + this.log(`VersionInfo ProductName is set for ${filePath}: ${productName.trim()}`); + } else { + invalid.push(filePath); + } + } + } + + if (invalid.length > 0) { + this.error(`VersionInfo ProductName is missing or empty for:\n${invalid.join('\n')}`); + } + } + + /** + * Validates that all .exe, .dll, .node and .msi binaries in the specified directory have a non-empty ProductName in VersionInfo. + * @param dir The directory to scan for binary files. + */ + public validateAllVersionInfo(dir: string) { + if (!this.options.checkSigning || !this.capabilities.has('windows')) { + this.log(`Skipping VersionInfo validation for ${dir} (signing checks disabled)`); + return; + } + + const files: string[] = []; + this.collectVersionInfoFiles(dir, files); + this.log(`Found ${files.length} file(s) to validate VersionInfo`); + this.validateVersionInfoForFiles(files); + } + /** * Validates the codesign signature of a macOS binary or app bundle. * @param filePath The path to the file or app bundle to validate. diff --git a/test/sanity/src/desktop.test.ts b/test/sanity/src/desktop.test.ts index 8a9b57e6dc3..abb37db72f0 100644 --- a/test/sanity/src/desktop.test.ts +++ b/test/sanity/src/desktop.test.ts @@ -168,9 +168,11 @@ export function setup(context: TestContext) { context.test('desktop-win32-arm64', ['windows', 'arm64', 'desktop'], async () => { const packagePath = await context.downloadTarget('win32-arm64'); context.validateAuthenticodeSignature(packagePath); + context.validateVersionInfo(packagePath); if (!context.options.downloadOnly) { const entryPoint = context.installWindowsApp('system', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); + context.validateAllVersionInfo(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('system'); } @@ -179,6 +181,7 @@ export function setup(context: TestContext) { context.test('desktop-win32-arm64-archive', ['windows', 'arm64', 'desktop'], async () => { const dir = await context.downloadAndUnpack('win32-arm64-archive'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir); @@ -189,9 +192,11 @@ export function setup(context: TestContext) { context.test('desktop-win32-arm64-user', ['windows', 'arm64', 'desktop'], async () => { const packagePath = await context.downloadTarget('win32-arm64-user'); context.validateAuthenticodeSignature(packagePath); + context.validateVersionInfo(packagePath); if (!context.options.downloadOnly) { const entryPoint = context.installWindowsApp('user', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); + context.validateAllVersionInfo(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('user'); } @@ -200,9 +205,11 @@ export function setup(context: TestContext) { context.test('desktop-win32-x64', ['windows', 'x64', 'desktop'], async () => { const packagePath = await context.downloadTarget('win32-x64'); context.validateAuthenticodeSignature(packagePath); + context.validateVersionInfo(packagePath); if (!context.options.downloadOnly) { const entryPoint = context.installWindowsApp('system', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); + context.validateAllVersionInfo(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('system'); } @@ -211,6 +218,7 @@ export function setup(context: TestContext) { context.test('desktop-win32-x64-archive', ['windows', 'x64', 'desktop'], async () => { const dir = await context.downloadAndUnpack('win32-x64-archive'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir); @@ -221,9 +229,11 @@ export function setup(context: TestContext) { context.test('desktop-win32-x64-user', ['windows', 'x64', 'desktop'], async () => { const packagePath = await context.downloadTarget('win32-x64-user'); context.validateAuthenticodeSignature(packagePath); + context.validateVersionInfo(packagePath); if (!context.options.downloadOnly) { const entryPoint = context.installWindowsApp('user', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); + context.validateAllVersionInfo(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('user'); } diff --git a/test/sanity/src/detectors.ts b/test/sanity/src/detectors.ts index ed1cc693099..7b14404a4aa 100644 --- a/test/sanity/src/detectors.ts +++ b/test/sanity/src/detectors.ts @@ -5,6 +5,7 @@ import fs from 'fs'; import os from 'os'; +import { spawnSync } from 'child_process'; import { webkit } from 'playwright'; /** @@ -152,6 +153,15 @@ function detectWSL(capabilities: Set) { if (os.platform() === 'win32') { const wslPath = `${process.env.SystemRoot}\\System32\\wsl.exe`; if (fs.existsSync(wslPath)) { + // wsl.exe can exist even when WSL isn't installed; ensure the command is usable. + const result = spawnSync(wslPath, ['--list', '--quiet'], { encoding: 'utf8', windowsHide: true, timeout: 5000 }); + if (result.status !== 0 || result.error) { + return; + } + if (result.stdout.trim().length === 0) { + return; + } + capabilities.add('wsl'); } } diff --git a/test/sanity/src/githubAuth.ts b/test/sanity/src/githubAuth.ts index 359ac030b7b..0a1844f7e2b 100644 --- a/test/sanity/src/githubAuth.ts +++ b/test/sanity/src/githubAuth.ts @@ -17,7 +17,7 @@ export class GitHubAuth { /** * Runs GitHub device authentication flow in a browser. - * @param browser Browser to use. + * @param page Page to use. * @param code Device authentication code to use. */ public async runDeviceCodeFlow(page: Page, code: string) { @@ -37,7 +37,7 @@ export class GitHubAuth { await page.getByRole('button', { name: 'Continue' }).click(); this.context.log('Entering device code'); - const codeChars = code.replace('-', ''); + const codeChars = code.replace(/-/g, ''); for (let i = 0; i < codeChars.length; i++) { await page.getByRole('textbox').nth(i).fill(codeChars[i]); } @@ -48,15 +48,11 @@ export class GitHubAuth { } /** - * Handles the GitHub "Authorize" dialog in a popup. - * Clicks "Continue" to authorize the app with the already signed-in account. - * @param page Main page that triggers the GitHub OAuth popup. + * Handles the GitHub "Authorize" popup dialog. + * @param page Page to use. */ public async runAuthorizeFlow(page: Page) { - this.context.log('Waiting for GitHub OAuth popup'); - const popup = await page.waitForEvent('popup'); - - this.context.log(`Authorizing app at ${popup.url()}`); - await popup.getByRole('button', { name: 'Continue' }).click(); + this.context.log(`Authorizing app at ${page.url()}`); + await page.getByRole('button', { name: 'Continue' }).click(); } } diff --git a/test/sanity/src/server.test.ts b/test/sanity/src/server.test.ts index ff2384f5c9a..81019ab63cf 100644 --- a/test/sanity/src/server.test.ts +++ b/test/sanity/src/server.test.ts @@ -54,6 +54,7 @@ export function setup(context: TestContext) { context.test('server-win32-arm64', ['windows', 'arm64'], async () => { const dir = await context.downloadAndUnpack('server-win32-arm64'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getServerEntryPoint(dir); await testServer(entryPoint); }); @@ -61,6 +62,7 @@ export function setup(context: TestContext) { context.test('server-win32-x64', ['windows', 'x64'], async () => { const dir = await context.downloadAndUnpack('server-win32-x64'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getServerEntryPoint(dir); await testServer(entryPoint); }); diff --git a/test/sanity/src/serverWeb.test.ts b/test/sanity/src/serverWeb.test.ts index 89084cb6c6c..4b765c0cd10 100644 --- a/test/sanity/src/serverWeb.test.ts +++ b/test/sanity/src/serverWeb.test.ts @@ -54,6 +54,7 @@ export function setup(context: TestContext) { context.test('server-web-win32-arm64', ['windows', 'arm64', 'browser'], async () => { const dir = await context.downloadAndUnpack('server-win32-arm64-web'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getServerEntryPoint(dir); await testServer(entryPoint); }); @@ -61,6 +62,7 @@ export function setup(context: TestContext) { context.test('server-web-win32-x64', ['windows', 'x64', 'browser'], async () => { const dir = await context.downloadAndUnpack('server-win32-x64-web'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); const entryPoint = context.getServerEntryPoint(dir); await testServer(entryPoint); }); diff --git a/test/sanity/src/wsl.test.ts b/test/sanity/src/wsl.test.ts index cd54740ac98..ab2c5817634 100644 --- a/test/sanity/src/wsl.test.ts +++ b/test/sanity/src/wsl.test.ts @@ -36,6 +36,7 @@ export function setup(context: TestContext) { context.test('wsl-desktop-arm64', ['windows', 'arm64', 'wsl', 'desktop'], async () => { const dir = await context.downloadAndUnpack('win32-arm64-archive'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir); @@ -46,6 +47,7 @@ export function setup(context: TestContext) { context.test('wsl-desktop-x64', ['windows', 'x64', 'wsl', 'desktop'], async () => { const dir = await context.downloadAndUnpack('win32-x64-archive'); context.validateAllAuthenticodeSignatures(dir); + context.validateAllVersionInfo(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir);