From 691026d2303ef127341a7c6700f8f9248ef2d762 Mon Sep 17 00:00:00 2001 From: Mohammad Malik <80257039+mohammad-malik@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:16:16 +0000 Subject: [PATCH] Allow autoTestDiscoverOnSavePattern to layer across settings scopes Change the setting to an object of glob patterns mapped to booleans so VS Code merges entries the same way it merges files.exclude. A saved string glob is still honored. Co-authored-by: Mohammad Malik --- package.json | 11 +- package.nls.json | 2 +- src/client/common/configSettings.ts | 17 +- .../autoTestDiscoverOnSavePattern.ts | 143 +++++++++++++++ src/client/testing/configuration/types.ts | 9 +- .../testing/testController/controller.ts | 9 +- .../configSettings.unit.test.ts | 45 +++++ ...autoTestDiscoverOnSavePattern.unit.test.ts | 163 ++++++++++++++++++ .../testController/controller.unit.test.ts | 79 ++++++++- 9 files changed, 465 insertions(+), 13 deletions(-) create mode 100644 src/client/testing/configuration/autoTestDiscoverOnSavePattern.ts create mode 100644 src/test/testing/configuration/autoTestDiscoverOnSavePattern.unit.test.ts diff --git a/package.json b/package.json index 12be1534f50e..523df4e35895 100644 --- a/package.json +++ b/package.json @@ -674,10 +674,15 @@ "type": "boolean" }, "python.testing.autoTestDiscoverOnSavePattern": { - "default": "**/*.py", - "description": "%python.testing.autoTestDiscoverOnSavePattern.description%", + "additionalProperties": { + "type": "boolean" + }, + "default": { + "**/*.py": true + }, + "markdownDescription": "%python.testing.autoTestDiscoverOnSavePattern.description%", "scope": "resource", - "type": "string" + "type": "object" }, "python.testing.cwd": { "default": null, diff --git a/package.nls.json b/package.nls.json index 038c0f278305..3f3fd8443f6d 100644 --- a/package.nls.json +++ b/package.nls.json @@ -80,7 +80,7 @@ "python.terminal.focusAfterLaunch.description": "When launching a python terminal, whether to focus the cursor on the terminal.", "python.terminal.launchArgs.description": "Python launch arguments to use when executing a file in the terminal.", "python.testing.autoTestDiscoverOnSaveEnabled.description": "Enable auto run test discovery when saving a test file.", - "python.testing.autoTestDiscoverOnSavePattern.description": "Glob pattern used to determine which files are used by autoTestDiscoverOnSaveEnabled.", + "python.testing.autoTestDiscoverOnSavePattern.description": "Glob patterns that decide which saved files trigger automatic test discovery when `#python.testing.autoTestDiscoverOnSaveEnabled#` is enabled. Each key is a glob and the value turns that pattern on (`true`) or off (`false`). Patterns merge across default, user, workspace, and folder settings the same way `#files.exclude#` and `#search.exclude#` do, so a pattern in user settings and another in workspace settings both apply. Set a pattern to `false` to turn off that pattern from a broader scope, including the default `**/*.py`. A saved file triggers discovery when it matches any enabled pattern. Patterns are matched against the workspace-relative path and the absolute path. A single glob string is still accepted so existing settings keep working, but a string replaces the whole value and does not merge across scopes.", "python.testing.cwd.description": "Optional working directory for tests.", "python.testing.debugPort.description": "Port number used for debugging of tests.", "python.testing.promptToConfigure.description": "Prompt to configure a test framework if potential tests directories are discovered.", diff --git a/src/client/common/configSettings.ts b/src/client/common/configSettings.ts index 91c06d9331fd..ec4ab96cbfb8 100644 --- a/src/client/common/configSettings.ts +++ b/src/client/common/configSettings.ts @@ -18,6 +18,10 @@ import { IInterpreterAutoSelectionProxyService } from '../interpreter/autoSelect import { sendTelemetryEvent } from '../telemetry'; import { EventName } from '../telemetry/constants'; import { sendSettingTelemetry } from '../telemetry/envFileTelemetry'; +import { + DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN, + resolveAutoTestDiscoverOnSavePattern, +} from '../testing/configuration/autoTestDiscoverOnSavePattern'; import { ITestingSettings } from '../testing/configuration/types'; import { IWorkspaceService } from './application/types'; import { WorkspaceService } from './application/workspace'; @@ -331,7 +335,7 @@ export class PythonSettings implements IPythonSettings { unittestEnabled: false, pytestPath: 'pytest', autoTestDiscoverOnSaveEnabled: true, - autoTestDiscoverOnSavePattern: '**/*.py', + autoTestDiscoverOnSavePattern: { ...DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN }, } as ITestingSettings; } } @@ -348,7 +352,7 @@ export class PythonSettings implements IPythonSettings { unittestArgs: [], unittestEnabled: false, autoTestDiscoverOnSaveEnabled: true, - autoTestDiscoverOnSavePattern: '**/*.py', + autoTestDiscoverOnSavePattern: { ...DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN }, }; this.testing.pytestPath = getAbsolutePath(systemVariables.resolveAny(this.testing.pytestPath), workspaceRoot); if (this.testing.cwd) { @@ -359,6 +363,15 @@ export class PythonSettings implements IPythonSettings { this.testing.pytestArgs = this.testing.pytestArgs.map((arg) => systemVariables.resolveAny(arg)); this.testing.unittestArgs = this.testing.unittestArgs.map((arg) => systemVariables.resolveAny(arg)); + // Glob patterns are the object keys. resolveAny substitutes variables in values, so resolve the keys here. + const resolvedPattern = resolveAutoTestDiscoverOnSavePattern( + this.testing.autoTestDiscoverOnSavePattern, + (value) => systemVariables.resolveAny(value), + ); + this.testing.autoTestDiscoverOnSavePattern = resolvedPattern ?? { + ...DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN, + }; + const terminalSettings = systemVariables.resolveAny(pythonSettings.get('terminal'))!; if (this.terminal) { Object.assign(this.terminal, terminalSettings); diff --git a/src/client/testing/configuration/autoTestDiscoverOnSavePattern.ts b/src/client/testing/configuration/autoTestDiscoverOnSavePattern.ts new file mode 100644 index 000000000000..8a010956a44a --- /dev/null +++ b/src/client/testing/configuration/autoTestDiscoverOnSavePattern.ts @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as minimatch from 'minimatch'; +import { AutoTestDiscoverOnSavePattern } from './types'; + +/** + * Default pattern registered for `python.testing.autoTestDiscoverOnSavePattern`. + * Kept in sync with the `default` in package.json. + */ +export const DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN: Readonly> = { + '**/*.py': true, +}; + +/** + * Resolve `${...}` variables in glob keys. + * + * `SystemVariables.resolveAny` walks object values and leaves keys untouched. + * Patterns live in the keys, the same way `files.exclude` does, so callers + * pass the string resolver and we rebuild the map. + * A legacy string pattern is returned unchanged; the caller has already + * resolved that string. + */ +export function resolveAutoTestDiscoverOnSavePattern( + pattern: AutoTestDiscoverOnSavePattern | undefined, + resolveString: (value: string) => string, +): AutoTestDiscoverOnSavePattern | undefined { + if (typeof pattern === 'string' || !isPatternMap(pattern)) { + return pattern; + } + + const resolved: Record = {}; + Object.entries(pattern).forEach(([key, value]) => { + if (typeof value !== 'boolean') { + return; + } + const resolvedKey = resolveString(key).trim(); + if (!resolvedKey) { + return; + } + // A later entry wins when two keys resolve to the same glob, which + // matches the last-write behavior of a settings object. + resolved[resolvedKey] = value; + }); + return resolved; +} + +/** + * A saved file triggers discovery when it matches any pattern whose value is `true`. + * + * VS Code deep-merges this object across default, user, workspace, and folder + * scopes before the extension reads it. A `false` value turns that one pattern + * off so a narrower scope can disable a pattern inherited from a broader scope + * (including the default Python glob). It does not exclude a file that matches a + * different enabled pattern. That is the same model as `files.exclude`. + * + * The previous setting was a single glob string matched against the absolute + * path. Object patterns are also matched against the workspace-relative path + * so globs written like `files.exclude` (relative to the workspace) work. + */ +export function matchesAutoTestDiscoverOnSavePattern( + fsPath: string, + pattern: AutoTestDiscoverOnSavePattern | undefined | null, + workspaceRoot?: string, +): boolean { + const globs = enabledGlobs(pattern); + if (globs.length === 0) { + return false; + } + const candidates = pathsToMatch(fsPath, workspaceRoot); + return globs.some((glob) => { + const normalizedGlob = toPosixPath(glob).trim(); + return ( + normalizedGlob.length > 0 && candidates.some((candidate) => minimatch.default(candidate, normalizedGlob)) + ); + }); +} + +function enabledGlobs(pattern: AutoTestDiscoverOnSavePattern | undefined | null): string[] { + if (typeof pattern === 'string') { + const glob = pattern.trim(); + return glob ? [glob] : []; + } + if (!isPatternMap(pattern)) { + return []; + } + return Object.keys(pattern).filter((key) => pattern[key] === true && key.trim().length > 0); +} + +function pathsToMatch(fsPath: string, workspaceRoot?: string): string[] { + const absolutePath = toPosixPath(fsPath); + const candidates = new Set([absolutePath]); + + // minimatch treats `:` as an extglob character, so `C:/...` often fails to + // match a `**` glob. Also try the path without the drive prefix. + const withoutDrive = absolutePath.replace(/^[A-Za-z]:/, ''); + if (withoutDrive !== absolutePath) { + candidates.add(withoutDrive); + } + + if (workspaceRoot) { + const relativePath = workspaceRelativePath(absolutePath, workspaceRoot); + if (relativePath) { + candidates.add(relativePath); + } + } + + return Array.from(candidates); +} + +function workspaceRelativePath(fsPath: string, workspaceRoot: string): string | undefined { + let root = toPosixPath(workspaceRoot); + if (root.endsWith('/')) { + root = root.slice(0, -1); + } + if (!root) { + return undefined; + } + + // Windows paths are case-insensitive. Treat a drive-letter path the same + // way when tests run on a case-sensitive OS. + const windows = process.platform === 'win32' || /^[A-Za-z]:\//.test(fsPath); + const fileForCompare = windows ? fsPath.toLowerCase() : fsPath; + const rootForCompare = windows ? root.toLowerCase() : root; + const prefix = `${rootForCompare}/`; + if (!fileForCompare.startsWith(prefix)) { + return undefined; + } + const relativePath = fsPath.slice(prefix.length); + return relativePath.length > 0 ? relativePath : undefined; +} + +/** + * minimatch treats `\` as an escape. Normalize separators so a Windows path + * and a glob written with backslashes match the same way they do elsewhere. + */ +function toPosixPath(value: string): string { + return value.replace(/\\/g, '/'); +} + +function isPatternMap(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/client/testing/configuration/types.ts b/src/client/testing/configuration/types.ts index 3b759bcb39e8..a6f7992c0535 100644 --- a/src/client/testing/configuration/types.ts +++ b/src/client/testing/configuration/types.ts @@ -1,6 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +/** + * Glob used to decide which saved files trigger test discovery. + * A string is the legacy single-pattern form. An object maps each glob to a + * boolean so VS Code can merge entries across settings scopes. + */ +export type AutoTestDiscoverOnSavePattern = string | { readonly [glob: string]: boolean }; + export interface ITestingSettings { readonly promptToConfigure: boolean; readonly debugPort: number; @@ -11,7 +18,7 @@ export interface ITestingSettings { unittestArgs: string[]; cwd?: string; readonly autoTestDiscoverOnSaveEnabled: boolean; - readonly autoTestDiscoverOnSavePattern: string; + autoTestDiscoverOnSavePattern: AutoTestDiscoverOnSavePattern; } export type TestSettingsPropertyNames = { diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index cd7b516af2b7..7f3ed284034b 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -3,7 +3,6 @@ import { inject, injectable, named } from 'inversify'; import { uniq } from 'lodash'; -import * as minimatch from 'minimatch'; import { CancellationToken, TestController, @@ -35,6 +34,7 @@ import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import type { UnitTestRunFailureCategory } from '../../telemetry/constants'; import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../common/constants'; +import { matchesAutoTestDiscoverOnSavePattern } from '../configuration/autoTestDiscoverOnSavePattern'; import { TestProvider } from '../types'; import { createErrorTestItem, DebugTestTag, getNodeByUri, RunTestTag } from './common/testItemUtilities'; import { buildErrorNodeOptions } from './common/utils'; @@ -1126,9 +1126,14 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.disposables.push( onDidSaveTextDocument(async (doc: TextDocument) => { const settings = this.configSettings.getSettings(doc.uri); + const workspaceFolder = this.workspaceService.getWorkspaceFolder(doc.uri); if ( settings.testing.autoTestDiscoverOnSaveEnabled && - minimatch.default(doc.uri.fsPath, settings.testing.autoTestDiscoverOnSavePattern) + matchesAutoTestDiscoverOnSavePattern( + doc.uri.fsPath, + settings.testing.autoTestDiscoverOnSavePattern, + workspaceFolder?.uri.fsPath, + ) ) { traceVerbose(`Testing: Trigger refresh after saving ${doc.uri.fsPath}`); this.sendTriggerTelemetry('watching'); diff --git a/src/test/common/configSettings/configSettings.unit.test.ts b/src/test/common/configSettings/configSettings.unit.test.ts index 65afc782d7bb..d6a8db0c4cde 100644 --- a/src/test/common/configSettings/configSettings.unit.test.ts +++ b/src/test/common/configSettings/configSettings.unit.test.ts @@ -308,4 +308,49 @@ suite('Python Settings', async () => { test('Experiments (not enabled)', () => testExperiments(false)); test('Experiments (enabled)', () => testExperiments(true)); + + test('autoTestDiscoverOnSavePattern resolves glob keys and preserves a legacy string', () => { + const previous = process.env.AUTO_TEST_DISCOVER_PATTERN_ROOT; + process.env.AUTO_TEST_DISCOVER_PATTERN_ROOT = '/repo/tests'; + try { + expected.pythonPath = 'python3'; + expected.testing = { + promptToConfigure: true, + debugPort: 3000, + pytestEnabled: false, + pytestPath: 'pytest', + pytestArgs: [], + unittestEnabled: false, + unittestArgs: [], + autoTestDiscoverOnSaveEnabled: true, + autoTestDiscoverOnSavePattern: { + '${env:AUTO_TEST_DISCOVER_PATTERN_ROOT}/**/test_*.py': true, + '**/*.py': false, + }, + }; + initializeConfig(expected); + + settings.update(config.object); + + expect(settings.testing.autoTestDiscoverOnSavePattern).to.deep.equal({ + '/repo/tests/**/test_*.py': true, + '**/*.py': false, + }); + + expected.testing = { + ...expected.testing, + autoTestDiscoverOnSavePattern: '${env:AUTO_TEST_DISCOVER_PATTERN_ROOT}/**/test_*.py', + }; + initializeConfig(expected); + settings.update(config.object); + + expect(settings.testing.autoTestDiscoverOnSavePattern).to.equal('/repo/tests/**/test_*.py'); + } finally { + if (previous === undefined) { + delete process.env.AUTO_TEST_DISCOVER_PATTERN_ROOT; + } else { + process.env.AUTO_TEST_DISCOVER_PATTERN_ROOT = previous; + } + } + }); }); diff --git a/src/test/testing/configuration/autoTestDiscoverOnSavePattern.unit.test.ts b/src/test/testing/configuration/autoTestDiscoverOnSavePattern.unit.test.ts new file mode 100644 index 000000000000..0886eb7ba1ca --- /dev/null +++ b/src/test/testing/configuration/autoTestDiscoverOnSavePattern.unit.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { + DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN, + matchesAutoTestDiscoverOnSavePattern, + resolveAutoTestDiscoverOnSavePattern, +} from '../../../client/testing/configuration/autoTestDiscoverOnSavePattern'; + +suite('autoTestDiscoverOnSavePattern', () => { + const pyFile = '/workspace/pkg/app.py'; + const testFile = '/workspace/pkg/test_app.py'; + const suffixTestFile = '/workspace/pkg/app_test.py'; + const readme = '/workspace/pkg/readme.md'; + + test('default pattern matches Python files only', () => { + assert.strictEqual( + matchesAutoTestDiscoverOnSavePattern(pyFile, DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN), + true, + ); + assert.strictEqual( + matchesAutoTestDiscoverOnSavePattern(readme, DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN), + false, + ); + }); + + test('legacy string pattern still matches the absolute path', () => { + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(pyFile, '**/*.py'), true); + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(readme, '**/*.py'), false); + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(pyFile, ' '), false); + }); + + test('enabled object patterns are combined and disabled patterns are ignored', () => { + const pattern = { + '**/*.py': false, + '**/test_*.py': true, + '**/*_test.py': true, + '**/generated/**': false, + }; + + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(pyFile, pattern), false); + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(testFile, pattern), true); + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(suffixTestFile, pattern), true); + // A false pattern does not exclude a file matched by a different enabled pattern. + assert.strictEqual( + matchesAutoTestDiscoverOnSavePattern('/workspace/generated/test_foo.py', { + '**/*.py': true, + '**/generated/**': false, + }), + true, + ); + }); + + test('a merged object keeps a narrower-scope override of the same pattern', () => { + // User `**/*.py: true` overridden by workspace `**/*.py: false`, plus a workspace pattern. + const merged = { + '**/*.py': false, + '**/test_*.py': true, + }; + + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(pyFile, merged), false); + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(testFile, merged), true); + }); + + test('workspace-relative globs match files inside the workspace', () => { + const pattern = { 'tests/**/*.py': true }; + + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern('/workspace/tests/test_foo.py', pattern), false); + assert.strictEqual( + matchesAutoTestDiscoverOnSavePattern('/workspace/tests/test_foo.py', pattern, '/workspace'), + true, + ); + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern('/workspace/src/app.py', pattern, '/workspace'), false); + assert.strictEqual( + matchesAutoTestDiscoverOnSavePattern('/other/tests/test_foo.py', pattern, '/workspace'), + false, + ); + }); + + test('windows paths and backslash globs match', () => { + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern('C:\\workspace\\pkg\\test_app.py', '**/*.py'), true); + assert.strictEqual( + matchesAutoTestDiscoverOnSavePattern( + 'C:\\workspace\\tests\\test_app.py', + { 'tests/**/*.py': true }, + 'C:\\workspace', + ), + true, + ); + assert.strictEqual( + matchesAutoTestDiscoverOnSavePattern('C:\\workspace\\pkg\\app.py', { '**\\*.py': true }), + true, + ); + }); + + test('missing and invalid patterns do not match and do not throw', () => { + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(pyFile, undefined), false); + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(pyFile, null), false); + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(pyFile, {}), false); + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(pyFile, { '**/*.py': false }), false); + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(pyFile, ['**/*.py'] as any), false); + assert.strictEqual(matchesAutoTestDiscoverOnSavePattern(pyFile, { '**/*.py': 'yes' } as any), false); + }); + + test('resolve substitutes variables in glob keys and keeps booleans', () => { + const resolved = resolveAutoTestDiscoverOnSavePattern( + { + '${workspaceFolder}/**/test_*.py': true, + '**/*.py': false, + ' **/extra.py ': true, + ignored: 'nope' as any, + '': true, + }, + (value) => value.replace('${workspaceFolder}', '/repo'), + ); + + assert.deepStrictEqual(resolved, { + '/repo/**/test_*.py': true, + '**/*.py': false, + '**/extra.py': true, + }); + }); + + test('resolve lets a later key win when two keys resolve to the same glob', () => { + const resolved = resolveAutoTestDiscoverOnSavePattern( + { + '${root}/**/test_*.py': true, + '/repo/**/test_*.py': false, + }, + (value) => value.replace('${root}', '/repo'), + ); + + assert.deepStrictEqual(resolved, { '/repo/**/test_*.py': false }); + }); + + test('resolve leaves a legacy string unchanged', () => { + assert.strictEqual( + resolveAutoTestDiscoverOnSavePattern('${workspaceFolder}/**/*.py', (value) => value), + '${workspaceFolder}/**/*.py', + ); + assert.strictEqual( + resolveAutoTestDiscoverOnSavePattern(undefined, (value) => value), + undefined, + ); + }); + + test('package.json default matches the built-in pattern', () => { + const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../../package.json'), 'utf8')); + const contribution = + packageJson.contributes.configuration.properties['python.testing.autoTestDiscoverOnSavePattern']; + + assert.deepStrictEqual(contribution.default, DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN); + assert.strictEqual(contribution.type, 'object'); + assert.strictEqual(contribution.additionalProperties.type, 'boolean'); + assert.strictEqual(contribution.scope, 'resource'); + }); +}); diff --git a/src/test/testing/testController/controller.unit.test.ts b/src/test/testing/testController/controller.unit.test.ts index b5a198d0bb46..d978e82027bc 100644 --- a/src/test/testing/testController/controller.unit.test.ts +++ b/src/test/testing/testController/controller.unit.test.ts @@ -4,9 +4,11 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; -import { TestController, Uri } from 'vscode'; +import { TestController, TextDocument, Uri } from 'vscode'; import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../../../client/testing/common/constants'; +import { AutoTestDiscoverOnSavePattern } from '../../../client/testing/configuration/types'; +import * as workspaceApis from '../../../client/common/vscodeApis/workspaceApis'; import * as envExtApiInternal from '../../../client/envExt/api.internal'; import * as projectUtils from '../../../client/testing/testController/common/projectUtils'; import { PythonTestController } from '../../../client/testing/testController/controller'; @@ -46,8 +48,16 @@ suite('PythonTestController', () => { sandbox.restore(); }); - function createController(options?: { unittestEnabled?: boolean; interpreter?: any; workspaceService?: any }): any { + function createController(options?: { + unittestEnabled?: boolean; + autoTestDiscoverOnSaveEnabled?: boolean; + autoTestDiscoverOnSavePattern?: AutoTestDiscoverOnSavePattern; + interpreter?: any; + workspaceService?: any; + }): any { const unittestEnabled = options?.unittestEnabled ?? false; + const autoTestDiscoverOnSaveEnabled = options?.autoTestDiscoverOnSaveEnabled ?? false; + const autoTestDiscoverOnSavePattern = options?.autoTestDiscoverOnSavePattern ?? '**/*.py'; const interpreter = options?.interpreter ?? ({ @@ -57,12 +67,18 @@ suite('PythonTestController', () => { sysPrefix: '/usr', } as any); - const workspaceService = options?.workspaceService ?? (({ workspaceFolders: [] } as unknown) as any); + const workspaceService = + options?.workspaceService ?? + (({ + workspaceFolders: [], + getWorkspaceFolder: () => undefined, + } as unknown) as any); const configSettings = ({ getSettings: sandbox.stub().returns({ testing: { unittestEnabled, - autoTestDiscoverOnSaveEnabled: false, + autoTestDiscoverOnSaveEnabled, + autoTestDiscoverOnSavePattern, }, }), } as unknown) as any; @@ -431,4 +447,59 @@ suite('PythonTestController', () => { assert.strictEqual(pending.size, 0); }); }); + + suite('watchForTestContentChangeOnSave', () => { + let saveHandler: (doc: TextDocument) => void = () => undefined; + + function installSaveHandler(): void { + sandbox.stub(workspaceApis, 'onDidSaveTextDocument').callsFake((callback) => { + saveHandler = callback; + return { dispose: () => undefined }; + }); + } + + test('refreshes when a saved file matches a legacy string pattern', async () => { + installSaveHandler(); + const controller = createController({ + autoTestDiscoverOnSaveEnabled: true, + autoTestDiscoverOnSavePattern: '**/*.py', + }); + const trigger = sandbox.stub(); + (controller as any).refreshData = { trigger }; + (controller as any).watchForTestContentChangeOnSave(); + + await saveHandler({ uri: Uri.file('/workspace/pkg/readme.md') } as TextDocument); + assert.strictEqual(trigger.called, false); + + const pyUri = Uri.file('/workspace/pkg/app.py'); + await saveHandler({ uri: pyUri } as TextDocument); + assert.strictEqual(trigger.calledOnce, true); + assert.strictEqual(trigger.firstCall.args[0], pyUri); + assert.strictEqual(trigger.firstCall.args[1], false); + }); + + test('applies layered patterns and the workspace-relative path', async () => { + installSaveHandler(); + const controller = createController({ + autoTestDiscoverOnSaveEnabled: true, + autoTestDiscoverOnSavePattern: { + '**/*.py': false, + 'tests/**/*.py': true, + }, + workspaceService: { + workspaceFolders: [], + getWorkspaceFolder: () => ({ uri: Uri.file('/workspace'), name: 'workspace', index: 0 }), + }, + }); + const trigger = sandbox.stub(); + (controller as any).refreshData = { trigger }; + (controller as any).watchForTestContentChangeOnSave(); + + await saveHandler({ uri: Uri.file('/workspace/src/app.py') } as TextDocument); + assert.strictEqual(trigger.called, false); + + await saveHandler({ uri: Uri.file('/workspace/tests/test_app.py') } as TextDocument); + assert.strictEqual(trigger.calledOnce, true); + }); + }); });