blob: 23847e881f4965457873d4f2bf98d5022c39b90d [file] [log] [blame] [edit]
// Copyright 2025 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as assert from 'assert';
import * as vscode from 'vscode';
import { describe, it } from 'mocha';
import {createSandbox, SinonStub} from 'sinon';
import {Fx} from '../../../fx';
import {
cacheTestCases,
discoverTestCasesLogic,
fxListTestCases,
queryTestCaseCache,
TestcaseDiscoveryQueue,
} from '../../../test_controller/discovery';
import {macrotask, MockedCommandInvocation, setupMockedCommand, StubbedSpawn} from '../utils';
import {Logger, initLogger} from '../../../logger';
import {Ffx} from '../../../ffx';
describe('Test Controller Discovery', function() {
const sandbox = createSandbox();
const TEST_CWD = '/path/to/workspace';
let controller: vscode.TestController;
let fx: Fx;
this.beforeEach(function() {
controller = vscode.tests.createTestController('TestControllerDiscovery', 'TestControllerDiscovery');
const log = vscode.window.createOutputChannel('test_controller.test', { log: true });
initLogger(log);
const ffx = new Ffx(TEST_CWD);
fx = new Fx(TEST_CWD, ffx);
});
this.afterEach(function() {
controller.dispose();
sandbox.restore();
});
describe('#fxListTestCases', function() {
let stubbedSpawn: StubbedSpawn;
this.beforeEach(function() {
stubbedSpawn = new StubbedSpawn(sandbox);
});
const mockCommands = (...commandInvocations: MockedCommandInvocation[]) => setupMockedCommand(
stubbedSpawn,
TEST_CWD,
commandInvocations,
);
it('resolves if no tests are discovered', async function() {
const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
const {actualInvocations, expectedInvocations} = mockCommands(
{
args: ['fx', `--invoker=Extension: ${vscode.env.appName}`, 'test', '--logpath=-', '--list', TEST_URL],
output: {},
},
);
const testItem = controller.createTestItem(TEST_URL, 'foo');
const result = await fxListTestCases(controller, fx, testItem, false);
assert.strictEqual(result.length, 0);
assert.deepStrictEqual(actualInvocations, expectedInvocations);
});
it('populates a TestItem', async function() {
const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/bar_test_component.cm';
const {actualInvocations, expectedInvocations} = mockCommands(
{
args: ['fx', `--invoker=Extension: ${vscode.env.appName}`, 'test', '--logpath=-', '--list', TEST_URL],
output: {
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: {
// eslint-disable-next-line @typescript-eslint/naming-convention
test_case_names: [
'BarTest.testA',
'BarTest.testB',
'BarTest.testC',
]
}
}
},
},
);
const testItem = controller.createTestItem(TEST_URL, 'bar');
const result = await fxListTestCases(controller, fx, testItem, false);
assert.strictEqual(result.length, 3);
assert.ok(result.find(item => item.id === 'BarTest.testA'));
assert.ok(result.find(item => item.id === 'BarTest.testB'));
assert.ok(result.find(item => item.id === 'BarTest.testC'));
assert.deepStrictEqual(actualInvocations, expectedInvocations);
});
it('specifies --no-build', async function() {
const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/bar_test_component.cm';
const {actualInvocations, expectedInvocations} = mockCommands(
{
args: ['fx', `--invoker=Extension: ${vscode.env.appName}`, 'test', '--logpath=-', '--list', TEST_URL, '--no-build'],
output: {
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: {
// eslint-disable-next-line @typescript-eslint/naming-convention
test_case_names: [
'BarTest.testA',
]
}
}
},
},
);
const testItem = controller.createTestItem(TEST_URL, 'bar');
const result = await fxListTestCases(controller, fx, testItem, true);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].id, 'BarTest.testA');
assert.deepStrictEqual(actualInvocations, expectedInvocations);
});
it('attaches the FuchsiaTest tag', async function() {
const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/bar_test_component.cm';
const {actualInvocations, expectedInvocations} = mockCommands(
{
args: ['fx', `--invoker=Extension: ${vscode.env.appName}`, 'test', '--logpath=-', '--list', TEST_URL],
output: {
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: {
// eslint-disable-next-line @typescript-eslint/naming-convention
test_case_names: [
'BarTest.testA',
]
}
}
},
},
);
const testItem = controller.createTestItem(TEST_URL, 'bar');
testItem.tags = [new vscode.TestTag('FuchsiaTest')];
const result = await fxListTestCases(controller, fx, testItem, false);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].id, 'BarTest.testA');
assert.strictEqual(result[0].tags.length, 1);
assert.strictEqual(result[0].tags[0].id, 'FuchsiaTest');
assert.deepStrictEqual(actualInvocations, expectedInvocations);
});
it('does not attach the FuchsiaTest tag', async function() {
const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/bar_test_component.cm';
const {actualInvocations, expectedInvocations} = mockCommands(
{
args: ['fx', `--invoker=Extension: ${vscode.env.appName}`, 'test', '--logpath=-', '--list', TEST_URL],
output: {
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: {
// eslint-disable-next-line @typescript-eslint/naming-convention
test_case_names: [
'BarTest.testA',
]
}
}
},
},
);
const testItem = controller.createTestItem(TEST_URL, 'bar');
testItem.tags = [new vscode.TestTag('NonFuchsiaTest')];
const result = await fxListTestCases(controller, fx, testItem, false);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].id, 'BarTest.testA');
assert.strictEqual(result[0].tags.length, 0);
assert.deepStrictEqual(actualInvocations, expectedInvocations);
});
it('throws if fx test errors', async function() {
const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
const {actualInvocations, expectedInvocations} = mockCommands(
{
args: ['fx', `--invoker=Extension: ${vscode.env.appName}`, 'test', '--logpath=-', '--list', TEST_URL],
output: new Error('{}'),
},
);
const testItem = controller.createTestItem(TEST_URL, 'foo');
await assert.rejects(fxListTestCases(controller, fx, testItem, false));
assert.deepStrictEqual(actualInvocations, expectedInvocations);
});
it('ignores irrelevant JSON objects', async function() {
const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
const MOCK_FX_JSON_OUTPUTS = [
undefined,
true,
{},
{
payload: -1,
},
{
payload: {},
},
{
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: 'invalid',
},
},
{
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: [],
},
},
{
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: {
// eslint-disable-next-line @typescript-eslint/naming-convention
test_case_names: [],
},
},
},
{
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: {
// eslint-disable-next-line @typescript-eslint/naming-convention
test_case_names: ['FooTest.testA'],
},
},
},
];
const {actualInvocations, expectedInvocations} = mockCommands(
{
args: ['fx', `--invoker=Extension: ${vscode.env.appName}`, 'test', '--logpath=-', '--list', TEST_URL],
output: MOCK_FX_JSON_OUTPUTS.map(obj => JSON.stringify(obj)).join('\n'),
},
);
const testItem = controller.createTestItem(TEST_URL, 'foo');
const result = await fxListTestCases(controller, fx, testItem, false);
assert.strictEqual(result.length, 1);
assert.ok(result.find(item => item.id === 'FooTest.testA'));
assert.deepStrictEqual(actualInvocations, expectedInvocations);
});
it('deduplicates test cases', async function() {
const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
const {actualInvocations, expectedInvocations} = mockCommands(
{
args: ['fx', `--invoker=Extension: ${vscode.env.appName}`, 'test', '--logpath=-', '--list', TEST_URL],
output: JSON.stringify({
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: {
// eslint-disable-next-line @typescript-eslint/naming-convention
test_case_names: ['FooTest.testA', 'FooTest.testB'],
},
},
}) + '\n' + JSON.stringify({
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: {
// eslint-disable-next-line @typescript-eslint/naming-convention
test_case_names: ['FooTest.testB', 'FooTest.testC'],
},
},
}),
},
);
const testItem = controller.createTestItem(TEST_URL, 'foo');
const result = await fxListTestCases(controller, fx, testItem, false);
assert.strictEqual(result.length, 3);
assert.ok(result.find(item => item.id === 'FooTest.testA'));
assert.ok(result.find(item => item.id === 'FooTest.testB'));
assert.ok(result.find(item => item.id === 'FooTest.testC'));
assert.deepStrictEqual(actualInvocations, expectedInvocations);
});
it('throws if test_case_names is the wrong type', async function() {
const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
const {actualInvocations, expectedInvocations} = mockCommands(
{
args: ['fx', `--invoker=Extension: ${vscode.env.appName}`, 'test', '--logpath=-', '--list', TEST_URL],
output: {
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: {
// eslint-disable-next-line @typescript-eslint/naming-convention
test_case_names: {
fooTest: TEST_URL
}
}
}
},
},
);
const testItem = controller.createTestItem(TEST_URL, 'foo');
await assert.rejects(fxListTestCases(controller, fx, testItem, false));
assert.deepStrictEqual(actualInvocations, expectedInvocations);
});
it('throws if test_case_names entries are the wrong type', async function() {
const TEST_URL = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
const {actualInvocations, expectedInvocations} = mockCommands(
{
args: ['fx', `--invoker=Extension: ${vscode.env.appName}`, 'test', '--logpath=-', '--list', TEST_URL],
output: {
payload: {
// eslint-disable-next-line @typescript-eslint/naming-convention
enumerate_test_cases: {
// eslint-disable-next-line @typescript-eslint/naming-convention
test_case_names: [
123,
]
}
}
},
},
);
const testItem = controller.createTestItem(TEST_URL, 'foo');
await assert.rejects(fxListTestCases(controller, fx, testItem, false));
assert.deepStrictEqual(actualInvocations, expectedInvocations);
});
});
describe('TestcaseDiscoveryQueue', function() {
describe('#discover', function() {
it('populates independent TestItem discovery requests', async function() {
const fooTestItem = controller.createTestItem('foo', 'foo');
const barTestItem = controller.createTestItem('bar', 'bar');
// eslint-disable-next-line require-await
const discoverFn = sandbox.stub().callsFake(async (_controller, _fx, testItem) => {
testItem.children.add(
controller.createTestItem(`${testItem.id}.testCase`, `${testItem.id}.testCase`)
);
});
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
await queue.discover(fooTestItem);
await queue.discover(barTestItem);
assert.strictEqual(fooTestItem.children.size, 1);
assert.ok(fooTestItem.children.get('foo.testCase'));
assert.strictEqual(barTestItem.children.size, 1);
assert.ok(barTestItem.children.get('bar.testCase'));
assert.ok(discoverFn.calledTwice);
assert.ok(discoverFn.firstCall.calledWith(controller, fx, fooTestItem));
assert.ok(discoverFn.secondCall.calledWith(controller, fx, barTestItem));
});
it('performs TestItem discovery requests in parallel', async function() {
const fooTestItem = controller.createTestItem('foo', 'foo');
const barTestItem = controller.createTestItem('bar', 'bar');
const discoverFn = sandbox.stub().callsFake(async (_controller, _fx, testItem) => {
await new Promise(resolve => setTimeout(resolve, 50));
testItem.children.add(
controller.createTestItem(`${testItem.id}.testCase`, `${testItem.id}.testCase`)
);
});
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
let finishedDiscoveries = 0;
void queue.discover(fooTestItem).then(() => finishedDiscoveries++);
void queue.discover(barTestItem).then(() => finishedDiscoveries++);
// Both discoveries should start in the background immediately.
await macrotask();
assert.ok(discoverFn.calledTwice);
assert.strictEqual(finishedDiscoveries, 0);
// Wait for both discoveries to finish.
await new Promise(resolve => setTimeout(resolve, 50));
assert.strictEqual(finishedDiscoveries, 2);
assert.strictEqual(fooTestItem.children.size, 1);
assert.ok(fooTestItem.children.get('foo.testCase'));
assert.strictEqual(barTestItem.children.size, 1);
assert.ok(barTestItem.children.get('bar.testCase'));
assert.ok(discoverFn.firstCall.calledWith(controller, fx, fooTestItem));
assert.ok(discoverFn.secondCall.calledWith(controller, fx, barTestItem));
});
it('populates varied concurrent TestItem discovery requests', async function() {
const MOCK_DISCOVERY_DURATION = 10;
const discoverFn = sandbox.stub().callsFake(
(_controller, _fx, testItem: vscode.TestItem) => new Promise<void>(resolve => {
setTimeout(() => {
const child = controller.createTestItem(
`${testItem.id}.testCase`,
`${testItem.id}.testCase`,
);
testItem.children.add(child);
resolve();
}, MOCK_DISCOVERY_DURATION);
})
);
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
const initialTestItem = controller.createTestItem('initial', 'initial');
const synchronousTestItem = controller.createTestItem('synchronous', 'synchronous');
const microtaskTestItem = controller.createTestItem('microtask', 'microtask');
const macrotaskTestItem = controller.createTestItem('macrotask', 'macrotask');
const timedTestItem = controller.createTestItem('timed', 'timed');
const discovered = {
initial: false,
synchronous: false,
microtask: false,
macrotask: false,
timed: false,
};
// Test synchronous concurrent discovery requests.
const initialPromise = queue.discover(initialTestItem)
.then(() => discovered.initial = true);
const synchronousPromise = queue.discover(synchronousTestItem)
.then(() => discovered.synchronous = true);
// Wait for the `initial` discovery to complete; keep the `synchronous` one in the queue.
await initialPromise;
assert.deepStrictEqual(
discovered,
{initial: true, synchronous: false, microtask: false, macrotask: false, timed: false},
);
assert.strictEqual(initialTestItem.children.size, 1);
assert.ok(initialTestItem.children.get('initial.testCase'));
// Test concurrent discovery request after 1 microtask.
await Promise.resolve();
const microtaskPromise = queue.discover(microtaskTestItem)
.then(() => discovered.microtask = true);
// Wait for the `synchronous` discovery to complete; keep the `microtask` one in the queue.
await synchronousPromise;
assert.deepStrictEqual(
discovered,
{initial: true, synchronous: true, microtask: false, macrotask: false, timed: false},
);
assert.strictEqual(synchronousTestItem.children.size, 1);
assert.ok(synchronousTestItem.children.get('synchronous.testCase'));
// Test concurrent discovery request after 1 macrotask.
await macrotask();
const macrotaskPromise = queue.discover(macrotaskTestItem)
.then(() => discovered.macrotask = true);
// Wait for the `microtask` discovery to complete; keep the `macrotask` one in the queue.
await microtaskPromise;
assert.deepStrictEqual(
discovered,
{initial: true, synchronous: true, microtask: true, macrotask: false, timed: false},
);
assert.strictEqual(microtaskTestItem.children.size, 1);
assert.ok(microtaskTestItem.children.get('microtask.testCase'));
// Test concurrent discovery request halfway through command execution.
await new Promise(resolve => setTimeout(resolve, MOCK_DISCOVERY_DURATION / 2));
const timedPromise = queue.discover(timedTestItem).then(() => discovered.timed = true);
// Wait for the `macrotask` discovery to complete; keep the `timed` one in the queue.
await macrotaskPromise;
assert.deepStrictEqual(
discovered,
{initial: true, synchronous: true, microtask: true, macrotask: true, timed: false},
);
assert.strictEqual(macrotaskTestItem.children.size, 1);
assert.ok(macrotaskTestItem.children.get('macrotask.testCase'));
// Wait for the `timed` discovery to complete; the discovery queue should be empty now.
await timedPromise;
assert.deepStrictEqual(
discovered,
{initial: true, synchronous: true, microtask: true, macrotask: true, timed: true},
);
assert.strictEqual(timedTestItem.children.size, 1);
assert.ok(timedTestItem.children.get('timed.testCase'));
// Check whether `TestcaseDiscoveryQueue` makes any extra unnecessary `fx` invocations.
await new Promise(resolve => setTimeout(resolve, MOCK_DISCOVERY_DURATION * 2));
assert.strictEqual(discoverFn.callCount, 5);
});
it('issues a notification without resolving if discovery fails', async function() {
const discoverFn = sandbox.stub().rejects(new Error('Discovery failed'));
const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
.resolves('Dismiss' as any);
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
const testItem = controller.createTestItem('id', 'label');
let discoverDidResolve = false;
void queue.discover(testItem).then(() => {
discoverDidResolve = true;
});
// Give time for the promise to reject and the error message to be shown.
await macrotask();
assert.ok(discoverFn.calledOnce);
assert.ok(stubShowErrorMessage.calledOnce);
assert.strictEqual(discoverDidResolve, false);
assert.strictEqual(testItem.children.size, 0);
});
it('shows error logs if the user clicks show logs', async function() {
const CHOICES = ['Show Logs'];
const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
.callsFake(() => new Promise(resolve => resolve(CHOICES.shift() as any)));
const spyLoggerShow = sandbox.spy(Logger.prototype, 'show');
const discoverFn = sandbox.stub().rejects(new Error('Discovery failed'));
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
const testItem = controller.createTestItem('id', 'label');
void queue.discover(testItem);
// Give time for the promise to reject and the error message to be shown.
await macrotask();
assert.strictEqual(stubShowErrorMessage.callCount, 2);
assert.ok(spyLoggerShow.calledOnce);
assert.strictEqual(testItem.children.size, 0);
});
it('reattempts a discovery and fails again', async function() {
const CHOICES = ['Retry'];
const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
.callsFake(() => new Promise(resolve => resolve(CHOICES.shift() as any)));
const discoverFn = sandbox.stub().rejects(new Error('Discovery failed'));
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
const testItem = controller.createTestItem('id', 'label');
let discoverDidResolve = false;
void queue.discover(testItem).then(() => {
discoverDidResolve = true;
});
// Wait for both discovery failures to complete.
await macrotask();
assert.strictEqual(stubShowErrorMessage.callCount, 2);
assert.strictEqual(discoverDidResolve, false);
assert.strictEqual(testItem.children.size, 0);
assert.strictEqual(discoverFn.callCount, 2);
});
it('issues one notification when multiple TestItems fail in a batch', async function() {
const spyShowErrorMessage = sandbox.spy(vscode.window, 'showErrorMessage');
const discoverFn = sandbox.stub().rejects(new Error('Discovery failed'));
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
const fooTestItem = controller.createTestItem('foo', 'foo');
const barTestItem = controller.createTestItem('bar', 'bar');
let discoverDidResolve = false;
void queue.discover(fooTestItem).then(() => {
discoverDidResolve = true;
});
void queue.discover(barTestItem).then(() => {
discoverDidResolve = true;
});
// Wait for both discoveries (run sequentially) to complete and propagate up to
// `queue.discover()`.
await macrotask();
assert.ok(spyShowErrorMessage.calledOnce);
assert.strictEqual(discoverDidResolve, false);
assert.strictEqual(fooTestItem.children.size, 0);
assert.strictEqual(barTestItem.children.size, 0);
assert.strictEqual(discoverFn.callCount, 2);
});
it('batches previous failures onto future discoveries', async function() {
const CHOICES = [
undefined, // User clicks the (x) button on the notification.
'Retry',
'Dismiss',
];
const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
.callsFake(() => new Promise(resolve => resolve(CHOICES.shift() as any)));
const discoverFn = sandbox.stub().rejects(new Error('Discovery failed'));
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
const fooTestItem = controller.createTestItem('foo', 'foo');
const barTestItem = controller.createTestItem('bar', 'bar');
let fooDidResolve = false;
void queue.discover(fooTestItem).then(() => fooDidResolve = true);
// Wait for the first discovery (foo) to fail.
// This shows an error message notification, which the user clicks (x) out of.
// `fooTestItem` remains in the queue as a failed item.
await macrotask();
assert.strictEqual(stubShowErrorMessage.callCount, 1);
assert.strictEqual(discoverFn.callCount, 1);
let barDidResolve = false;
void queue.discover(barTestItem).then(() => barDidResolve = true);
// Wait for the second discovery batch (containing the failed `foo` and new `bar`) and the
// third discovery batches (foo and bar again) to fail.
// The User clicks 'Retry' on the second discovery batch's error message, and 'Dismiss' on
// the third batch's error message.
await macrotask();
assert.strictEqual(stubShowErrorMessage.callCount, 3);
assert.strictEqual(discoverFn.callCount, 5);
// Final state check
assert.strictEqual(fooDidResolve, false);
assert.strictEqual(barDidResolve, false);
assert.strictEqual(fooTestItem.children.size, 0);
assert.strictEqual(barTestItem.children.size, 0);
});
it('reattempts a discovery and succeeds', async function() {
const CHOICES = ['Retry'];
const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
.callsFake(() => new Promise(resolve => resolve(CHOICES.shift() as any)));
const discoverFn = sandbox.stub()
.onFirstCall().rejects(new Error('Discovery failed'))
// eslint-disable-next-line require-await
.onSecondCall().callsFake(async (_controller, _fx, testItem: vscode.TestItem) => {
const child = controller.createTestItem('TestCase.testA', 'TestCase.testA');
testItem.children.add(child);
});
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
const testItem = controller.createTestItem('id', 'label');
await queue.discover(testItem);
assert.ok(stubShowErrorMessage.calledOnce);
assert.strictEqual(discoverFn.callCount, 2);
assert.strictEqual(testItem.children.size, 1);
assert.ok(testItem.children.get('TestCase.testA'));
});
it('only retries failing discoveries when mixed with successful discoveries', async function() {
const CHOICES = ['Retry'];
const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
.callsFake(() => new Promise(resolve => resolve(CHOICES.shift() as any)));
const fooTestItem = controller.createTestItem('foo', 'foo');
const barTestItem = controller.createTestItem('bar', 'bar');
const discoverFn = sandbox.stub()
.onFirstCall().rejects(new Error('Discovery failed'))
// eslint-disable-next-line require-await
.callsFake(async (_controller, _fx, testItem: vscode.TestItem) => {
const child = controller.createTestItem(testItem.id, testItem.id);
testItem.children.add(child);
});
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
const fooPromise = queue.discover(fooTestItem);
await queue.discover(barTestItem);
await fooPromise;
assert.ok(stubShowErrorMessage.calledOnce);
assert.strictEqual(discoverFn.callCount, 3);
// Verify call order
assert.ok(discoverFn.getCall(0).calledWith(controller, fx, fooTestItem));
assert.ok(discoverFn.getCall(1).calledWith(controller, fx, barTestItem));
assert.ok(discoverFn.getCall(2).calledWith(controller, fx, fooTestItem));
// Verify test items are populated
assert.strictEqual(fooTestItem.children.size, 1);
assert.ok(fooTestItem.children.get('foo'));
assert.strictEqual(barTestItem.children.size, 1);
assert.ok(barTestItem.children.get('bar'));
});
it('batches failing discoveries onto future discoveries when mixed with successful discoveries', async function() {
const spyShowErrorMessage = sandbox.spy(vscode.window, 'showErrorMessage');
const fooTestItem = controller.createTestItem('foo', 'foo');
const barTestItem = controller.createTestItem('bar', 'bar');
const bazTestItem = controller.createTestItem('baz', 'baz');
const discoverFn = sandbox.stub()
.onFirstCall().rejects(new Error('Discovery failed'))
// eslint-disable-next-line require-await
.callsFake(async (_controller, _fx, testItem: vscode.TestItem) => {
const child = controller.createTestItem(testItem.id, testItem.id);
testItem.children.add(child);
});
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
let isFooResolved = false;
// Discover `foo` and `bar` in the same batch; `foo` should fail while `bar` passes.
void queue.discover(fooTestItem).then(() => isFooResolved = true);
await queue.discover(barTestItem);
assert.strictEqual(isFooResolved, false);
assert.strictEqual(spyShowErrorMessage.callCount, 1);
assert.strictEqual(barTestItem.children.size, 1);
assert.ok(barTestItem.children.get('bar'));
assert.strictEqual(discoverFn.callCount, 2);
// Now, trigger a new discovery for `baz`. This will start a new discovery batch that also
// includes the previously failed `foo` item from the queue, effectively retrying it.
await queue.discover(bazTestItem);
assert.strictEqual(isFooResolved, true);
assert.strictEqual(spyShowErrorMessage.callCount, 1);
assert.strictEqual(fooTestItem.children.size, 1);
assert.ok(fooTestItem.children.get('foo'));
assert.strictEqual(bazTestItem.children.size, 1);
assert.ok(bazTestItem.children.get('baz'));
assert.strictEqual(discoverFn.callCount, 4);
// Verify call order
assert.ok(discoverFn.getCall(0).calledWith(controller, fx, fooTestItem));
assert.ok(discoverFn.getCall(1).calledWith(controller, fx, barTestItem));
assert.ok(discoverFn.getCall(2).calledWith(controller, fx, fooTestItem));
assert.ok(discoverFn.getCall(3).calledWith(controller, fx, bazTestItem));
});
it('deduplicates 2 retry buttons rapidly clicked', async function() {
const pendingErrorMessageToasts: ((choice: string | undefined) => void)[] = [];
const stubShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage')
.callsFake(() => new Promise(resolve => pendingErrorMessageToasts.push(resolve as any)));
const discoverFn = sandbox.stub().rejects(new Error('Discovery failed'));
const queue = TestcaseDiscoveryQueue.createForTesting(controller, fx, discoverFn);
const fooTestItem = controller.createTestItem('foo', 'foo');
const barTestItem = controller.createTestItem('bar', 'bar');
const resolved = {
foo: false,
bar: false,
};
// Batch 1: Discover `foo`, which fails.
void queue.discover(fooTestItem).then(() => resolved.foo = true);
await macrotask();
assert.strictEqual(discoverFn.callCount, 1);
assert.strictEqual(stubShowErrorMessage.callCount, 1);
// Batch 2: Discover `bar`, which also retries the failed `foo`. Both fail.
void queue.discover(barTestItem).then(() => resolved.bar = true);
await macrotask();
assert.strictEqual(discoverFn.callCount, 3); // foo (initial) + foo (retry) + bar
assert.strictEqual(stubShowErrorMessage.callCount, 2);
// User clicks 'Retry' on both notifications simultaneously.
pendingErrorMessageToasts.forEach(makeSelection => makeSelection('Retry'));
// Batch 3: A single, deduplicated retry batch for `foo` and `bar` runs and fails.
await macrotask();
assert.strictEqual(discoverFn.callCount, 5); // + foo (retry) + bar (retry)
assert.strictEqual(stubShowErrorMessage.callCount, 3);
// Final state check
assert.deepStrictEqual(resolved, {foo: false, bar: false});
assert.strictEqual(fooTestItem.children.size, 0);
assert.strictEqual(barTestItem.children.size, 0);
});
});
});
describe('Test Case Caching', function() {
let memento: vscode.Memento;
this.beforeEach(function() {
const storage = new Map<string, any>();
memento = {
keys: () => [...storage.keys()],
get: <T>(key: string, defaultValue?: T): T | undefined =>
storage.has(key) ? storage.get(key) : defaultValue,
// eslint-disable-next-line require-await
update: async (key: string, value: any): Promise<void> => void storage.set(key, value),
};
});
it('saves and restores TestItems from the cache', async function() {
const TEST_ID = 'fuchsia-pkg://a_repo/test_pkg#meta/foo_test_component.cm';
const TEST_URI = vscode.Uri.parse('file:///path/to/workspace/foo_test.cc');
const testCases: vscode.TestItem[] = [
controller.createTestItem('FooTest.testA', 'FooTest.testA', TEST_URI),
controller.createTestItem('FooTest.testB', 'FooTest.testB', TEST_URI),
];
testCases[0].tags = [new vscode.TestTag('FuchsiaTest')];
await cacheTestCases(memento, TEST_ID, testCases);
const restoredTestCases = queryTestCaseCache(memento, controller, TEST_ID);
assert.strictEqual(restoredTestCases?.length, 2);
const restoredTestA = restoredTestCases?.find(item => item.id === 'FooTest.testA');
assert.ok(restoredTestA);
assert.strictEqual(restoredTestA.label, 'FooTest.testA');
assert.strictEqual(restoredTestA.uri?.toString(), TEST_URI.toString());
assert.strictEqual(restoredTestA.tags.length, 1);
assert.strictEqual(restoredTestA.tags[0].id, 'FuchsiaTest');
const restoredTestB = restoredTestCases?.find(item => item.id === 'FooTest.testB');
assert.ok(restoredTestB);
assert.strictEqual(restoredTestB.label, 'FooTest.testB');
assert.strictEqual(restoredTestB.uri?.toString(), TEST_URI.toString());
assert.strictEqual(restoredTestB.tags.length, 0);
});
it('returns undefined for a cache miss', function() {
const restoredTestCases = queryTestCaseCache(memento, controller, 'non-existent-id');
assert.deepStrictEqual(restoredTestCases, undefined);
});
it('handles test items without URIs or tags', async function() {
const TEST_ID = 'test-id-no-uri';
const testCases: vscode.TestItem[] = [
controller.createTestItem('NoUri.test', 'NoUri.test'),
];
await cacheTestCases(memento, TEST_ID, testCases);
const restoredTestCases = queryTestCaseCache(memento, controller, TEST_ID);
assert.strictEqual(restoredTestCases?.length, 1);
const restoredTest = restoredTestCases[0];
assert.strictEqual(restoredTest.id, 'NoUri.test');
assert.strictEqual(restoredTest.label, 'NoUri.test');
assert.strictEqual(restoredTest.uri, undefined);
assert.strictEqual(restoredTest.tags.length, 0);
});
});
describe('#discoverTestCasesLogic', function() {
let queryCacheStub: SinonStub;
let listTestCasesNoBuildStub: SinonStub;
let listTestCasesWithBuildStub: SinonStub;
let updateChildrenStub: SinonStub;
let cacheResultStub: SinonStub;
this.beforeEach(function() {
queryCacheStub = sandbox.stub();
listTestCasesNoBuildStub = sandbox.stub();
listTestCasesWithBuildStub = sandbox.stub();
updateChildrenStub = sandbox.stub();
cacheResultStub = sandbox.stub();
});
it('should populate children from cache first, then no-build, then with-build', async function() {
const cachedChildren = [controller.createTestItem('cached', 'Testcase (cached)')];
const noBuildChildren = [controller.createTestItem('no-build', 'Testcase (no-build)')];
const withBuildChildren = [controller.createTestItem('with-build', 'Testcase (with-build)')];
queryCacheStub.returns(cachedChildren);
listTestCasesNoBuildStub.resolves(noBuildChildren);
listTestCasesWithBuildStub.resolves(withBuildChildren);
cacheResultStub.resolves();
await discoverTestCasesLogic(
queryCacheStub,
listTestCasesNoBuildStub,
listTestCasesWithBuildStub,
updateChildrenStub,
cacheResultStub
);
// Verify that the cache query, cached children update, no-build discovery, and with-build
// discovery are all stated synchronously.
assert.ok(queryCacheStub.calledOnce);
assert.ok(updateChildrenStub.calledWith(cachedChildren));
assert.ok(listTestCasesNoBuildStub.calledOnce);
assert.ok(listTestCasesWithBuildStub.calledOnce);
// Wait for both outstanding listTestCases discovery promises to resolve.
await macrotask();
// Verify children are populated in the correct order.
assert.strictEqual(updateChildrenStub.callCount, 3);
assert.ok(updateChildrenStub.getCall(0).calledWith(cachedChildren));
assert.ok(updateChildrenStub.getCall(1).calledWith(noBuildChildren));
assert.ok(updateChildrenStub.getCall(2).calledWith(withBuildChildren));
// Verify cache is updated with the no-build results, then with the with-build results.
assert.strictEqual(cacheResultStub.callCount, 2);
assert.ok(cacheResultStub.getCall(0).calledWith(noBuildChildren));
assert.ok(cacheResultStub.getCall(1).calledWith(withBuildChildren));
});
it('should not block on no-build results', async function() {
const cachedChildren = [controller.createTestItem('cached', 'Testcase (cached)')];
const withBuildChildren = [controller.createTestItem('with-build', 'Testcase (with-build)')];
queryCacheStub.returns(cachedChildren);
listTestCasesNoBuildStub.returns(new Promise(() => {})); // Never finishes.
listTestCasesWithBuildStub.resolves(withBuildChildren); // Finishes quickly.
cacheResultStub.resolves();
await discoverTestCasesLogic(
queryCacheStub,
listTestCasesNoBuildStub,
listTestCasesWithBuildStub,
updateChildrenStub,
cacheResultStub
);
// Verify that the cache query, cached children update, no-build discovery, and with-build
// discovery are all stated synchronously.
assert.ok(queryCacheStub.calledOnce);
assert.ok(updateChildrenStub.calledWith(cachedChildren));
assert.ok(listTestCasesNoBuildStub.calledOnce);
assert.ok(listTestCasesWithBuildStub.calledOnce);
// Wait for withBuildChildren to resolve.
await macrotask();
// Verify children are populated in the correct order.
assert.strictEqual(updateChildrenStub.callCount, 2);
assert.ok(updateChildrenStub.getCall(0).calledWith(cachedChildren));
assert.ok(updateChildrenStub.getCall(1).calledWith(withBuildChildren));
// Verify cache is updated only with the with-build results.
assert.strictEqual(cacheResultStub.callCount, 1);
assert.ok(cacheResultStub.calledWith(withBuildChildren));
});
it('should reject and update with no-build results if with-build fails', async function() {
const cachedChildren = [controller.createTestItem('cached', 'Testcase (cached)')];
const noBuildChildren = [controller.createTestItem('no-build', 'Testcase (no-build)')];
queryCacheStub.returns(cachedChildren);
listTestCasesNoBuildStub.resolves(noBuildChildren);
listTestCasesWithBuildStub.rejects(new Error('Build failed'));
cacheResultStub.resolves();
// discoverTestCasesLogic should reject, which helps TestcaseDiscoveryQueue present an error
// notification.
await assert.rejects(discoverTestCasesLogic(
queryCacheStub,
listTestCasesNoBuildStub,
listTestCasesWithBuildStub,
updateChildrenStub,
cacheResultStub
));
// Verify that the cache query, cached children update, no-build discovery, and with-build
// discovery are all stated synchronously.
assert.ok(queryCacheStub.calledOnce);
assert.ok(updateChildrenStub.calledWith(cachedChildren));
assert.ok(listTestCasesNoBuildStub.calledOnce);
assert.ok(listTestCasesWithBuildStub.calledOnce);
// Wait for noBuildChildren to resolve.
await macrotask();
// Verify children are populated in the correct order.
assert.strictEqual(updateChildrenStub.callCount, 2);
assert.ok(updateChildrenStub.getCall(0).calledWith(cachedChildren));
assert.ok(updateChildrenStub.getCall(1).calledWith(noBuildChildren));
// Verify cache is updated only with the no-build results.
assert.strictEqual(cacheResultStub.callCount, 1);
assert.ok(cacheResultStub.calledWith(noBuildChildren));
});
it('should not update with no-build results if with-build has already updated', async function() {
const cachedChildren = [controller.createTestItem('cached', 'Testcase (cached)')];
const noBuildChildren = [controller.createTestItem('no-build', 'Testcase (no-build)')];
const withBuildChildren = [controller.createTestItem('with-build', 'Testcase (with-build)')];
queryCacheStub.returns(cachedChildren);
listTestCasesNoBuildStub.returns(new Promise(
resolve => setTimeout(() => resolve(noBuildChildren), 50)
)); // Finishes slower.
listTestCasesWithBuildStub.resolves(withBuildChildren); // Finishes quicker.
cacheResultStub.resolves();
await discoverTestCasesLogic(
queryCacheStub,
listTestCasesNoBuildStub,
listTestCasesWithBuildStub,
updateChildrenStub,
cacheResultStub
);
// Verify that the cache query, cached children update, no-build discovery, and with-build
// discovery are all stated synchronously.
assert.ok(queryCacheStub.calledOnce);
assert.ok(updateChildrenStub.calledWith(cachedChildren));
assert.ok(listTestCasesNoBuildStub.calledOnce);
assert.ok(listTestCasesWithBuildStub.calledOnce);
// Wait for both promises to resolve.
await macrotask();
await new Promise(resolve => setTimeout(resolve, 50));
// Verify `noBuildChildren` is not populated into the list of children.
assert.strictEqual(updateChildrenStub.callCount, 2);
assert.ok(updateChildrenStub.getCall(0).calledWith(cachedChildren));
assert.ok(updateChildrenStub.getCall(1).calledWith(withBuildChildren));
// Verify cache is updated only with the with-build results.
assert.strictEqual(cacheResultStub.callCount, 1);
assert.ok(cacheResultStub.calledWith(withBuildChildren));
});
it('should populate from no-build, then with-build when cache is unavailable', async function() {
const noBuildChildren = [controller.createTestItem('no-build', 'Testcase (no-build)')];
const withBuildChildren = [controller.createTestItem('with-build', 'Testcase (with-build)')];
queryCacheStub.returns(undefined); // Cache is unavailable
listTestCasesNoBuildStub.resolves(noBuildChildren);
listTestCasesWithBuildStub.resolves(withBuildChildren);
cacheResultStub.resolves();
await discoverTestCasesLogic(
queryCacheStub,
listTestCasesNoBuildStub,
listTestCasesWithBuildStub,
updateChildrenStub,
cacheResultStub
);
// Verify that the cache query, and both discoveries are all stated synchronously.
assert.ok(queryCacheStub.calledOnce);
assert.ok(listTestCasesNoBuildStub.calledOnce);
assert.ok(listTestCasesWithBuildStub.calledOnce);
// Wait for both promises to resolve.
await macrotask();
// Verify children are populated in the correct order.
assert.strictEqual(updateChildrenStub.callCount, 2);
assert.ok(updateChildrenStub.getCall(0).calledWith(noBuildChildren));
assert.ok(updateChildrenStub.getCall(1).calledWith(withBuildChildren));
// Verify cache is updated with the no-build results, then with the with-build results.
assert.strictEqual(cacheResultStub.callCount, 2);
assert.ok(cacheResultStub.getCall(0).calledWith(noBuildChildren));
assert.ok(cacheResultStub.getCall(1).calledWith(withBuildChildren));
});
it('should handle when no test cases are available from any source', async function() {
queryCacheStub.returns(undefined);
listTestCasesNoBuildStub.rejects(new Error('failed to connect to target device'));
listTestCasesWithBuildStub.rejects(new Error('failed to connect to target device'));
cacheResultStub.resolves();
await assert.rejects(discoverTestCasesLogic(
queryCacheStub,
listTestCasesNoBuildStub,
listTestCasesWithBuildStub,
updateChildrenStub,
cacheResultStub
));
// Verify that the cache query, no-build discovery, and with-build discovery are all called.
assert.ok(queryCacheStub.calledOnce);
assert.ok(listTestCasesNoBuildStub.calledOnce);
assert.ok(listTestCasesWithBuildStub.calledOnce);
// Wait for promises to resolve.
await macrotask();
// Verify children aren't updated.
assert.strictEqual(updateChildrenStub.callCount, 0);
// Verify cache isn't updated.
assert.strictEqual(cacheResultStub.callCount, 0);
});
it('resolves when only built testcase results are available', async function() {
const withBuildChildren = [controller.createTestItem('with-build', 'Testcase (with-build)')];
queryCacheStub.returns(undefined);
listTestCasesNoBuildStub.rejects(new Error('test not built yet'));
listTestCasesWithBuildStub.resolves(withBuildChildren);
cacheResultStub.resolves();
await discoverTestCasesLogic(
queryCacheStub,
listTestCasesNoBuildStub,
listTestCasesWithBuildStub,
updateChildrenStub,
cacheResultStub
);
// Verify that the cache query, no-build discovery, and with-build discovery are all called.
assert.ok(queryCacheStub.calledOnce);
assert.ok(listTestCasesNoBuildStub.calledOnce);
assert.ok(listTestCasesWithBuildStub.calledOnce);
// Wait for promises to resolve.
await macrotask();
// Verify children are only populated with the with-build results.
assert.strictEqual(updateChildrenStub.callCount, 1);
assert.ok(updateChildrenStub.calledWith(withBuildChildren));
// Verify cache is only updated with the with-build results.
assert.strictEqual(cacheResultStub.callCount, 1);
assert.ok(cacheResultStub.calledWith(withBuildChildren));
});
});
});