Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 53 additions & 49 deletions .github/workflows/job-compile-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,56 +25,56 @@ jobs:
runs-on: ${{ inputs.runner-env }}

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.checkout-ref }}

- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24

- name: Validate Yarn lockfile
run: yarn test-yarn-lock && yarn verify-yarn-lock
working-directory: Extension

- name: Validate LLDB-MI component manifest
run: yarn test-lldb-mi-component-manifest && yarn verify-lldb-mi-component-manifest
working-directory: Extension

- name: Validate Yarn lockfile
run: yarn test-yarn-lock && yarn verify-yarn-lock
working-directory: Extension
- name: Validate LLDB-MI component manifest
run: yarn test-lldb-mi-component-manifest && yarn verify-lldb-mi-component-manifest
working-directory: Extension
- name: Install Dependencies
shell: bash
env:
YARN_ARGS: ${{ inputs.yarn-args }}
run: |
read -r -a yarn_args <<< "$YARN_ARGS"
for attempt in 1 2 3; do
if yarn install "${yarn_args[@]}"; then
exit 0
fi
if (( attempt == 3 )); then
exit 1
fi
delay=$((attempt * 15))
printf 'yarn install failed; retrying in %d seconds.\n' "$delay" >&2
sleep "$delay"
done
shell: bash
env:
YARN_ARGS: ${{ inputs.yarn-args }}
run: |
read -r -a yarn_args <<< "$YARN_ARGS"
for attempt in 1 2 3; do
if yarn install "${yarn_args[@]}"; then
exit 0
fi
if (( attempt == 3 )); then
exit 1
fi
delay=$((attempt * 15))
printf 'yarn install failed; retrying in %d seconds.\n' "$delay" >&2
sleep "$delay"
done
working-directory: Extension

- name: Install gdb (linux)
if: ${{ inputs.platform == 'linux' }}
timeout-minutes: 10
timeout-minutes: 10
run: |
sudo apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
update
sudo apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
install -y gdb
sudo apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
update
sudo apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
install -y gdb

- name: Compile Sources
run: yarn run compile
Expand All @@ -88,6 +88,10 @@ jobs:
run: yarn test
working-directory: Extension

- name: Test VS Code acquisition
run: yarn test-vscode-acquisition
working-directory: Extension

- name: Acquire Native Binaries
run: yarn install-and-copy-binaries-for-test
working-directory: Extension
Expand All @@ -97,11 +101,11 @@ jobs:
run: yarn test --scenario=SingleRootProject
working-directory: Extension

- name: Run SimpleCppProject tests (Windows)
if: ${{ inputs.platform == 'windows' }}
run: yarn test --scenario=SimpleCppProject
working-directory: Extension

- name: Run SimpleCppProject tests (Windows)
if: ${{ inputs.platform == 'windows' }}
run: yarn test --scenario=SimpleCppProject
working-directory: Extension
- name: Run E2E IntelliSense features tests (Windows)
if: ${{ inputs.platform == 'windows' }}
run: yarn test --scenario=MultirootDeadlockTest
Expand All @@ -122,13 +126,13 @@ jobs:
run: yarn test --scenario=SingleRootProject
working-directory: Extension

- name: Run SimpleCppProject tests (linux/macOS)
if: ${{ inputs.platform == 'mac' || inputs.platform == 'linux' }}
uses: coactions/setup-xvfb@b6b4fcfb9f5a895edadc3bc76318fae0ac17c8b3 # v1.0.1
with:
run: yarn test --scenario=SimpleCppProject
working-directory: Extension

- name: Run SimpleCppProject tests (linux/macOS)
if: ${{ inputs.platform == 'mac' || inputs.platform == 'linux' }}
uses: coactions/setup-xvfb@b6b4fcfb9f5a895edadc3bc76318fae0ac17c8b3 # v1.0.1
with:
run: yarn test --scenario=SimpleCppProject
working-directory: Extension
- name: Run E2E IntelliSense features tests (linux/macOS)
if: ${{ inputs.platform == 'mac' || inputs.platform == 'linux' }}
uses: coactions/setup-xvfb@b6b4fcfb9f5a895edadc3bc76318fae0ac17c8b3 # v1.0.1
Expand Down
6 changes: 1 addition & 5 deletions Extension/.scripts/installAndCopyBinaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,13 @@
import { runVSCodeCommand } from '@vscode/test-electron';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { $root, error, heading, note, warn } from './common';
import { $root, heading, note, warn } from './common';
import * as copy from './copyExtensionBinaries';
import { install, isolated, options } from "./vscode";

export async function main() {
console.log(heading(`Install VS Code`));
const vscode = await install();
if (!vscode) {
error('Failed to install VS Code');
return;
}

console.log(heading('Install latest C/C++ Extension'));
const result = await runVSCodeCommand([...vscode.args ?? [], '--install-extension', 'ms-vscode.cpptools', '--pre-release'], options);
Expand Down
205 changes: 205 additions & 0 deletions Extension/.scripts/vscode.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { fileURLToPath, URL } from 'node:url';

const require = createRequire(import.meta.url);
const extensionRoot = fileURLToPath(new URL('../', import.meta.url));

require('ts-node').register({
project: fileURLToPath(new URL('tsconfig.json', import.meta.url)),
transpileOnly: true
});
const proxyquire = require('proxyquire').noCallThru();
const sinon = require('sinon');
const { TimeoutError } = require('@vscode/test-electron/out/request');

function networkError(code) {
return Object.assign(new Error('Controlled acquisition failure'), { code });
}

function createInstaller() {
const download = sinon.stub().resolves('test-executable');
const resolveCli = sinon.stub().returns(['test-cli', '--existing-argument', '--extensions-dir=default', '--user-data-dir=default']);
const wait = sinon.stub().resolves();
const mkdir = sinon.stub().resolves();
const write = sinon.stub().resolves();
const warn = sinon.stub();
const installer = proxyquire(fileURLToPath(new URL('vscode.ts', import.meta.url)), {
'@vscode/test-electron': { downloadAndUnzipVSCode: download, resolveCliArgsFromVSCodeExecutablePath: resolveCli },
'timers/promises': { setTimeout: wait },
'../src/Utility/Text/streams': { verbose: sinon.stub() },
'./common': { mkdir, write, warn, readJson: sinon.stub().resolves({}) },
'./vscodeTestPath': { getVSCodeTestIsolate: () => join(tmpdir(), 'cpptools-acquisition-unit') }
});
return { ...installer, download, resolveCli, wait, mkdir, write, warn };
}

test('successful acquisition preserves the version, cache and CLI arguments without retries', async () => {
const installer = createInstaller();
const result = await installer.install();

assert.equal(installer.download.callCount, 1);
assert.equal(installer.download.firstCall.args[0], installer.options);
assert.equal(installer.options.version, installer.testVSCodeVersion);
assert.equal(installer.options.cachePath, `${installer.isolated}/cache`);
assert.deepEqual(result, {
cli: 'test-cli',
args: ['--existing-argument', `--extensions-dir=${installer.extensionsDir}`, `--user-data-dir=${installer.userDir}`]
});
assert.equal(installer.wait.callCount, 0);
assert.equal(installer.warn.callCount, 0);
assert.equal(installer.write.callCount, 1);
});

for (const code of ['EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE', 'ETIMEDOUT']) {
test(`retries ${code} once before a successful acquisition`, async () => {
const installer = createInstaller();
installer.download.onFirstCall().rejects(networkError(code));

await installer.install();

assert.equal(installer.download.callCount, 2);
assert.ok(installer.download.getCalls().every(call => call.args[0] === installer.options));
assert.deepEqual(installer.wait.args, [[1000]]);
assert.equal(installer.warn.callCount, 1);
assert.equal(installer.mkdir.callCount, 1);
assert.equal(installer.write.callCount, 1);
});
}

test('retries the test-electron request timeout', async () => {
const installer = createInstaller();
installer.download.onFirstCall().rejects(new TimeoutError(15000));

await installer.install();

assert.equal(installer.download.callCount, 2);
assert.deepEqual(installer.wait.args, [[1000]]);
});

test('exhausts transient aggregate errors after three attempts with bounded backoff', async () => {
const installer = createInstaller();
installer.download.rejects(new AggregateError([networkError('ETIMEDOUT'), networkError('ENETUNREACH')]));

await assert.rejects(installer.install(), /after 3 attempts: ETIMEDOUT:.*ENETUNREACH:/);

assert.equal(installer.download.callCount, 3);
assert.deepEqual(installer.wait.args, [[1000], [2000]]);
assert.equal(installer.warn.callCount, 2);
assert.equal(installer.resolveCli.callCount, 0);
assert.equal(installer.write.callCount, 0);
});

for (const [name, failure] of [
['an invalid version', new Error('Invalid version')],
['a permissions error', networkError('EACCES')],
['a full disk', networkError('ENOSPC')],
['a certificate error', networkError('CERT_HAS_EXPIRED')],
['the library exhausting its archive retries', new Error('Failed to download and unzip VS Code 1.131.0')],
['an unclassified HTTP failure', 'Failed to get JSON'],
['an empty aggregate error', new AggregateError([])],
['an aggregate containing a permanent error', new AggregateError([networkError('ETIMEDOUT'), networkError('EACCES')])]
]) {
test(`does not retry ${name}`, async () => {
const installer = createInstaller();
installer.download.callsFake(async () => { throw failure; });

await assert.rejects(installer.install(), /Failed to install VS Code:.*after 1 attempt/);

assert.equal(installer.download.callCount, 1);
assert.equal(installer.wait.callCount, 0);
assert.equal(installer.warn.callCount, 0);
assert.equal(installer.resolveCli.callCount, 0);
assert.equal(installer.write.callCount, 0);
});
}

test('does not retry installation work after acquisition succeeds', async () => {
const installer = createInstaller();
installer.write.rejects(networkError('EPIPE'));

await assert.rejects(installer.install(), /Failed to install VS Code/);

assert.equal(installer.download.callCount, 1);
assert.equal(installer.write.callCount, 1);
assert.equal(installer.wait.callCount, 0);
});

for (const [name, code, attempts, delays] of [
['a non-retryable failure', 'EACCES', 1, []],
['exhausted transient failures', 'ETIMEDOUT', 3, [1000, 2000]]
]) {
test(`acquisition CLI exits 1 without downstream work after ${name}`, () => {
const testRoot = mkdtempSync(join(tmpdir(), 'cpptools-acquisition-'));
const preload = `
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import process from 'node:process';
const require = createRequire(${JSON.stringify(import.meta.url)});
require('https').get = () => {
process.stdout.write('ACQUISITION_ATTEMPT\\n');
const request = new EventEmitter();
request.destroy = () => request;
process.nextTick(() => {
const failure = Object.assign(new Error('Controlled VS Code acquisition failure'), { code: '${code}' });
request.emit('error', '${code}' === 'ETIMEDOUT'
? new AggregateError([failure, Object.assign(new Error('Controlled IPv6 failure'), { code: 'ENETUNREACH' })])
: failure);
});
return request;
};
require('timers/promises').setTimeout = async (milliseconds) => {
process.stdout.write('ACQUISITION_DELAY:' + milliseconds + '\\n');
};
const electronPath = require.resolve('@vscode/test-electron');
const electron = require(electronPath);
require.cache[electronPath].exports = {
...electron,
async runVSCodeCommand() {
throw new Error('Unexpected extension installation');
}
};
const copyPath = require.resolve('./copyExtensionBinaries.ts');
require.cache[copyPath] = {
id: copyPath,
filename: copyPath,
loaded: true,
exports: {
async main() {
throw new Error('Unexpected binary copying');
}
}
};
`;

try {
const result = spawnSync(process.execPath, [
'--import', `data:text/javascript,${encodeURIComponent(preload)}`,
require.resolve('ts-node/dist/bin.js'), '-T', '.scripts/installAndCopyBinaries.ts'
], {
cwd: extensionRoot,
env: { ...process.env, CPPTOOLS_VSCODE_TEST_ROOT: testRoot },
encoding: 'utf8',
timeout: 15000
});

assert.ifError(result.error);
assert.equal(result.signal, null);
const output = result.stdout + result.stderr;
assert.match(output, /Controlled VS Code acquisition failure/);
assert.match(output, new RegExp(`acquisition failed after ${attempts} attempt`));
assert.equal(output.match(/^ACQUISITION_ATTEMPT$/gm)?.length, attempts);
assert.deepEqual([...output.matchAll(/^ACQUISITION_DELAY:(\d+)$/gm)].map(match => Number(match[1])), delays);
assert.doesNotMatch(output, /Install latest C\/C\+\+ Extension|Unexpected extension installation|Unexpected binary copying/);
assert.equal(result.status, 1, output);
} finally {
rmSync(testRoot, { recursive: true, force: true });
}
});
}
Loading
Loading