Skip to content
Closed
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
6 changes: 3 additions & 3 deletions extension/src/test-e2e/edgeCases.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ExecutableLaunchConfiguration } from '../dcp/types';
import { getCommandInvocationCount, getDebugLaunchCount, isSamePath, waitForCommandOutcome, waitForDebugLaunch, waitForDebugSessionStartup, waitForExtensionState, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForRunningAppHost, waitForSelectedWorkspaceAppHost, waitForWorkspaceAppHost } from './helpers/assertions';
import { createEmptyAppHostProject, createExternalSingleFileAppHost, executeE2eControlCommand, getGeneratedAppHostPath, getGeneratedProjectRoot, isProcessAlive, removeExternalSingleFileAppHost, removeGeneratedProject, restoreWorkspaceAppHostConfig, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setDebugLaunchSuppressedForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning, waitForKnownProcessExit, writeFileWithRetry, writeWorkspaceAppHostConfigForPath } from './helpers/fixtures';
import { getPrimaryAppHostProjectPath, getWorkspaceRoot } from './helpers/paths';
import { chooseActiveQuickPick, executeCommandFromPalette, openAspireView, waitForEditorTitle, waitForNotificationMessage } from './helpers/vscode';
import { chooseActiveQuickPick, dismissAllNotifications, executeCommandFromPalette, openAspireView, waitForEditorTitle, waitForNotificationMessage } from './helpers/vscode';

interface DebugSessionProcessInfo {
appHostPath?: string;
Expand Down Expand Up @@ -219,10 +219,10 @@ builder.Build().Run();`));
await executeE2eControlCommand({ name: 'runAppHost', appHostPath }, { waitFor: 'started' });
await waitForCommandOutcome('aspire-vscode.runAppHost', 'success', 180000, runBefore);

const notification = await waitForNotificationMessage(
await waitForNotificationMessage(
'Set up Python debugging support to debug resources in this app.',
60000);
await notification.dismiss();
await dismissAllNotifications();
});

test('process-owner cleanup stops the owned CLI and AppHost process tree', async () => {
Expand Down
6 changes: 5 additions & 1 deletion extension/src/test-e2e/helpers/vscode.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { AspireExtensionE2EControlCommand } from '../../types/extensionApi';
import { applyE2eControl } from './assertions';
import { BottomBarPanel, By, EditorView, InputBox, ModalDialog, Notification, SideBarView, TreeItem, TreeSection, VSBrowser, WebView, Workbench } from './extester';
import { error as webDriverError } from 'selenium-webdriver';

Expand Down Expand Up @@ -666,7 +668,9 @@ export async function dismissAllNotifications(timeoutMs = 30000): Promise<void>
return true;
}

await notifications[0].dismiss();
// Clicking a toast's clear action needs the toast hovered, and toasts re-layout as others
// arrive or expire. VS Code's own command clears every notification without the DOM.
await applyE2eControl({ command: { name: 'clearAllNotifications' } satisfies AspireExtensionE2EControlCommand });
return false;
}
catch (error) {
Expand Down
22 changes: 22 additions & 0 deletions extension/src/test/e2eStateFileBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,28 @@ suite('E2E state file bridge', () => {
provider.dispose();
});

test('clears notifications with the VS Code clear-all command', async () => {
const repository = createRepository([]);
const terminalProvider = {} as AspireTerminalProvider;
const launchService = createLaunchService();
const provider = {} as AspireAppHostTreeProvider;
const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves();
const markStarted = sandbox.spy();

await dispatchControlCommand(
{ name: 'clearAllNotifications' },
repository,
launchService,
provider,
terminalProvider,
markStarted);

assert.deepStrictEqual(executeCommandStub.getCalls().map(call => call.args), [
['notifications.clearAll'],
]);
assert.strictEqual(markStarted.callCount, 1);
});

test('scopes missing and exact C# extension version overrides to resource configuration creation', async () => {
const repository = createRepository([]);
const terminalProvider = {} as AspireTerminalProvider;
Expand Down
86 changes: 86 additions & 0 deletions extension/src/test/notificationWaitForMessage.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { error as webDriverError } from 'selenium-webdriver';

Expand All @@ -21,6 +23,7 @@ interface NotificationExtesterStubModule {
}

interface VscodeHelpersModule {
dismissAllNotifications(timeoutMs?: number): Promise<void>;
waitForCodeLensText(fileName: string, expectedText: string, timeoutMs?: number): Promise<string[]>;
waitForEditorTitle(expectedText: string, timeoutMs?: number): Promise<string>;
waitForNotificationMessage(expectedText: string, timeoutMs?: number): Promise<NotificationLike>;
Expand All @@ -32,6 +35,7 @@ const extesterStubModulePath = path.join(extensionRoot, 'out', 'test', 'fixtures
const compiledE2eHelpersPath = path.join(extensionRoot, 'out', 'test-e2e', 'helpers');
const compiledExtesterModulePath = path.join(compiledE2eHelpersPath, 'extester.js');
const compiledVscodeHelpersModulePath = path.join(compiledE2eHelpersPath, 'vscode.js');
const e2eBridgeEnvironmentVariables = ['ASPIRE_EXTENSION_E2E_CONTROL_FILE', 'ASPIRE_EXTENSION_E2E_STATE_FILE', 'ASPIRE_EXTENSION_E2E_RUN_ID'];

suite('waitForNotificationMessage', () => {
const originalExtesterModule = process.env.ASPIRE_EXTENSION_E2E_EXTESTER_MODULE;
Expand Down Expand Up @@ -137,6 +141,35 @@ suite('waitForNotificationMessage', () => {
);
});

test('dismisses notifications through VS Code clear-all instead of hovering each toast', async () => {
const { stub, vscode } = loadNotificationWaitModules();
const notification: NotificationLike = {
getMessage: async () => "Required option '--message' was not provided.",
// VS Code hides a toast's clear action once the toast moves out from under the pointer.
dismiss: async () => {
throw new webDriverError.ElementNotInteractableError('element not interactable');
},
};
const bridge = startFakeE2eBridge(commandName => {
if (commandName === 'clearAllNotifications') {
stub.setNotificationPolls([]);
}
});

stub.setNotificationPolls([[notification], [notification], [notification]]);

try {
await vscode.dismissAllNotifications(5000);
}
finally {
bridge.dispose();
}

assert.deepStrictEqual(bridge.commandNames, ['clearAllNotifications']);
// Clearing replaced the remaining polls, so a single poll after it found no notifications.
assert.strictEqual(stub.getNotificationWaitState().notificationPollCount, 1);
});

test('waits for the tab to exist and for the provider to produce lenses', async () => {
const { stub, vscode } = loadNotificationWaitModules();

Expand Down Expand Up @@ -184,6 +217,59 @@ function loadNotificationWaitModules(): {
return { stub, vscode };
}

/**
* Answers E2E control commands the way the extension host bridge does: it reads each new control
* revision, reports it applied in the state file and records the command name.
*/
function startFakeE2eBridge(onCommand: (commandName: string) => void): { commandNames: string[]; dispose(): void } {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-e2e-bridge-'));
const controlFile = path.join(directory, 'control.json');
const stateFile = path.join(directory, 'state.json');
const originalEnvironment = new Map(e2eBridgeEnvironmentVariables.map(name => [name, process.env[name]]));
const commandNames: string[] = [];
let lastRevision = -1;

process.env.ASPIRE_EXTENSION_E2E_CONTROL_FILE = controlFile;
process.env.ASPIRE_EXTENSION_E2E_STATE_FILE = stateFile;
delete process.env.ASPIRE_EXTENSION_E2E_RUN_ID;
fs.writeFileSync(stateFile, JSON.stringify({}));

const interval = setInterval(() => {
if (!fs.existsSync(controlFile)) {
return;
}

const payload = JSON.parse(fs.readFileSync(controlFile, 'utf8')) as { revision: number; command?: { name: string } };
if (payload.revision <= lastRevision) {
return;
}

lastRevision = payload.revision;
if (payload.command) {
commandNames.push(payload.command.name);
onCommand(payload.command.name);
}

fs.writeFileSync(stateFile, JSON.stringify({ control: { revision: payload.revision, status: 'applied', startedObserved: true } }));
}, 10);

return {
commandNames,
dispose: () => {
clearInterval(interval);
for (const [name, value] of originalEnvironment) {
if (value === undefined) {
delete process.env[name];
}
else {
process.env[name] = value;
}
}
fs.rmSync(directory, { recursive: true, force: true });
},
};
}

function resetLoadedNotificationModules(): void {
for (const modulePath of [compiledVscodeHelpersModulePath, compiledExtesterModulePath, extesterStubModulePath]) {
try {
Expand Down
4 changes: 4 additions & 0 deletions extension/src/testing/e2eStateFileBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,10 @@ export async function executeE2eControlCommand(
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
return getActiveEditorInfo();
}
case 'clearAllNotifications': {
markStarted();
return await vscode.commands.executeCommand('notifications.clearAll');
}
case 'getRegisteredAspireCommands': {
markStarted();
const commands = await vscode.commands.getCommands(true);
Expand Down
1 change: 1 addition & 0 deletions extension/src/types/extensionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export type AspireExtensionE2EControlCommand =
| { name: 'startDebugging'; configurationName: string }
| { name: 'stopDebugging' }
| { name: 'closeAllEditors' }
| { name: 'clearAllNotifications' }
| { name: 'getRegisteredAspireCommands' }
| { name: 'getRegisteredLanguageModelTools' }
| { name: 'prepareLanguageModelToolInvocation'; toolName: string; input: Record<string, unknown> }
Expand Down
Loading