diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f4768e0..7b19680 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,10 +20,10 @@ repos: files: \.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx types: [file] additional_dependencies: - - eslint - - typescript - - '@typescript-eslint/parser' - - '@typescript-eslint/eslint-plugin' + - eslint@8.56.0 + - '@typescript-eslint/parser@6.18.1' + - '@typescript-eslint/eslint-plugin@6.18.1' + - '@eslint/js@8.56.0' - repo: https://github.com/lyz-code/yamlfix rev: 1.17.0 hooks: diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..7733a7e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 100, + "singleQuote": false +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 1b93d55..3966acc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,45 +1,45 @@ -import js from '@eslint/js'; -import typescript from '@typescript-eslint/eslint-plugin'; -import tsParser from '@typescript-eslint/parser'; +import js from "@eslint/js"; +import typescript from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; export default [ js.configs.recommended, { - files: ['**/*.ts'], + files: ["**/*.ts"], languageOptions: { parser: tsParser, parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', + ecmaVersion: "latest", + sourceType: "module", }, globals: { - console: 'readonly', - process: 'readonly', - __dirname: 'readonly', - setTimeout: 'readonly', + console: "readonly", + process: "readonly", + __dirname: "readonly", + setTimeout: "readonly", }, }, plugins: { - '@typescript-eslint': typescript, + "@typescript-eslint": typescript, }, rules: { - ...typescript.configs['recommended'].rules, + ...typescript.configs["recommended"].rules, }, }, { - files: ['**/test/**/*.ts'], + files: ["**/test/**/*.ts"], languageOptions: { globals: { - suite: 'readonly', - test: 'readonly', - teardown: 'readonly', - suiteSetup: 'readonly', - suiteTeardown: 'readonly', - setup: 'readonly', + suite: "readonly", + test: "readonly", + teardown: "readonly", + suiteSetup: "readonly", + suiteTeardown: "readonly", + setup: "readonly", }, }, rules: { - '@typescript-eslint/no-unused-vars': 'off', + "@typescript-eslint/no-unused-vars": "off", }, }, ]; diff --git a/package.json b/package.json index 1c6bba8..cb9b1a7 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", "watch": "tsc -watch -p ./", - "pretest": "npm run compile && npm run lint && npm run format", + "pretest": "rm -rf out && npm run compile && npm run lint && npm run format", "lint": "eslint src", "test": "node ./out/test/runTest.js", "format": "prettier --write \"src/**/*.{ts,js,json,md}\"" diff --git a/src/extension.ts b/src/extension.ts index 89aa0e0..90e9431 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,280 +1,8 @@ -import * as vscode from 'vscode'; -import * as path from 'path'; -import * as fs from 'fs'; - -type TreeItemType = FolderItem | ModuleItem | TaskItem; - -class FolderItem extends vscode.TreeItem { - constructor( - public readonly label: string, - public readonly children: (FolderItem | ModuleItem)[] = [], - public readonly folderPath: string - ) { - super(label, vscode.TreeItemCollapsibleState.Expanded); - this.contextValue = 'folder'; - this.iconPath = new vscode.ThemeIcon('folder'); - this.tooltip = folderPath; - } -} - -class ModuleItem extends vscode.TreeItem { - constructor( - public readonly label: string, - public readonly children: TaskItem[] = [], - public readonly filePath: string - ) { - super(label, vscode.TreeItemCollapsibleState.Collapsed); - this.contextValue = 'module'; - this.iconPath = new vscode.ThemeIcon('symbol-file'); - this.tooltip = filePath; - this.command = { - command: 'vscode.open', - title: 'Open Module File', - arguments: [vscode.Uri.file(filePath)], - }; - } -} - -class TaskItem extends vscode.TreeItem { - constructor( - public readonly label: string, - public readonly filePath: string, - public readonly lineNumber: number - ) { - super(label, vscode.TreeItemCollapsibleState.None); - this.contextValue = 'task'; - this.iconPath = new vscode.ThemeIcon('symbol-method'); - this.tooltip = `${this.label} - ${path.basename(this.filePath)}:${this.lineNumber}`; - this.description = path.basename(this.filePath); - this.command = { - command: 'vscode.open', - title: 'Open Task File', - arguments: [ - vscode.Uri.file(this.filePath), - { - selection: new vscode.Range( - new vscode.Position(this.lineNumber - 1, 0), - new vscode.Position(this.lineNumber - 1, 0) - ), - }, - ], - }; - } -} - -export class PyTaskProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter = - new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event = - this._onDidChangeTreeData.event; - private fileSystemWatcher: vscode.FileSystemWatcher; - - constructor() { - this.fileSystemWatcher = vscode.workspace.createFileSystemWatcher('**/task_*.py'); - - this.fileSystemWatcher.onDidCreate(() => { - this.refresh(); - }); - - this.fileSystemWatcher.onDidChange(() => { - this.refresh(); - }); - - this.fileSystemWatcher.onDidDelete(() => { - this.refresh(); - }); - } - - dispose() { - this.fileSystemWatcher.dispose(); - } - - refresh(): void { - this._onDidChangeTreeData.fire(); - } - - getTreeItem(element: TreeItemType): vscode.TreeItem { - return element; - } - - async getChildren(element?: TreeItemType): Promise { - if (!element) { - return this.buildFileTree(); - } - - if (element instanceof FolderItem) { - return element.children; - } - - if (element instanceof ModuleItem) { - return element.children; - } - - return []; - } - - private async buildFileTree(): Promise { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) { - return []; - } - - const rootItems = new Map(); - - // Get all task modules across the workspace - const taskFiles = await vscode.workspace.findFiles( - '**/task_*.py', - '{**/node_modules/**,**/.venv/**,**/.git/**,**/.pixi/**,**/venv/**,**/__pycache__/**}' - ); - - // Process each task module - for (const taskFile of taskFiles) { - const relativePath = path.relative(workspaceFolders[0].uri.fsPath, taskFile.fsPath); - const dirPath = path.dirname(relativePath); - const fileName = path.basename(taskFile.fsPath); - - // Create folder hierarchy - let currentPath = ''; - let currentItems = rootItems; - const pathParts = dirPath.split(path.sep); - - // Skip if it's in the root - if (dirPath !== '.') { - for (const part of pathParts) { - currentPath = currentPath ? path.join(currentPath, part) : part; - const fullPath = path.join(workspaceFolders[0].uri.fsPath, currentPath); - - if (!currentItems.has(currentPath)) { - const newFolder = new FolderItem(part, [], fullPath); - currentItems.set(currentPath, newFolder); - } - - const folderItem = currentItems.get(currentPath); - if (folderItem instanceof FolderItem) { - currentItems = new Map( - folderItem.children - .filter((child) => child instanceof FolderItem) - .map((child) => [path.basename(child.label), child as FolderItem]) - ); - } - } - } - - // Create module and its tasks - const content = fs.readFileSync(taskFile.fsPath, 'utf8'); - const taskItems = this.findTaskFunctions(taskFile.fsPath, content); - const moduleItem = new ModuleItem(fileName, taskItems, taskFile.fsPath); - - // Add module to appropriate folder or root - if (dirPath === '.') { - rootItems.set(fileName, moduleItem); - } else { - const parentFolder = rootItems.get(dirPath); - if (parentFolder instanceof FolderItem) { - parentFolder.children.push(moduleItem); - } - } - } - - // Sort everything - const result = Array.from(rootItems.values()); - - // Sort folders and modules - result.sort((a, b) => { - // Folders come before modules - if (a instanceof FolderItem && !(b instanceof FolderItem)) return -1; - if (!(a instanceof FolderItem) && b instanceof FolderItem) return 1; - // Alphabetical sort within same type - return a.label.localeCompare(b.label); - }); - - return result; - } - - findTaskFunctions(filePath: string, content: string): TaskItem[] { - // Find out whether the task decorator is used in the file. - - // Booleans to track if the task decorator is imported as `from pytask import task` - // and used as `@task` or `import pytask` and used as `@pytask.task`. - let hasTaskImport = false; - let taskAlias = 'task'; // default name for 'from pytask import task' - let pytaskAlias = 'pytask'; // default name for 'import pytask' - let hasPytaskImport = false; - - // Match the import statements - // Handle various import patterns: - // - from pytask import task - // - from pytask import task as t - // - from pytask import Product, task - // - from pytask import (Product, task) - const fromPytaskImport = content.match( - /from\s+pytask\s+import\s+(?:\(?\s*(?:[\w]+\s*,\s*)*task(?:\s+as\s+(\w+))?(?:\s*,\s*[\w]+)*\s*\)?)/ - ); - const importPytask = content.match(/import\s+pytask(?:\s+as\s+(\w+))?\s*$/m); - - if (fromPytaskImport) { - hasTaskImport = true; - if (fromPytaskImport[1]) { - taskAlias = fromPytaskImport[1]; - } - } - - if (importPytask) { - hasPytaskImport = true; - // If there's an alias (import pytask as something), use it - pytaskAlias = importPytask[1] || 'pytask'; - } - - // Find the tasks. - const tasks: TaskItem[] = []; - const lines = content.split('\n'); - - let isDecorated = false; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - - // Check for decorators - if (line.startsWith('@')) { - // Handle both @task and @pytask.task(...) patterns - isDecorated = - (hasTaskImport && line === `@${taskAlias}`) || - (hasPytaskImport && line.startsWith(`@${pytaskAlias}.task`)); - continue; - } - - // Check for function definitions - const funcMatch = line.match(/^def\s+(\w+)\s*\(/); - if (funcMatch) { - const funcName = funcMatch[1]; - // Add if it's a task_* function or has a task decorator - if (funcName.startsWith('task_') || isDecorated) { - tasks.push(new TaskItem(funcName, filePath, i + 1)); - } - isDecorated = false; // Reset decorator flag - } - } - - // Sort the tasks by name. - tasks.sort((a, b) => a.label.localeCompare(b.label)); - - return tasks; - } -} +import * as vscode from "vscode"; +import { activate as activateTaskProvider } from "./providers/taskProvider"; export function activate(context: vscode.ExtensionContext) { - const pytaskProvider = new PyTaskProvider(); - const treeView = vscode.window.createTreeView('pytaskExplorer', { - treeDataProvider: pytaskProvider, - showCollapseAll: true, - }); - - context.subscriptions.push(treeView); - - const refreshCommand = vscode.commands.registerCommand('pytask.refresh', () => { - pytaskProvider.refresh(); - }); - - context.subscriptions.push(refreshCommand, pytaskProvider); + activateTaskProvider(context); } export function deactivate() {} diff --git a/src/providers/taskProvider.ts b/src/providers/taskProvider.ts new file mode 100644 index 0000000..d0753cb --- /dev/null +++ b/src/providers/taskProvider.ts @@ -0,0 +1,280 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import * as fs from "fs"; + +type TreeItemType = FolderItem | ModuleItem | TaskItem; + +class FolderItem extends vscode.TreeItem { + constructor( + public readonly label: string, + public readonly children: (FolderItem | ModuleItem)[] = [], + public readonly folderPath: string, + ) { + super(label, vscode.TreeItemCollapsibleState.Expanded); + this.contextValue = "folder"; + this.iconPath = new vscode.ThemeIcon("folder"); + this.tooltip = folderPath; + } +} + +class ModuleItem extends vscode.TreeItem { + constructor( + public readonly label: string, + public readonly children: TaskItem[] = [], + public readonly filePath: string, + ) { + super(label, vscode.TreeItemCollapsibleState.Collapsed); + this.contextValue = "module"; + this.iconPath = new vscode.ThemeIcon("symbol-file"); + this.tooltip = filePath; + this.command = { + command: "vscode.open", + title: "Open Module File", + arguments: [vscode.Uri.file(filePath)], + }; + } +} + +class TaskItem extends vscode.TreeItem { + constructor( + public readonly label: string, + public readonly filePath: string, + public readonly lineNumber: number, + ) { + super(label, vscode.TreeItemCollapsibleState.None); + this.contextValue = "task"; + this.iconPath = new vscode.ThemeIcon("symbol-method"); + this.tooltip = `${this.label} - ${path.basename(this.filePath)}:${this.lineNumber}`; + this.description = path.basename(this.filePath); + this.command = { + command: "vscode.open", + title: "Open Task File", + arguments: [ + vscode.Uri.file(this.filePath), + { + selection: new vscode.Range( + new vscode.Position(this.lineNumber - 1, 0), + new vscode.Position(this.lineNumber - 1, 0), + ), + }, + ], + }; + } +} + +export class PyTaskProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = + new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = + this._onDidChangeTreeData.event; + private fileSystemWatcher: vscode.FileSystemWatcher; + + constructor() { + this.fileSystemWatcher = vscode.workspace.createFileSystemWatcher("**/task_*.py"); + + this.fileSystemWatcher.onDidCreate(() => { + this.refresh(); + }); + + this.fileSystemWatcher.onDidChange(() => { + this.refresh(); + }); + + this.fileSystemWatcher.onDidDelete(() => { + this.refresh(); + }); + } + + dispose() { + this.fileSystemWatcher.dispose(); + } + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: TreeItemType): vscode.TreeItem { + return element; + } + + async getChildren(element?: TreeItemType): Promise { + if (!element) { + return this.buildFileTree(); + } + + if (element instanceof FolderItem) { + return element.children; + } + + if (element instanceof ModuleItem) { + return element.children; + } + + return []; + } + + private async buildFileTree(): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return []; + } + + const rootItems = new Map(); + + // Get all task modules across the workspace + const taskFiles = await vscode.workspace.findFiles( + "**/task_*.py", + "{**/node_modules/**,**/.venv/**,**/.git/**,**/.pixi/**,**/venv/**,**/__pycache__/**}", + ); + + // Process each task module + for (const taskFile of taskFiles) { + const relativePath = path.relative(workspaceFolders[0].uri.fsPath, taskFile.fsPath); + const dirPath = path.dirname(relativePath); + const fileName = path.basename(taskFile.fsPath); + + // Create folder hierarchy + let currentPath = ""; + let currentItems = rootItems; + const pathParts = dirPath.split(path.sep); + + // Skip if it's in the root + if (dirPath !== ".") { + for (const part of pathParts) { + currentPath = currentPath ? path.join(currentPath, part) : part; + const fullPath = path.join(workspaceFolders[0].uri.fsPath, currentPath); + + if (!currentItems.has(currentPath)) { + const newFolder = new FolderItem(part, [], fullPath); + currentItems.set(currentPath, newFolder); + } + + const folderItem = currentItems.get(currentPath); + if (folderItem instanceof FolderItem) { + currentItems = new Map( + folderItem.children + .filter((child) => child instanceof FolderItem) + .map((child) => [path.basename(child.label), child as FolderItem]), + ); + } + } + } + + // Create module and its tasks + const content = fs.readFileSync(taskFile.fsPath, "utf8"); + const taskItems = this.findTaskFunctions(taskFile.fsPath, content); + const moduleItem = new ModuleItem(fileName, taskItems, taskFile.fsPath); + + // Add module to appropriate folder or root + if (dirPath === ".") { + rootItems.set(fileName, moduleItem); + } else { + const parentFolder = rootItems.get(dirPath); + if (parentFolder instanceof FolderItem) { + parentFolder.children.push(moduleItem); + } + } + } + + // Sort everything + const result = Array.from(rootItems.values()); + + // Sort folders and modules + result.sort((a, b) => { + // Folders come before modules + if (a instanceof FolderItem && !(b instanceof FolderItem)) return -1; + if (!(a instanceof FolderItem) && b instanceof FolderItem) return 1; + // Alphabetical sort within same type + return a.label.localeCompare(b.label); + }); + + return result; + } + + findTaskFunctions(filePath: string, content: string): TaskItem[] { + // Find out whether the task decorator is used in the file. + + // Booleans to track if the task decorator is imported as `from pytask import task` + // and used as `@task` or `import pytask` and used as `@pytask.task`. + let hasTaskImport = false; + let taskAlias = "task"; // default name for 'from pytask import task' + let pytaskAlias = "pytask"; // default name for 'import pytask' + let hasPytaskImport = false; + + // Match the import statements + // Handle various import patterns: + // - from pytask import task + // - from pytask import task as t + // - from pytask import Product, task + // - from pytask import (Product, task) + const fromPytaskImport = content.match( + /from\s+pytask\s+import\s+(?:\(?\s*(?:[\w]+\s*,\s*)*task(?:\s+as\s+(\w+))?(?:\s*,\s*[\w]+)*\s*\)?)/, + ); + const importPytask = content.match(/import\s+pytask(?:\s+as\s+(\w+))?\s*$/m); + + if (fromPytaskImport) { + hasTaskImport = true; + if (fromPytaskImport[1]) { + taskAlias = fromPytaskImport[1]; + } + } + + if (importPytask) { + hasPytaskImport = true; + // If there's an alias (import pytask as something), use it + pytaskAlias = importPytask[1] || "pytask"; + } + + // Find the tasks. + const tasks: TaskItem[] = []; + const lines = content.split("\n"); + + let isDecorated = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Check for decorators + if (line.startsWith("@")) { + // Handle both @task and @pytask.task(...) patterns + isDecorated = + (hasTaskImport && line === `@${taskAlias}`) || + (hasPytaskImport && line.startsWith(`@${pytaskAlias}.task`)); + continue; + } + + // Check for function definitions + const funcMatch = line.match(/^def\s+(\w+)\s*\(/); + if (funcMatch) { + const funcName = funcMatch[1]; + // Add if it's a task_* function or has a task decorator + if (funcName.startsWith("task_") || isDecorated) { + tasks.push(new TaskItem(funcName, filePath, i + 1)); + } + isDecorated = false; // Reset decorator flag + } + } + + // Sort the tasks by name. + tasks.sort((a, b) => a.label.localeCompare(b.label)); + + return tasks; + } +} + +export function activate(context: vscode.ExtensionContext): vscode.TreeView { + const pytaskProvider = new PyTaskProvider(); + const treeView = vscode.window.createTreeView("pytaskExplorer", { + treeDataProvider: pytaskProvider, + showCollapseAll: true, + }); + + context.subscriptions.push(treeView); + + const refreshCommand = vscode.commands.registerCommand("pytask.refresh", () => { + pytaskProvider.refresh(); + }); + + context.subscriptions.push(refreshCommand, pytaskProvider); + + return treeView; +} diff --git a/src/test/suite/index.ts b/src/test/providers/index.ts similarity index 77% rename from src/test/suite/index.ts rename to src/test/providers/index.ts index fcaf7b1..b39b7a2 100644 --- a/src/test/suite/index.ts +++ b/src/test/providers/index.ts @@ -1,20 +1,20 @@ -import * as path from 'path'; -import * as Mocha from 'mocha'; -import * as fs from 'fs'; +import * as path from "path"; +import * as Mocha from "mocha"; +import * as fs from "fs"; export function run(): Promise { // Create the mocha test const mocha = new Mocha({ - ui: 'tdd', + ui: "tdd", color: true, }); - const testsRoot = path.resolve(__dirname, '.'); + const testsRoot = path.resolve(__dirname, "."); return new Promise((resolve, reject) => { try { // Get all test files - const files = fs.readdirSync(testsRoot).filter((file) => file.endsWith('.test.js')); + const files = fs.readdirSync(testsRoot).filter((file) => file.endsWith(".test.js")); // Add files to the test suite files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); diff --git a/src/test/providers/taskProvider.test.ts b/src/test/providers/taskProvider.test.ts new file mode 100644 index 0000000..61a2cbb --- /dev/null +++ b/src/test/providers/taskProvider.test.ts @@ -0,0 +1,366 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import * as path from "path"; +import * as fs from "fs"; +import { expect } from "chai"; +import { PyTaskProvider } from "../../providers/taskProvider"; + +suite("PyTask Extension Test Suite", function () { + // Increase timeout for all tests + this.timeout(5000); + + let testFilePath: string; + + suiteSetup(function () { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + throw new Error("No workspace folders found"); + } + testFilePath = path.join(workspaceFolders[0].uri.fsPath, "task_test.py"); + }); + + setup(async function () { + // Increase timeout for setup + + // Create a test task file before each test + const testFileContent = ` +def task_one(): + pass + +def task_two(): + pass + +def not_a_task(): + pass +`; + fs.writeFileSync(testFilePath, testFileContent); + }); + + teardown(function () { + // Clean up test file after each test + if (fs.existsSync(testFilePath)) { + fs.unlinkSync(testFilePath); + } + }); + + test("Extension should be present", async function () { + const extension = vscode.extensions.getExtension("undefined_publisher.pytask-vscode"); + assert.ok(extension, "Extension should be present"); + await extension?.activate(); + }); + + test("Should find task functions in Python files", async function () { + // Get the PyTask explorer view + const provider = new PyTaskProvider(); + const treeView = vscode.window.createTreeView("pytaskExplorer", { + treeDataProvider: provider, + }); + + try { + // Get the tree items + const items = await provider.getChildren(); + + // Verify we found the correct number of modules + expect(items.length).to.equal(1, "Should find exactly 1 module"); + + const moduleItem = items[0]; + expect(moduleItem.contextValue).to.equal("module", "First item should be a module"); + expect(moduleItem.label).to.equal("task_test.py", "Module should have correct name"); + + // Verify tasks within the module + const tasks = await provider.getChildren(moduleItem); + expect(tasks.length).to.equal(2, "Should find exactly 2 tasks in the module"); + + // Verify task names + const taskNames = tasks.map((item: vscode.TreeItem) => item.label); + expect(taskNames).to.include("task_one", "Should find task_one"); + expect(taskNames).to.include("task_two", "Should find task_two"); + expect(taskNames).to.not.include("not_a_task", "Should not find not_a_task"); + } finally { + treeView.dispose(); + } + }); + + test("Should display empty task modules", async function () { + const wsfolders = vscode.workspace.workspaceFolders; + if (!wsfolders) { + throw new Error("No workspace folders found"); + } + + // Create an empty task file + const emptyTaskFile = path.join(wsfolders[0].uri.fsPath, "task_empty.py"); + fs.writeFileSync(emptyTaskFile, "# Empty task file\n"); + + try { + const provider = new PyTaskProvider(); + const treeView = vscode.window.createTreeView("pytaskExplorer", { + treeDataProvider: provider, + }); + + try { + // Get the tree items + const items = await provider.getChildren(); + + // Verify we found both modules (empty and non-empty) + expect(items.length).to.equal(2, "Should find both task modules"); + + // Find the empty module + const emptyModule = items.find((item: vscode.TreeItem) => item.label === "task_empty.py"); + expect(emptyModule).to.exist; + expect(emptyModule!.contextValue).to.equal( + "module", + "Empty file should be shown as module", + ); + + // Verify empty module has no tasks + const emptyModuleTasks = await provider.getChildren(emptyModule); + expect(emptyModuleTasks.length).to.equal(0, "Empty module should have no tasks"); + } finally { + treeView.dispose(); + } + } finally { + // Clean up empty task file + if (fs.existsSync(emptyTaskFile)) { + fs.unlinkSync(emptyTaskFile); + } + } + }); + + test("Should update when task file changes", async function () { + // Add a new task to the file + const updatedContent = + fs.readFileSync(testFilePath, "utf8") + "\ndef task_three():\n pass\n"; + fs.writeFileSync(testFilePath, updatedContent); + + // Wait for the file watcher to detect changes + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Get the tree view items + const provider = new PyTaskProvider(); + const treeView = vscode.window.createTreeView("pytaskExplorer", { + treeDataProvider: provider, + }); + + try { + const items = await provider.getChildren(); + + // Verify we still have one module + expect(items.length).to.equal(1, "Should find exactly 1 module"); + const moduleItem = items[0]; + expect(moduleItem.contextValue).to.equal("module", "First item should be a module"); + + // Get tasks under the module + const tasks = await provider.getChildren(moduleItem); + expect(tasks.length).to.equal(3, "Should find exactly 3 tasks in the module"); + + // Verify task names + const taskNames = tasks.map((item: vscode.TreeItem) => item.label); + expect(taskNames).to.include("task_one", "Should find task_one"); + expect(taskNames).to.include("task_two", "Should find task_two"); + expect(taskNames).to.include("task_three", "Should find the newly added task_three"); + } finally { + treeView.dispose(); + } + }); +}); + +suite("Task Function Detection", () => { + const provider = new PyTaskProvider(); + const dummyPath = "/test/task_test.py"; + + test("Should find functions with task_ prefix", () => { + const content = ` +def task_one(): + pass + +def task_two(): + pass + +def not_a_task(): + pass +`; + const tasks = provider.findTaskFunctions(dummyPath, content); + expect(tasks).to.have.lengthOf(2, "Should find exactly 2 tasks"); + expect(tasks[0].label).to.equal("task_one"); + expect(tasks[1].label).to.equal("task_two"); + }); + + test("Should find functions with @task decorator", () => { + const content = ` +from pytask import task + +@task +def function_one(): + pass + +@task +def another_function(): + pass + +def not_decorated(): + pass +`; + const tasks = provider.findTaskFunctions(dummyPath, content); + expect(tasks).to.have.lengthOf(2, "Should find exactly 2 tasks"); + expect(tasks[0].label).to.equal("another_function"); + expect(tasks[1].label).to.equal("function_one"); + }); + + test("Should find functions with @pytask.task decorator", () => { + const content = ` +import pytask + +@pytask.task +def function_one(): + pass + +@pytask.task() +def function_two(): + pass + +@pytask.task(...) +def function_three(): + pass +`; + const tasks = provider.findTaskFunctions(dummyPath, content); + expect(tasks).to.have.lengthOf(3, "Should find exactly 3 tasks"); + expect(tasks[0].label).to.equal("function_one"); + expect(tasks[1].label).to.equal("function_three"); + expect(tasks[2].label).to.equal("function_two"); + }); + + test("Should handle mixed task definitions", () => { + const content = ` +from pytask import task +import pytask + +def task_one(): + pass + +@task +def function_two(): + pass + +@pytask.task +def function_three(): + pass + +def not_a_task(): + pass +`; + const tasks = provider.findTaskFunctions(dummyPath, content); + expect(tasks).to.have.lengthOf(3, "Should find exactly 3 tasks"); + expect(tasks[0].label).to.equal("function_three"); + expect(tasks[1].label).to.equal("function_two"); + expect(tasks[2].label).to.equal("task_one"); + }); + + test("Should not find tasks without proper imports", () => { + const content = ` +# Missing imports + +@task +def not_a_task1(): + pass + +@pytask.task +def not_a_task2(): + pass + +def task_one(): + pass +`; + const tasks = provider.findTaskFunctions(dummyPath, content); + expect(tasks).to.have.lengthOf(1, "Should only find the task_ prefixed function"); + expect(tasks[0].label).to.equal("task_one"); + }); + + test("Should handle complex import statements", () => { + const content = ` +from pytask import clean, task, collect +import pytask as pt + +@task +def task_one(): + pass + +@pt.task +def another_task(): + pass +`; + const tasks = provider.findTaskFunctions(dummyPath, content); + expect(tasks).to.have.lengthOf(2, "Should find both tasks"); + expect(tasks[0].label).to.equal("another_task"); + expect(tasks[1].label).to.equal("task_one"); + }); + + test("Should handle aliased task import", () => { + const content = ` +from pytask import task as t + +@t +def my_task(): + pass + +def not_a_task(): + pass +`; + const tasks = provider.findTaskFunctions(dummyPath, content); + expect(tasks).to.have.lengthOf(1, "Should find task with aliased decorator"); + expect(tasks[0].label).to.equal("my_task"); + }); + + test("Should handle multi-import statements", () => { + const content = ` +from pytask import Product, task + +@task +def task_one(): + pass + +def not_a_task(): + pass +`; + const tasks = provider.findTaskFunctions(dummyPath, content); + expect(tasks).to.have.lengthOf(1, "Should find task with multi-import"); + expect(tasks[0].label).to.equal("task_one"); + }); + + test("Should handle multi-line import statements", () => { + const content = ` +from pytask import ( + Product, + task, +) + +@task +def task_one(): + pass + +def not_a_task(): + pass +`; + const tasks = provider.findTaskFunctions(dummyPath, content); + expect(tasks).to.have.lengthOf(1, "Should find task with multi-line import"); + expect(tasks[0].label).to.equal("task_one"); + }); + + test("Should set correct line numbers", () => { + const content = ` +from pytask import task + +def task_one(): # line 4 + pass + +@task +def decorated_task(): # line 8 + pass +`; + const tasks = provider.findTaskFunctions(dummyPath, content); + expect(tasks).to.have.lengthOf(2, "Should find both tasks"); + expect(tasks[0].label).to.equal("decorated_task"); + expect(tasks[0].lineNumber).to.equal(8); + expect(tasks[1].label).to.equal("task_one"); + expect(tasks[1].lineNumber).to.equal(4); + }); +}); diff --git a/src/test/runTest.ts b/src/test/runTest.ts index 5640b49..ee25e65 100644 --- a/src/test/runTest.ts +++ b/src/test/runTest.ts @@ -1,23 +1,23 @@ -import * as path from 'path'; -import { runTests } from '@vscode/test-electron'; -import * as fs from 'fs'; -import * as os from 'os'; +import * as path from "path"; +import { runTests } from "@vscode/test-electron"; +import * as fs from "fs"; +import * as os from "os"; async function main() { try { // Create a temporary test workspace const testWorkspacePath = path.join( os.tmpdir(), - `pytask-test-${Math.random().toString(36).substring(2)}` + `pytask-test-${Math.random().toString(36).substring(2)}`, ); fs.mkdirSync(testWorkspacePath, { recursive: true }); console.log(`Test workspace created at: ${testWorkspacePath}`); // The folder containing the Extension Manifest package.json - const extensionDevelopmentPath = path.resolve(__dirname, '../../'); + const extensionDevelopmentPath = path.resolve(__dirname, "../../"); // The path to the extension test script - const extensionTestsPath = path.resolve(__dirname, './suite/index'); + const extensionTestsPath = path.resolve(__dirname, "./providers/index"); // Download VS Code, unzip it and run the integration test await runTests({ @@ -25,8 +25,8 @@ async function main() { extensionTestsPath, launchArgs: [ testWorkspacePath, - '--disable-extensions', // Disable other extensions - '--disable-workspace-trust', // Disable workspace trust dialog + "--disable-extensions", // Disable other extensions + "--disable-workspace-trust", // Disable workspace trust dialog ], }); @@ -36,7 +36,7 @@ async function main() { } console.log(`Test workspace cleaned up: ${testWorkspacePath}`); } catch (err) { - console.error('Failed to run tests'); + console.error("Failed to run tests"); process.exit(1); } } diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts deleted file mode 100644 index 04f6e92..0000000 --- a/src/test/suite/extension.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import * as path from 'path'; -import * as fs from 'fs'; -import { expect } from 'chai'; -import { PyTaskProvider } from '../../extension'; - -suite('PyTask Extension Test Suite', function () { - // Increase timeout for all tests - this.timeout(5000); - - let testFilePath: string; - - suiteSetup(function () { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) { - throw new Error('No workspace folders found'); - } - testFilePath = path.join(workspaceFolders[0].uri.fsPath, 'task_test.py'); - }); - - setup(async function () { - // Increase timeout for setup - - // Create a test task file before each test - const testFileContent = ` -def task_one(): - pass - -def task_two(): - pass - -def not_a_task(): - pass -`; - fs.writeFileSync(testFilePath, testFileContent); - }); - - teardown(function () { - // Clean up test file after each test - if (fs.existsSync(testFilePath)) { - fs.unlinkSync(testFilePath); - } - }); - - test('Extension should be present', async function () { - const extension = vscode.extensions.getExtension('undefined_publisher.pytask-vscode'); - assert.ok(extension, 'Extension should be present'); - await extension?.activate(); - }); - - test('Should find task functions in Python files', async function () { - // Get the PyTask explorer view - const provider = new PyTaskProvider(); - const treeView = vscode.window.createTreeView('pytaskExplorer', { - treeDataProvider: provider, - }); - - try { - // Get the tree items - const items = await provider.getChildren(); - - // Verify we found the correct number of modules - expect(items.length).to.equal(1, 'Should find exactly 1 module'); - - const moduleItem = items[0]; - expect(moduleItem.contextValue).to.equal('module', 'First item should be a module'); - expect(moduleItem.label).to.equal('task_test.py', 'Module should have correct name'); - - // Verify tasks within the module - const tasks = await provider.getChildren(moduleItem); - expect(tasks.length).to.equal(2, 'Should find exactly 2 tasks in the module'); - - // Verify task names - const taskNames = tasks.map((item) => item.label); - expect(taskNames).to.include('task_one', 'Should find task_one'); - expect(taskNames).to.include('task_two', 'Should find task_two'); - expect(taskNames).to.not.include('not_a_task', 'Should not find not_a_task'); - } finally { - treeView.dispose(); - } - }); - - test('Should display empty task modules', async function () { - const wsfolders = vscode.workspace.workspaceFolders; - if (!wsfolders) { - throw new Error('No workspace folders found'); - } - - // Create an empty task file - const emptyTaskFile = path.join(wsfolders[0].uri.fsPath, 'task_empty.py'); - fs.writeFileSync(emptyTaskFile, '# Empty task file\n'); - - try { - const provider = new PyTaskProvider(); - const treeView = vscode.window.createTreeView('pytaskExplorer', { - treeDataProvider: provider, - }); - - try { - // Get the tree items - const items = await provider.getChildren(); - - // Verify we found both modules (empty and non-empty) - expect(items.length).to.equal(2, 'Should find both task modules'); - - // Find the empty module - const emptyModule = items.find((item) => item.label === 'task_empty.py'); - expect(emptyModule).to.exist; - expect(emptyModule!.contextValue).to.equal( - 'module', - 'Empty file should be shown as module' - ); - - // Verify empty module has no tasks - const emptyModuleTasks = await provider.getChildren(emptyModule); - expect(emptyModuleTasks.length).to.equal(0, 'Empty module should have no tasks'); - } finally { - treeView.dispose(); - } - } finally { - // Clean up empty task file - if (fs.existsSync(emptyTaskFile)) { - fs.unlinkSync(emptyTaskFile); - } - } - }); - - test('Should update when task file changes', async function () { - // Add a new task to the file - const updatedContent = - fs.readFileSync(testFilePath, 'utf8') + '\ndef task_three():\n pass\n'; - fs.writeFileSync(testFilePath, updatedContent); - - // Wait for the file watcher to detect changes - await new Promise((resolve) => setTimeout(resolve, 3000)); - - // Get the tree view items - const provider = new PyTaskProvider(); - const treeView = vscode.window.createTreeView('pytaskExplorer', { - treeDataProvider: provider, - }); - - try { - const items = await provider.getChildren(); - - // Verify we still have one module - expect(items.length).to.equal(1, 'Should find exactly 1 module'); - const moduleItem = items[0]; - expect(moduleItem.contextValue).to.equal('module', 'First item should be a module'); - - // Get tasks under the module - const tasks = await provider.getChildren(moduleItem); - expect(tasks.length).to.equal(3, 'Should find exactly 3 tasks in the module'); - - // Verify task names - const taskNames = tasks.map((item) => item.label); - expect(taskNames).to.include('task_one', 'Should find task_one'); - expect(taskNames).to.include('task_two', 'Should find task_two'); - expect(taskNames).to.include('task_three', 'Should find the newly added task_three'); - } finally { - treeView.dispose(); - } - }); -}); - -suite('Task Function Detection', () => { - const provider = new PyTaskProvider(); - const dummyPath = '/test/task_test.py'; - - test('Should find functions with task_ prefix', () => { - const content = ` -def task_one(): - pass - -def task_two(): - pass - -def not_a_task(): - pass -`; - const tasks = provider.findTaskFunctions(dummyPath, content); - expect(tasks).to.have.lengthOf(2, 'Should find exactly 2 tasks'); - expect(tasks[0].label).to.equal('task_one'); - expect(tasks[1].label).to.equal('task_two'); - }); - - test('Should find functions with @task decorator', () => { - const content = ` -from pytask import task - -@task -def function_one(): - pass - -@task -def another_function(): - pass - -def not_decorated(): - pass -`; - const tasks = provider.findTaskFunctions(dummyPath, content); - expect(tasks).to.have.lengthOf(2, 'Should find exactly 2 tasks'); - expect(tasks[0].label).to.equal('another_function'); - expect(tasks[1].label).to.equal('function_one'); - }); - - test('Should find functions with @pytask.task decorator', () => { - const content = ` -import pytask - -@pytask.task -def function_one(): - pass - -@pytask.task() -def function_two(): - pass - -@pytask.task(...) -def function_three(): - pass -`; - const tasks = provider.findTaskFunctions(dummyPath, content); - expect(tasks).to.have.lengthOf(3, 'Should find exactly 3 tasks'); - expect(tasks[0].label).to.equal('function_one'); - expect(tasks[1].label).to.equal('function_three'); - expect(tasks[2].label).to.equal('function_two'); - }); - - test('Should handle mixed task definitions', () => { - const content = ` -from pytask import task -import pytask - -def task_one(): - pass - -@task -def function_two(): - pass - -@pytask.task -def function_three(): - pass - -def not_a_task(): - pass -`; - const tasks = provider.findTaskFunctions(dummyPath, content); - expect(tasks).to.have.lengthOf(3, 'Should find exactly 3 tasks'); - expect(tasks[0].label).to.equal('function_three'); - expect(tasks[1].label).to.equal('function_two'); - expect(tasks[2].label).to.equal('task_one'); - }); - - test('Should not find tasks without proper imports', () => { - const content = ` -# Missing imports - -@task -def not_a_task1(): - pass - -@pytask.task -def not_a_task2(): - pass - -def task_one(): - pass -`; - const tasks = provider.findTaskFunctions(dummyPath, content); - expect(tasks).to.have.lengthOf(1, 'Should only find the task_ prefixed function'); - expect(tasks[0].label).to.equal('task_one'); - }); - - test('Should handle complex import statements', () => { - const content = ` -from pytask import clean, task, collect -import pytask as pt - -@task -def task_one(): - pass - -@pt.task -def another_task(): - pass -`; - const tasks = provider.findTaskFunctions(dummyPath, content); - expect(tasks).to.have.lengthOf(2, 'Should find both tasks'); - expect(tasks[0].label).to.equal('another_task'); - expect(tasks[1].label).to.equal('task_one'); - }); - - test('Should handle aliased task import', () => { - const content = ` -from pytask import task as t - -@t -def my_task(): - pass - -def not_a_task(): - pass -`; - const tasks = provider.findTaskFunctions(dummyPath, content); - expect(tasks).to.have.lengthOf(1, 'Should find task with aliased decorator'); - expect(tasks[0].label).to.equal('my_task'); - }); - - test('Should handle multi-import statements', () => { - const content = ` -from pytask import Product, task - -@task -def task_one(): - pass - -def not_a_task(): - pass -`; - const tasks = provider.findTaskFunctions(dummyPath, content); - expect(tasks).to.have.lengthOf(1, 'Should find task with multi-import'); - expect(tasks[0].label).to.equal('task_one'); - }); - - test('Should handle multi-line import statements', () => { - const content = ` -from pytask import ( - Product, - task, -) - -@task -def task_one(): - pass - -def not_a_task(): - pass -`; - const tasks = provider.findTaskFunctions(dummyPath, content); - expect(tasks).to.have.lengthOf(1, 'Should find task with multi-line import'); - expect(tasks[0].label).to.equal('task_one'); - }); - - test('Should set correct line numbers', () => { - const content = ` -from pytask import task - -def task_one(): # line 4 - pass - -@task -def decorated_task(): # line 8 - pass -`; - const tasks = provider.findTaskFunctions(dummyPath, content); - expect(tasks).to.have.lengthOf(2, 'Should find both tasks'); - expect(tasks[0].label).to.equal('decorated_task'); - expect(tasks[0].lineNumber).to.equal(8); - expect(tasks[1].label).to.equal('task_one'); - expect(tasks[1].lineNumber).to.equal(4); - }); -});