diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..6f8332db3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,424 @@ +# GitHub Copilot Instructions for vscode-documentdb + +This document provides comprehensive guidelines and context for GitHub Copilot to assist contributors working on the **DocumentDB for VS Code** repository. + +--- + +## Context + +- **Project Type**: VS Code Extension + API Host for Plugins to this VS Code Extension +- **Language**: TypeScript (strict mode enabled) +- **Framework / Libraries**: + - React for web views (exclusively in `/src/webviews/`) + - VS Code Extension APIs + - MongoDB drivers and Azure SDK + - Webpack for bundling + - Jest for testing + +--- + +## 1. Branching Strategy + +### Branch Types + +- **`main`**: Production-ready code. All releases are tagged here. +- **`next`**: Staging for the upcoming release. Pull requests should be created against this branch unless explicitly stated otherwise. +- **`dev//`**: Individual feature branches for personal development. +- **`feature/`**: Shared branches for large features requiring collaboration. + +### Pull Request Guidelines + +- Pull requests should generally target the `next` branch. +- Changes merged into `next` will be reviewed and manually merged into `main` during the release process. +- PRs targeting `main` are reserved for hotfixes or release-specific changes. +- Ensure all automated checks pass before requesting a review. + +--- + +## 2. Repository Structure + +### Core Folders + +- **`api/`**: Contains API-related code. This folder has its own `package.json` and is a separate Node.js project used to expose APIs for the VS Code extension. +- **`src/`**: The main source code for the VS Code extension. + - **`src/webviews/`**: Contains web view components built with React. + - **`src/commands/`**: Command handlers for the VS Code extension. Always create a folder with the command name, and then the handler in that folder. + - **`src/services/`**: Contains singleton services and utility functions. + - **`src/utils/`**: Utility functions and helpers. + - **`src/tree/`**: Tree view components for the VS Code extension. + - **`src/tree/connections-view/`**: Contains tree branch data provider for the Connections View. + - **`src/tree/discovery-view/`**: Contains tree branch data provider for the Discovery View. + - **`src/tree/documentdb/`**: Contains shared tree items for all tree views (related to DocumentDB). + - **`src/documentdb/`**: Core DocumentDB/MongoDB functionality and models. + - **`src/plugins/`**: Plugin architecture and implementations. + - **`src/extension.ts`**: The entry point for the VS Code extension. +- **`l10n/`**: Localization files and scripts. +- **`test/`**: Test files and utilities. +- **`docs/`**: Documentation files related to the project. Used to generate documentation. +- **`package.json`**: Defines dependencies, scripts, and metadata for the project. + +--- + +## 3. Contribution Guidelines + +### Pre-Commit Checklist + +- Follow the branching strategy outlined above. +- Ensure all tests pass locally before pushing changes. +- Use l10n for any user-facing strings with `vscode.l10n.t()`. +- Use `npm run prettier-fix` to format your code before committing. +- Use `npm run lint` to check for linting errors. +- Use `npm run l10n` to update localization files in case you change any user-facing strings. +- Ensure TypeScript compilation passes without errors. + +--- + +## 4. TypeScript Coding Guidelines + +### Strict TypeScript Practices + +- **Never use `any`** - Use proper types, `unknown`, or create specific interfaces. +- **Prefer `interface` over `type`** for object shapes and extensible contracts. +- **Use `type` for unions, primitives, and computed types**. +- **Always specify return types** for functions, especially public APIs. +- **Use generic constraints** with `extends` for type safety. +- **Prefer `const assertions`** for literal types: `as const`. + +### Function and Class Patterns + +```typescript +// ✅ Good - Named function with explicit return type +export function createConnection(config: ConnectionConfig): Promise { + // implementation +} + +// ✅ Good - Interface for object shapes +interface ConnectionConfig { + readonly host: string; + readonly port: number; + readonly database?: string; +} + +// ✅ Good - Prefer enums over type unions for well-defined sets of constants +enum ConnectionStatus { + Connected = 'connected', + Disconnected = 'disconnected', + Error = 'error', +} + +enum ConnectionMode { + ConnectionString, + ServiceDiscovery, +} + +// ✅ Good - Type for computed types and flexible unions +type EventMap = Record void>; + +// ✅ Good - Generic with constraints +function createService(ServiceClass: new () => T): T { + return new ServiceClass(); +} +``` + +### Error Handling Patterns + +- **Always use typed error handling** with custom error classes. +- **Use `Result` pattern** for operations that can fail. +- **Wrap VS Code APIs** with proper error boundaries. + +```typescript +// ✅ Good - Custom error classes +export class DocumentDBConnectionError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly cause?: Error, + ) { + super(message); + this.name = 'DocumentDBConnectionError'; + } +} + +// ✅ Good - Result pattern +type Result = { success: true; data: T } | { success: false; error: E }; +``` + +### VS Code Extension Patterns + +- **Use proper VS Code API types** from `@types/vscode`. +- **Implement proper disposal** for disposables with `vscode.Disposable`. +- **Use command registration patterns** with proper error handling. +- **Leverage VS Code's theming** and l10n systems. + +```typescript +// ✅ Good - Command registration +export function registerCommands(context: vscode.ExtensionContext): void { + const disposables = [ + vscode.commands.registerCommand('documentdb.connect', async (item) => { + try { + await handleConnect(item); + } catch (error) { + void vscode.window.showErrorMessage(vscode.l10n.t('Failed to connect: {0}', error.message)); + } + }), + ]; + + context.subscriptions.push(...disposables); +} +``` + +### Async/Await Best Practices + +- **Always use `async/await`** over Promises chains. +- **Handle errors with try/catch** blocks. +- **Use `Promise.allSettled()`** for parallel operations that can fail independently. +- **Avoid `void` except for fire-and-forget operations**. + +### Import/Export Patterns + +- **Use named exports** for better tree-shaking and IDE support. +- **Group imports** by type: Node.js built-ins, third-party, local. +- **Use barrel exports** (`index.ts`) for clean module interfaces. + +```typescript +// ✅ Good - Import grouping +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { ConnectionManager } from '../services/ConnectionManager'; +import { DocumentDBError } from '../utils/errors'; + +import type { ConnectionConfig, DatabaseInfo } from './types'; +``` + +### Anti-Patterns to Avoid + +- ❌ **Never use `any`** - Use `unknown` and type guards instead. +- ❌ **Don't use `function` declarations** - Use `const` with arrow functions or named function expressions. +- ❌ **Avoid nested ternaries** - Use proper if/else or switch statements. +- ❌ **Don't ignore Promise rejections** - Always handle errors. +- ❌ **Avoid mutations** - Prefer immutable operations. +- ❌ **Don't use `@ts-ignore`** - Fix the underlying type issue. +- ❌ **Avoid large switch statements** - Use object maps or polymorphism. + +```typescript +// ❌ Bad +const result: any = await someOperation(); + +// ✅ Good +const result: unknown = await someOperation(); +if (isConnectionResult(result)) { + // now result is properly typed +} + +// ❌ Bad +function processData(data: any) { + return data.something?.else; +} + +// ✅ Good +function processData(data: unknown): string | undefined { + if (isDataObject(data) && typeof data.something?.else === 'string') { + return data.something.else; + } + return undefined; +} +``` + +--- + +## 5. Testing Guidelines + +### Testing Frameworks + +- Use `Jest` for unit and integration tests. +- Use `@types/jest` for TypeScript support. + +### Testing Structure + +- Keep tests in the same directory structure as the code they test. +- Test business logic in services; mock dependencies using `jest.mock()` for unit tests. +- Use descriptive test names that explain the expected behavior. +- Group related tests with `describe` blocks. + +### Testing Patterns + +```typescript +// ✅ Good - Descriptive test structure +describe('ConnectionManager', () => { + describe('when connecting to DocumentDB', () => { + it('should return connection for valid credentials', async () => { + // Arrange + const config: ConnectionConfig = { + host: 'localhost', + port: 27017, + }; + + // Act + const result = await connectionManager.connect(config); + + // Assert + expect(result.success).toBe(true); + }); + }); +}); +``` + +--- + +## 6. Code Organization and Architecture + +### Service Layer Pattern + +- Use singleton services for shared functionality. +- Implement proper dependency injection patterns. +- Keep services focused on single responsibilities. + +### Command Pattern + +- Each command should have its own folder under `src/commands/`. +- Implement proper error handling and user feedback. +- Use VS Code's progress API for long-running operations. + +### Wizard Implementation Pattern + +When implementing wizards (multi-step user flows), follow the established pattern used in commands like `renameConnection` and `updateCredentials`: + +**Required Files Structure:** + +``` +src/commands/yourCommand/ +├── YourCommandWizardContext.ts # Wizard state/data interface +├── PromptXStep.ts # User input collection steps +├── PromptYStep.ts # Additional prompt steps as needed +├── ExecuteStep.ts # Final execution logic +└── yourCommand.ts # Main wizard orchestration +``` + +**Implementation Pattern:** + +1. **Context File** (`*WizardContext.ts`): Define the wizard's state and data + +```typescript +export interface YourCommandWizardContext extends IActionContext { + // Target item details + targetId: string; + + // User input properties + userInput?: string; + validatedData?: SomeType; +} +``` + +2. **Prompt Steps** (`Prompt*Step.ts`): Collect user input with validation + +```typescript +export class PromptUserInputStep extends AzureWizardPromptStep { + public async prompt(context: YourCommandWizardContext): Promise { + const userInput = await context.ui.showInputBox({ + prompt: vscode.l10n.t('Enter your input'), + validateInput: (input) => this.validateInput(input), + asyncValidationTask: (input) => this.asyncValidate(context, input), + }); + + context.userInput = userInput.trim(); + } + + public shouldPrompt(): boolean { + return true; + } +} +``` + +3. **Execute Step** (`ExecuteStep.ts`): Perform the final operation + +```typescript +export class ExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + public async execute(context: YourCommandWizardContext): Promise { + // Perform the actual operation using context data + await performOperation(context); + } + + public shouldExecute(context: YourCommandWizardContext): boolean { + return !!context.userInput; // Validate required data exists + } +} +``` + +4. **Main Wizard File** (`yourCommand.ts`): Orchestrate the wizard flow + +```typescript +export async function yourCommand(context: IActionContext, targetItem: SomeItem): Promise { + const wizardContext: YourCommandWizardContext = { + ...context, + targetId: targetItem.id, + }; + + const wizard = new AzureWizard(wizardContext, { + title: vscode.l10n.t('Your Command Title'), + promptSteps: [new PromptUserInputStep()], + executeSteps: [new ExecuteStep()], + }); + + await wizard.prompt(); + await wizard.execute(); + + // Refresh relevant views if needed + await refreshView(context, Views.ConnectionsView); +} +``` + +### Tree View Architecture + +- Use proper data providers that implement `vscode.TreeDataProvider`. +- Implement refresh mechanisms with event emitters. +- Use proper icons and theming support. + +--- + +## 7. Localization (l10n) + +- **Always use `vscode.l10n.t()`** for user-facing strings. +- **Use descriptive keys** that explain the context. +- **Include placeholders** for dynamic content. +- **Run `npm run l10n`** after adding new strings. + +```typescript +// ✅ Good - Proper l10n usage +const message = vscode.l10n.t( + 'Connected to {0} database with {1} collections', + databaseName, + collectionCount.toString(), +); +``` + +--- + +## 8. Performance and Best Practices + +- **Use lazy loading** for heavy operations. +- **Implement proper caching** for expensive computations. +- **Use VS Code's built-in APIs** for file operations and UI. +- **Minimize bundle size** by avoiding unnecessary dependencies. +- **Use proper disposal patterns** to prevent memory leaks. + +--- + +## 9. Security Guidelines + +- **Never log sensitive information** (passwords, tokens, connection strings). +- **Use VS Code's secure storage** for credentials. +- **Validate all user inputs** before processing. +- **Use proper error messages** that don't leak sensitive details. + +--- + +## 10. Additional Notes + +- Use `next` as the default branch for new features and fixes. +- Avoid committing directly to `main` unless explicitly instructed. +- Ensure compatibility with Node.js version specified in `.nvmrc`. +- Follow the project's ESLint configuration for consistent code style. +- Use webpack for bundling and ensure proper tree-shaking. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 78e48c628..2aedc58f8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,11 +3,11 @@ name: Node PR Lint, Build and Test # This workflow handles three scenarios: # # 1. Push to `next`, `dev/*`, or `feature/*` branches: -# - Runs `code-quality-and-tests` and `integration-tests` -# - Skips `build-and-package` to save resources and focus on code quality +# - Runs `code-quality-and-tests` +# - Skips `integration-tests` and `build-and-package` to save resources and focus on code quality # # 2. Pull Requests to `main` or `next`: -# - Runs all jobs: code checks, tests, and packaging +# - Runs all jobs: code checks, tests, integration tests, and packaging # - Ensures complete validation including artifact generation before merge # # 3. Push to `main`: @@ -77,6 +77,13 @@ jobs: integration-tests: name: Integration Tests runs-on: ubuntu-latest + needs: [code-quality-and-tests] + if: | + github.ref == 'refs/heads/main' || + (startsWith(github.ref, 'refs/pull/') && ( + github.base_ref == 'main' || + github.base_ref == 'next' + )) defaults: run: working-directory: '.' @@ -109,8 +116,7 @@ jobs: build-and-package: name: Build & Package Artifacts runs-on: ubuntu-latest - needs: [code-quality-and-tests, integration-tests] - + needs: [code-quality-and-tests] if: | github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/pull/') && ( diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3655c7a9d..eab16a554 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,25 +2,54 @@ Thank you for your interest in contributing to the **DocumentDB for VS Code** extension. This guide helps you set up your development environment and configure Visual Studio Code to effectively contribute to the extension. -The document consists of two sections: +The document consists of three sections: -1. [Machine Setup](#1-machine-setup) -2. [VS Code Configuration](#2-vs-code-configuration) +1. [Branching Strategy](#1-branching-strategy) +2. [Machine Setup](#2-machine-setup) +3. [VS Code Configuration](#3-vs-code-configuration) -## 1. Machine Setup +## 1. Branching Strategy + +The repository follows a structured branching strategy to ensure smooth development and release processes: + +- **`main`** — Production-ready code. All releases are tagged here. +- **`next`** — Staging for the upcoming release. Completed features are merged here. +- **`dev//`** — Individual feature branches for personal development. +- **`feature/`** — Shared branches for large features requiring collaboration. + +### Pull Requests and GitHub Actions + +GitHub Actions are configured to perform automated checks on the repository. The intensity of these checks depends on the target branch: + +1. **Push to `next`, `dev/*`, or `feature/*` branches**: + + - Runs basic code quality checks and tests. + - Skips resource-intensive jobs like integration tests and packaging to focus on code validation. + +2. **Pull Requests to `main` or `next`**: + + - Executes all jobs, including code checks, tests, and packaging. + - Ensures complete validation before merging, including artifact generation. + +3. **Push to `main`**: + - Runs the full workflow for release validation and artifact generation. + +This setup ensures that contributions are thoroughly validated while optimizing resource usage during development. + +## 2. Machine Setup Follow these instructions to configure your machine for JavaScript/TypeScript development using Windows Subsystem for Linux (WSL2) and Visual Studio Code. > This setup assumes you're using WSL2 on Windows. However, you can use a Linux or Windows setup exclusively if preferred. -### 1.1. Install Ubuntu 22.\* on Windows +### 2.1. Install Ubuntu 22.\* on Windows - Install **Ubuntu 22.\*** from the Microsoft Store and launch it to configure your Linux user account. - Your development environment and tools will reside within `WSL2`. - VS Code integrates seamlessly with `WSL2` instances, enabling smooth development from your Windows machine. -### 1.2. Update Ubuntu Packages +### 2.2. Update Ubuntu Packages Open your Ubuntu terminal and run: @@ -29,7 +58,7 @@ sudo apt update sudo apt upgrade ``` -### 1.3. Install Node.js with FNM (Fast Node Manager) +### 2.3. Install Node.js with FNM (Fast Node Manager) - `FNM` helps with installing and switching Node.js versions easily. This is useful for testing compatibility across different Node.js versions. @@ -44,7 +73,7 @@ fnm default 22 node --version ``` -### 1.4. Install TypeScript Globally (optional) +### 2.4. Install TypeScript Globally (optional) You can install TypeScript globally: @@ -52,13 +81,13 @@ You can install TypeScript globally: npm install -g typescript ``` -## 2. VS Code Configuration +## 3. VS Code Configuration This section explains how to clone the **DocumentDB for VS Code** repository and set up Visual Studio Code for development and debugging. -### 2.1. Steps to Clone and Set Up Repository +### 3.1. Steps to Clone and Set Up Repository -1. Ensure you have completed the [Machine Setup](#1-machine-setup) steps. +1. Ensure you have completed the [Machine Setup](#2-machine-setup) steps. 2. Fork or directly clone the official repository: @@ -79,7 +108,7 @@ npm install npm run build ``` -### 2.2. Launching and Debugging in VS Code +### 3.2. Launching and Debugging in VS Code To effectively isolate development environments, it is beneficial to create and use a separate VS Code profile. diff --git a/src/services/tasks/DummyTask.test.ts b/src/services/tasks/DummyTask.test.ts new file mode 100644 index 000000000..2aa71ef59 --- /dev/null +++ b/src/services/tasks/DummyTask.test.ts @@ -0,0 +1,206 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TaskState } from '../taskService'; +import { DummyTask } from './DummyTask'; + +describe('DummyTask', () => { + let task: DummyTask; + + beforeEach(() => { + task = new DummyTask('Test Dummy Task'); + }); + + afterEach(async () => { + await task.delete(); + }); + + describe('constructor', () => { + it('should initialize with correct properties', () => { + const status = task.getStatus(); + + expect(task.id).toMatch(/^dummy-task-\d+$/); + expect(task.type).toBe('dummy-task'); + expect(task.name).toBe('Test Dummy Task'); + expect(status.state).toBe(TaskState.Pending); + expect(status.progress).toBe(0); + expect(status.message).toBe('Task created and ready to start'); + }); + + it('should generate unique IDs for multiple instances', () => { + const task1 = new DummyTask(); + const task2 = new DummyTask(); + + expect(task1.id).not.toBe(task2.id); + + // Cleanup + void task1.delete(); + void task2.delete(); + }); + + it('should use default name when none provided', () => { + const defaultTask = new DummyTask(); + expect(defaultTask.name).toMatch(/^Dummy Task \d+$/); + + void defaultTask.delete(); + }); + }); + + describe('start', () => { + it('should transition through initialization states', async () => { + const startPromise = task.start(); + + // Should be initializing briefly + await new Promise(resolve => setTimeout(resolve, 50)); + let status = task.getStatus(); + expect(status.state).toBe(TaskState.Initializing); + expect(status.message).toBe('Initializing task...'); + + await startPromise; + + // Should now be running + status = task.getStatus(); + expect(status.state).toBe(TaskState.Running); + expect(status.message).toBe('Task execution started'); + }); + + it('should not allow starting twice', async () => { + await task.start(); + + await expect(task.start()).rejects.toThrow('Cannot start task in state: running'); + }); + + it('should update progress over time', async () => { + await task.start(); + + // Wait for at least one progress update + await new Promise(resolve => setTimeout(resolve, 1100)); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Running); + expect(status.progress).toBeGreaterThan(0); + expect(status.progress).toBeLessThanOrEqual(100); + expect(status.message).toContain('Processing...'); + expect(status.message).toContain('% complete'); + }); + + it('should complete after approximately 10 seconds', async () => { + await task.start(); + + // Wait for completion (with some buffer for timing) + await new Promise(resolve => setTimeout(resolve, 11000)); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Completed); + expect(status.progress).toBe(100); + expect(status.message).toBe('Task completed successfully'); + }, 15000); // Increase Jest timeout for this test + }); + + describe('stop', () => { + it('should stop a running task', async () => { + await task.start(); + + // Let it run for a bit + await new Promise(resolve => setTimeout(resolve, 1100)); + + await task.stop(); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Stopped); + expect(status.message).toBe('Task was stopped'); + }); + + it('should handle stopping before start', async () => { + await task.stop(); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Stopped); + }); + + it('should handle multiple stop calls', async () => { + await task.start(); + await task.stop(); + + // Second stop should not throw + await expect(task.stop()).resolves.toBeUndefined(); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Stopped); + }); + + it('should preserve progress when stopped', async () => { + await task.start(); + + // Wait for some progress + await new Promise(resolve => setTimeout(resolve, 2100)); + + const progressBeforeStop = task.getStatus().progress; + await task.stop(); + + const status = task.getStatus(); + expect(status.progress).toBe(progressBeforeStop); + }); + }); + + describe('delete', () => { + it('should stop and cleanup the task', async () => { + await task.start(); + + // Let it run briefly + await new Promise(resolve => setTimeout(resolve, 500)); + + await task.delete(); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Stopped); + }); + + it('should handle delete on pending task', async () => { + await expect(task.delete()).resolves.toBeUndefined(); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Stopped); + }); + }); + + describe('abort signal handling', () => { + it('should handle abort during initialization', async () => { + const startPromise = task.start(); + + // Give it a tiny bit of time to enter initialization, then stop + await new Promise(resolve => setTimeout(resolve, 10)); + await task.stop(); + await startPromise; + + const status = task.getStatus(); + // The task might be in running state if start() completed before stop() + // or stopped if stop() was processed first + expect([TaskState.Stopped, TaskState.Running].includes(status.state)).toBe(true); + }); + + it('should handle abort during execution', async () => { + await task.start(); + + // Let it start running + await new Promise(resolve => setTimeout(resolve, 1100)); + + await task.stop(); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Stopped); + }); + }); + + describe('getStatus', () => { + it('should return a copy of status to prevent mutation', () => { + const status1 = task.getStatus(); + const status2 = task.getStatus(); + + expect(status1).toEqual(status2); + expect(status1).not.toBe(status2); // Different object references + }); + }); +}); \ No newline at end of file diff --git a/src/services/tasks/DummyTask.ts b/src/services/tasks/DummyTask.ts new file mode 100644 index 000000000..2e929e92d --- /dev/null +++ b/src/services/tasks/DummyTask.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TaskState, type Task, type TaskStatus } from '../taskService'; + +/** + * A dummy task implementation that demonstrates basic task interface usage. + * + * This task runs for 10 seconds with 1-second progress intervals and demonstrates: + * - Basic task state transitions + * - Progress reporting + * - Abort signal handling + */ +export class DummyTask implements Task { + private static _instanceCounter = 0; + + public readonly id: string; + public readonly type: string = 'dummy-task'; + public readonly name: string; + + private _status: TaskStatus; + private _abortController: AbortController | undefined; + private _progressInterval: NodeJS.Timeout | undefined; + private _startTime: number | undefined; + + constructor(name?: string) { + DummyTask._instanceCounter++; + this.id = `dummy-task-${DummyTask._instanceCounter}`; + this.name = name ?? `Dummy Task ${DummyTask._instanceCounter}`; + + this._status = { + state: TaskState.Pending, + progress: 0, + message: 'Task created and ready to start' + }; + } + + public getStatus(): TaskStatus { + return { ...this._status }; + } + + public async start(): Promise { + if (this._status.state !== TaskState.Pending) { + throw new Error(`Cannot start task in state: ${this._status.state}`); + } + + this._abortController = new AbortController(); + + this._status = { + state: TaskState.Initializing, + progress: 0, + message: 'Initializing task...' + }; + + // Brief initialization delay to simulate setup + await this._delay(100); + + if (this._abortController?.signal.aborted) { + await this._handleAbort(); + return; + } + + this._status = { + state: TaskState.Running, + progress: 0, + message: 'Task execution started' + }; + + this._startTime = Date.now(); + + this._startProgressLoop(); + } + + public async stop(): Promise { + if (this._status.state === TaskState.Completed || + this._status.state === TaskState.Failed || + this._status.state === TaskState.Stopped) { + return; // Already in terminal state + } + + this._status = { + ...this._status, + state: TaskState.Stopping, + message: 'Stopping task...' + }; + + if (this._abortController) { + this._abortController.abort(); + } + await this._cleanup(); + + this._status = { + state: TaskState.Stopped, + progress: this._status.progress, + message: 'Task was stopped' + }; + } + + public async delete(): Promise { + await this.stop(); + await this._cleanup(); + } + + private _startProgressLoop(): void { + this._progressInterval = setInterval(() => { + if (this._abortController?.signal.aborted) { + void this._handleAbort(); + return; + } + + const elapsed = Date.now() - (this._startTime ?? Date.now()); + const progress = Math.min(100, Math.floor((elapsed / 10000) * 100)); // 10 seconds = 100% + + this._status = { + state: TaskState.Running, + progress, + message: `Processing... ${progress}% complete` + }; + + if (progress >= 100) { + this._status = { + state: TaskState.Completed, + progress: 100, + message: 'Task completed successfully' + }; + void this._cleanup(); + } + }, 1000); // Update every second + } + + private async _handleAbort(): Promise { + await this._cleanup(); + this._status = { + state: TaskState.Stopped, + progress: this._status.progress ?? 0, + message: 'Task was aborted' + }; + } + + private async _cleanup(): Promise { + if (this._progressInterval) { + clearInterval(this._progressInterval); + this._progressInterval = undefined; + } + this._abortController = undefined; + } + + private async _delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} \ No newline at end of file diff --git a/src/services/tasks/PausableDummyTask.test.ts b/src/services/tasks/PausableDummyTask.test.ts new file mode 100644 index 000000000..9efeba04d --- /dev/null +++ b/src/services/tasks/PausableDummyTask.test.ts @@ -0,0 +1,334 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TaskState } from '../taskService'; +import { PausableDummyTask } from './PausableDummyTask'; + +describe('PausableDummyTask', () => { + let task: PausableDummyTask; + + beforeEach(() => { + task = new PausableDummyTask('Test Pausable Task'); + }); + + afterEach(async () => { + await task.delete(); + }); + + describe('constructor', () => { + it('should initialize with correct properties', () => { + const status = task.getStatus(); + + expect(task.id).toMatch(/^pausable-dummy-task-\d+$/); + expect(task.type).toBe('pausable-dummy-task'); + expect(task.name).toBe('Test Pausable Task'); + expect(status.state).toBe(TaskState.Pending); + expect(status.progress).toBe(0); + expect(status.message).toBe('Pausable task created and ready to start'); + }); + + it('should generate unique IDs for multiple instances', () => { + const task1 = new PausableDummyTask(); + const task2 = new PausableDummyTask(); + + expect(task1.id).not.toBe(task2.id); + + // Cleanup + void task1.delete(); + void task2.delete(); + }); + }); + + describe('canPause', () => { + it('should return false when task is pending', () => { + expect(task.canPause()).toBe(false); + }); + + it('should return true when task is running', async () => { + await task.start(); + expect(task.canPause()).toBe(true); + }); + + it('should return false when task is paused', async () => { + await task.start(); + await new Promise(resolve => setTimeout(resolve, 500)); + await task.pause(); + expect(task.canPause()).toBe(false); + }); + + it('should return false when task is completed', async () => { + await task.start(); + + // Wait for completion + await new Promise(resolve => setTimeout(resolve, 11000)); + + expect(task.canPause()).toBe(false); + }, 15000); + }); + + describe('start', () => { + it('should transition through initialization states', async () => { + const startPromise = task.start(); + + // Should be initializing briefly + await new Promise(resolve => setTimeout(resolve, 50)); + let status = task.getStatus(); + expect(status.state).toBe(TaskState.Initializing); + + await startPromise; + + // Should now be running + status = task.getStatus(); + expect(status.state).toBe(TaskState.Running); + }); + + it('should update progress over time', async () => { + await task.start(); + + // Wait for at least one progress update + await new Promise(resolve => setTimeout(resolve, 1100)); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Running); + expect(status.progress).toBeGreaterThan(0); + expect(status.progress).toBeLessThanOrEqual(100); + }); + }); + + describe('pause', () => { + it('should pause a running task', async () => { + await task.start(); + await new Promise(resolve => setTimeout(resolve, 500)); + + await task.pause(); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Paused); + expect(status.message).toContain('Task paused at'); + expect(status.message).toContain('% progress'); + }); + + it('should not allow pausing non-running task', async () => { + await expect(task.pause()).rejects.toThrow('Cannot pause task in state: pending'); + }); + + it('should not allow pausing already paused task', async () => { + await task.start(); + await new Promise(resolve => setTimeout(resolve, 500)); + await task.pause(); + + await expect(task.pause()).rejects.toThrow('Cannot pause task in state: paused'); + }); + + it('should transition through pausing state', async () => { + await task.start(); + await new Promise(resolve => setTimeout(resolve, 500)); + + const pausePromise = task.pause(); + + // Brief moment to catch pausing state + await new Promise(resolve => setTimeout(resolve, 25)); + const pausingStatus = task.getStatus(); + expect(pausingStatus.state).toBe(TaskState.Pausing); + + await pausePromise; + + const pausedStatus = task.getStatus(); + expect(pausedStatus.state).toBe(TaskState.Paused); + }); + }); + + describe('resume', () => { + it('should resume a paused task', async () => { + await task.start(); + await new Promise(resolve => setTimeout(resolve, 1100)); + + const progressBeforePause = task.getStatus().progress; + await task.pause(); + await task.resume(); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Running); + expect(status.message).toContain('Task resumed from'); + expect(status.message).toContain(`${progressBeforePause}% progress`); + }); + + it('should not allow resuming non-paused task', async () => { + await task.start(); + + await expect(task.resume()).rejects.toThrow('Cannot resume task in state: running'); + }); + + it('should transition through resuming state', async () => { + await task.start(); + await new Promise(resolve => setTimeout(resolve, 500)); + await task.pause(); + + const resumePromise = task.resume(); + + // Brief moment to catch resuming state + await new Promise(resolve => setTimeout(resolve, 25)); + const resumingStatus = task.getStatus(); + expect(resumingStatus.state).toBe(TaskState.Resuming); + + await resumePromise; + + const resumedStatus = task.getStatus(); + expect(resumedStatus.state).toBe(TaskState.Running); + }); + + it('should preserve progress when resuming', async () => { + await task.start(); + await new Promise(resolve => setTimeout(resolve, 2100)); + + const progressBeforePause = task.getStatus().progress; + await task.pause(); + + // Wait while paused + await new Promise(resolve => setTimeout(resolve, 1000)); + + await task.resume(); + + // Progress should be preserved (not reset) + const progressAfterResume = task.getStatus().progress; + expect(progressAfterResume).toBe(progressBeforePause); + }); + }); + + describe('pause and resume cycles', () => { + it('should handle multiple pause/resume cycles', async () => { + await task.start(); + + // First cycle + await new Promise(resolve => setTimeout(resolve, 1100)); + const progress1 = task.getStatus().progress ?? 0; + await task.pause(); + await task.resume(); + + // Second cycle + await new Promise(resolve => setTimeout(resolve, 1100)); + const progress2 = task.getStatus().progress ?? 0; + await task.pause(); + await task.resume(); + + expect(progress2).toBeGreaterThan(progress1); + expect(task.getStatus().state).toBe(TaskState.Running); + }); + + it('should continue progress correctly after multiple pauses', async () => { + await task.start(); + + // Let it run for 2 seconds + await new Promise(resolve => setTimeout(resolve, 2100)); + const progress1 = task.getStatus().progress ?? 0; + + await task.pause(); + // Pause for 2 seconds (this time should not count toward progress) + await new Promise(resolve => setTimeout(resolve, 2000)); + await task.resume(); + + // Let it run for another 2 seconds + await new Promise(resolve => setTimeout(resolve, 2100)); + const progress2 = task.getStatus().progress ?? 0; + + // Progress should reflect only 4 seconds of actual work, not 6 + expect(progress2).toBeGreaterThan(35); // Should be around 40% but timing may vary + expect(progress2).toBeLessThan(50); // Should not be much higher than 45% + expect(progress2).toBeGreaterThan(progress1); + }, 10000); + + it('should not count paused time toward total duration', async () => { + await task.start(); + + // Run for 3 seconds + await new Promise(resolve => setTimeout(resolve, 3100)); + await task.pause(); + + // Pause for 5 seconds + await new Promise(resolve => setTimeout(resolve, 5000)); + await task.resume(); + + // Run for another 7 seconds to complete + await new Promise(resolve => setTimeout(resolve, 7100)); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Completed); + expect(status.progress).toBe(100); + }, 20000); // Increase timeout for this longer test + }); + + describe('stop during pause operations', () => { + it('should handle stop while paused', async () => { + await task.start(); + await new Promise(resolve => setTimeout(resolve, 1100)); + await task.pause(); + + await task.stop(); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Stopped); + }); + + it('should handle stop during pause transition', async () => { + await task.start(); + await new Promise(resolve => setTimeout(resolve, 1100)); + + // Start pausing and stop immediately + const pausePromise = task.pause(); + const stopPromise = task.stop(); + + await Promise.all([pausePromise.catch(() => {}), stopPromise]); + + const status = task.getStatus(); + expect([TaskState.Stopped, TaskState.Paused].includes(status.state)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle rapid pause/resume calls', async () => { + await task.start(); + await new Promise(resolve => setTimeout(resolve, 1100)); + + // Rapid pause/resume + await task.pause(); + await task.resume(); + await new Promise(resolve => setTimeout(resolve, 100)); + await task.pause(); + await task.resume(); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Running); + }); + + it('should handle pause at very beginning of execution', async () => { + await task.start(); + + // Pause immediately after start + await task.pause(); + + const status = task.getStatus(); + expect(status.state).toBe(TaskState.Paused); + expect(status.progress).toBe(0); + }); + + it('should handle pause near completion', async () => { + await task.start(); + + // Wait close to completion + await new Promise(resolve => setTimeout(resolve, 9500)); + + if (task.canPause()) { + await task.pause(); + await task.resume(); + } + + // Should still complete properly + await new Promise(resolve => setTimeout(resolve, 1000)); + + const status = task.getStatus(); + expect([TaskState.Completed, TaskState.Running]).toContain(status.state); + }, 15000); + }); +}); \ No newline at end of file diff --git a/src/services/tasks/PausableDummyTask.ts b/src/services/tasks/PausableDummyTask.ts new file mode 100644 index 000000000..1e03f0540 --- /dev/null +++ b/src/services/tasks/PausableDummyTask.ts @@ -0,0 +1,221 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TaskState, type PausableTask, type TaskStatus } from '../taskService'; + +/** + * A pausable task implementation that extends the dummy task concept with pause/resume functionality. + * + * This task runs for 10 seconds with 1-second progress intervals and demonstrates: + * - Basic task state transitions + * - Progress reporting + * - Abort signal handling + * - Pause and resume functionality + * - State preservation across pause/resume cycles + */ +export class PausableDummyTask implements PausableTask { + private static _instanceCounter = 0; + + public readonly id: string; + public readonly type: string = 'pausable-dummy-task'; + public readonly name: string; + + private _status: TaskStatus; + private _abortController: AbortController | undefined; + private _progressInterval: NodeJS.Timeout | undefined; + private _startTime: number | undefined; + private _pausedTime: number = 0; // Total time spent paused + private _pauseStartTime: number | undefined; + + constructor(name?: string) { + PausableDummyTask._instanceCounter++; + this.id = `pausable-dummy-task-${PausableDummyTask._instanceCounter}`; + this.name = name ?? `Pausable Dummy Task ${PausableDummyTask._instanceCounter}`; + + this._status = { + state: TaskState.Pending, + progress: 0, + message: 'Pausable task created and ready to start' + }; + } + + public getStatus(): TaskStatus { + return { ...this._status }; + } + + public canPause(): boolean { + return this._status.state === TaskState.Running; + } + + public async start(): Promise { + if (this._status.state !== TaskState.Pending) { + throw new Error(`Cannot start task in state: ${this._status.state}`); + } + + this._abortController = new AbortController(); + + this._status = { + state: TaskState.Initializing, + progress: 0, + message: 'Initializing pausable task...' + }; + + // Brief initialization delay to simulate setup + await this._delay(100); + + if (this._abortController?.signal.aborted) { + await this._handleAbort(); + return; + } + + this._status = { + state: TaskState.Running, + progress: 0, + message: 'Pausable task execution started' + }; + + this._startTime = Date.now(); + + this._startProgressLoop(); + } + + public async stop(): Promise { + if (this._status.state === TaskState.Completed || + this._status.state === TaskState.Failed || + this._status.state === TaskState.Stopped) { + return; // Already in terminal state + } + + this._status = { + ...this._status, + state: TaskState.Stopping, + message: 'Stopping pausable task...' + }; + + this._abortController?.abort(); + await this._cleanup(); + + this._status = { + state: TaskState.Stopped, + progress: this._status.progress, + message: 'Pausable task was stopped' + }; + } + + public async pause(): Promise { + if (this._status.state !== TaskState.Running) { + throw new Error(`Cannot pause task in state: ${this._status.state}`); + } + + this._status = { + ...this._status, + state: TaskState.Pausing, + message: 'Pausing task...' + }; + + await this._delay(50); // Brief delay to simulate pause processing + + this._pauseStartTime = Date.now(); + + if (this._progressInterval) { + clearInterval(this._progressInterval); + this._progressInterval = undefined; + } + + this._status = { + ...this._status, + state: TaskState.Paused, + message: `Task paused at ${this._status.progress}% progress` + }; + } + + public async resume(): Promise { + if (this._status.state !== TaskState.Paused) { + throw new Error(`Cannot resume task in state: ${this._status.state}`); + } + + this._status = { + ...this._status, + state: TaskState.Resuming, + message: 'Resuming task...' + }; + + await this._delay(50); // Brief delay to simulate resume processing + + // Update paused time tracking + if (this._pauseStartTime) { + this._pausedTime += Date.now() - this._pauseStartTime; + this._pauseStartTime = undefined; + } + + this._status = { + ...this._status, + state: TaskState.Running, + message: `Task resumed from ${this._status.progress}% progress` + }; + + this._startProgressLoop(); + } + + public async delete(): Promise { + await this.stop(); + await this._cleanup(); + } + + private _startProgressLoop(): void { + this._progressInterval = setInterval(() => { + if (this._abortController?.signal.aborted) { + void this._handleAbort(); + return; + } + + // Calculate elapsed time excluding paused time + const totalElapsed = Date.now() - (this._startTime ?? Date.now()); + const currentPausedTime = this._pauseStartTime ? + this._pausedTime + (Date.now() - this._pauseStartTime) : + this._pausedTime; + const activeElapsed = totalElapsed - currentPausedTime; + + const progress = Math.min(100, Math.floor((activeElapsed / 10000) * 100)); // 10 seconds = 100% + + this._status = { + state: TaskState.Running, + progress, + message: `Processing... ${progress}% complete` + }; + + if (progress >= 100) { + this._status = { + state: TaskState.Completed, + progress: 100, + message: 'Pausable task completed successfully' + }; + void this._cleanup(); + } + }, 1000); // Update every second + } + + private async _handleAbort(): Promise { + await this._cleanup(); + this._status = { + state: TaskState.Stopped, + progress: this._status.progress ?? 0, + message: 'Pausable task was aborted' + }; + } + + private async _cleanup(): Promise { + if (this._progressInterval) { + clearInterval(this._progressInterval); + this._progressInterval = undefined; + } + this._abortController = undefined; + this._pauseStartTime = undefined; + } + + private async _delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} \ No newline at end of file diff --git a/src/services/tasks/demo.ts b/src/services/tasks/demo.ts new file mode 100644 index 000000000..365e5b6c3 --- /dev/null +++ b/src/services/tasks/demo.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Example usage demonstration for DummyTask and PausableDummyTask + * + * This file shows how to use the task implementations for future development. + * It is not part of the production code but serves as documentation. + */ + +import { TaskService } from '../taskService'; +import { DummyTask, PausableDummyTask } from './index'; + +/** + * Example: Basic DummyTask usage + */ +export async function demonstrateDummyTask(): Promise { + console.log('=== DummyTask Demonstration ==='); + + const task = new DummyTask('Demo Dummy Task'); + + // Register with task service + TaskService.registerTask(task); + + console.log('Initial status:', task.getStatus()); + + // Start the task + await task.start(); + console.log('After start:', task.getStatus()); + + // Monitor progress for a few seconds + const progressInterval = setInterval(() => { + const status = task.getStatus(); + console.log(`Progress: ${status.progress}% - ${status.message}`); + + if (status.state === 'completed' || status.state === 'stopped') { + clearInterval(progressInterval); + console.log('Final status:', status); + } + }, 2000); + + // Stop after 5 seconds + setTimeout(async () => { + await task.stop(); + console.log('Task stopped manually'); + clearInterval(progressInterval); + await TaskService.deleteTask(task.id); + }, 5000); +} + +/** + * Example: PausableDummyTask usage with pause/resume + */ +export async function demonstratePausableTask(): Promise { + console.log('\n=== PausableDummyTask Demonstration ==='); + + const task = new PausableDummyTask('Demo Pausable Task'); + + // Register with task service + TaskService.registerTask(task); + + console.log('Initial status:', task.getStatus()); + + // Start the task + await task.start(); + console.log('After start:', task.getStatus()); + + // Let it run for 3 seconds + setTimeout(async () => { + if (task.canPause()) { + await task.pause(); + console.log('Task paused:', task.getStatus()); + + // Resume after 2 seconds of being paused + setTimeout(async () => { + await task.resume(); + console.log('Task resumed:', task.getStatus()); + }, 2000); + } + }, 3000); + + // Monitor progress + const progressInterval = setInterval(() => { + const status = task.getStatus(); + console.log(`State: ${status.state}, Progress: ${status.progress}% - ${status.message}`); + + if (status.state === 'completed' || status.state === 'stopped') { + clearInterval(progressInterval); + console.log('Final status:', status); + void TaskService.deleteTask(task.id); + } + }, 1000); +} + +/** + * Example: Using TaskService to manage multiple tasks + */ +export async function demonstrateTaskService(): Promise { + console.log('\n=== TaskService Demonstration ==='); + + const task1 = new DummyTask('Task 1'); + const task2 = new PausableDummyTask('Pausable Task 2'); + + // Register multiple tasks + TaskService.registerTask(task1); + TaskService.registerTask(task2); + + console.log('Registered tasks:', TaskService.listTasks().map(t => ({ id: t.id, name: t.name, type: t.type }))); + + // Start both tasks + await task1.start(); + await task2.start(); + + // Demonstrate service operations + console.log('Task 1 pausable?', TaskService.isTaskPausable(task1.id)); + console.log('Task 2 pausable?', TaskService.isTaskPausable(task2.id)); + + // Pause the pausable task via service + if (TaskService.isTaskPausable(task2.id)) { + setTimeout(async () => { + await TaskService.pauseTask(task2.id); + console.log('Paused task 2 via service'); + + setTimeout(async () => { + await TaskService.resumeTask(task2.id); + console.log('Resumed task 2 via service'); + }, 2000); + }, 2000); + } + + // Clean up after 8 seconds + setTimeout(async () => { + console.log('Cleaning up tasks...'); + await TaskService.deleteTask(task1.id); + await TaskService.deleteTask(task2.id); + console.log('All tasks cleaned up'); + }, 8000); +} + +// Export for potential use in integration demos +export { DummyTask, PausableDummyTask, TaskService }; \ No newline at end of file diff --git a/src/services/tasks/index.ts b/src/services/tasks/index.ts new file mode 100644 index 000000000..494d11fce --- /dev/null +++ b/src/services/tasks/index.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { DummyTask } from './DummyTask'; +export { PausableDummyTask } from './PausableDummyTask'; \ No newline at end of file