Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions docs/spec-auto-run-on-test-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Spec: Auto-Run Coverage on Test File Save

## Objective

When a developer writes or modifies a test file, the coverage dashboard and status bar should
automatically update with fresh numbers — without the user having to manually run pytest or
trigger any command.

**User:** Python developer using VS Code with coverage-visualizer installed. They are actively
writing tests and want immediate feedback on whether their new test improves coverage.

**Problem today:** User saves `test_calculator.py` → nothing happens → they manually run pytest
→ coverage files update → dashboard refreshes. The gap between save and updated numbers is
entirely manual friction.

**Success criteria:**
- User saves any test file → pytest runs in the background within 2s → dashboard shows updated %
- Status bar shows a spinner while pytest is running so the user knows something is happening
- If pytest fails, a warning notification appears (reusing existing behaviour from `handleNoCoverage`)
- A setting `coverageVisualizer.autoRunOnTestChange` (boolean, default `true`) lets users with
slow test suites opt out
- Behaviour is additive — existing manual `coverage-visualizer.show` command is unchanged

## Tech Stack

TypeScript, VS Code Extension API 1.90+, Node.js `child_process.spawn`

## Commands

```bash
npm run compile # compile TypeScript → out/
npm test # run Jest unit tests
npm run lint # ESLint on src/
```

## Project Structure

```
src/
extension.ts ← file watcher setup lives here (setupWatchers)
config.ts ← add autoRunOnTestChange setting here
ui/
statusBar.ts ← add spinner state here
package.json ← add new configuration property
```

## Code Style

Follow existing patterns exactly. New watchers go inside `setupWatchers()`. New config keys go
in `getConfig()`. No new files unless unavoidable.

```typescript
// Good — matches existing watcher pattern
const testWatcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(root, '{test_*.py,*_test.py,tests/**/*.py,test/**/*.py}')
);
testWatcher.onDidChange(debouncedTestRun);
testWatcher.onDidCreate(debouncedTestRun);
context.subscriptions.push(testWatcher);
```

## Testing Strategy

- Unit tests in `tests/` using Jest (existing framework)
- `vscode` is mocked at `tests/__mocks__/vscode.ts`
- New logic to test: debounce behaviour, setting read, watcher glob pattern
- `extension.ts` integration tested manually via F5

## Boundaries

- **Always:** run `npm run compile` before committing, keep changes inside existing functions
- **Ask first:** adding new npm dependencies, changing existing watcher patterns
- **Never:** auto-run on non-test source file changes, run pytest without checking
`autoRunOnTestChange` setting first, break the existing manual `show` command

## Assumptions

1. Test files are identified by filename pattern only (`test_*.py`, `*_test.py`) or by living
inside `tests/` or `test/` directories — same logic already used in `isTestFile()`
2. Debounce is 2 seconds after the last save event (not per-save)
3. Auto-run reuses the existing `handleNoCoverage` spawn infrastructure — no new process manager
4. The spinner uses VS Code's existing `$(sync~spin)` ThemeIcon in the status bar
5. We do NOT run only the changed test file — full suite always, for accurate numbers

## Not Doing (and Why)

- **Run only the changed test file** — gives partial/misleading coverage numbers; requires
coverage merging complexity; not worth it for v1
- **pytest-watch / `--watch` mode** — external dependency we don't control; overkill
- **Per-test CodeLens "▶ Run"** — different feature, much larger scope, separate PR
- **Watch non-test source files** — source changes don't add tests; false positives everywhere
- **Auto-install pytest-cov if missing** — out of scope; existing `handleNoCoverage` already
handles missing dependencies

## Open Questions

None — direction confirmed in brainstorm session.

## Success Criteria (testable)

- [ ] Save `test_foo.py` → within 2s pytest runs → status bar shows spinner → dashboard % updates
- [ ] Set `autoRunOnTestChange: false` → save test file → nothing happens
- [ ] pytest exits non-zero → warning notification shown → coverage not cleared
- [ ] Saving a non-test `.py` file → no auto-run triggered
- [ ] Multiple rapid saves → only one pytest run (debounce working)
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "coverage-visualizer",
"displayName": "Python Coverage Visualizer",
"description": "Visualize Python test coverage inline in VS Code — highlights, CodeLens, dashboard, and sidebar tree view",
"version": "1.0.2",
"version": "1.1.0",
"publisher": "kool7",
"engines": {
"vscode": "^1.90.0"
Expand Down Expand Up @@ -109,6 +109,11 @@
"type": "boolean",
"default": true,
"description": "Skip decorations and CodeLens on test files (test_*.py, *_test.py, files inside tests/ directories)."
},
"coverageVisualizer.autoRunOnTestChange": {
"type": "boolean",
"default": true,
"description": "Automatically re-run pytest and refresh coverage when a test file is saved. Disable if your test suite is too slow to run on every save."
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface Config {
enableHoverMessages: boolean;
autoReloadOnChange: boolean;
excludeTestFiles: boolean;
autoRunOnTestChange: boolean;
}

export function getConfig(): Config {
Expand All @@ -22,5 +23,6 @@ export function getConfig(): Config {
enableHoverMessages: cfg.get<boolean>('enableHoverMessages', true),
autoReloadOnChange: cfg.get<boolean>('autoReloadOnChange', true),
excludeTestFiles: cfg.get<boolean>('excludeTestFiles', true),
autoRunOnTestChange: cfg.get<boolean>('autoRunOnTestChange', true),
};
}
148 changes: 108 additions & 40 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
CoverageReport,
RawCoverageJson,
} from './parsers/coverageParser.js';
import { initStatusBar, updateStatusBar, clearStatusBar } from './ui/statusBar.js';
import { initStatusBar, updateStatusBar, clearStatusBar, showRunningStatusBar } from './ui/statusBar.js';
import { showDashboard, updateDashboard } from './ui/dashboardPanel.js';
import { getConfig } from './config.js';
import { CoverageCodeLensProvider } from './providers/codeLensProvider.js';
Expand All @@ -24,13 +24,19 @@ let currentReport: CoverageReport | undefined;
let coverageRunInProgress = false;
let noCoveragePromptActive = false;
let reloadTimer: ReturnType<typeof setTimeout> | undefined;
let testChangeTimer: ReturnType<typeof setTimeout> | undefined;
let coverageOutputChannel: vscode.OutputChannel | undefined;
let runningProc: ReturnType<typeof spawn> | undefined;
let cachedPytestEnv: { hasPytestCov: boolean; hasCoverage: boolean } | undefined;
let extensionContext: vscode.ExtensionContext | undefined;
let showDashboardPending = false;

const codeLensProvider = new CoverageCodeLensProvider();
const hoverProvider = new CoverageHoverProvider();
const treeProvider = new CoverageTreeProvider();

export function activate(context: vscode.ExtensionContext) {
extensionContext = context;
createDecorations();
initStatusBar(context);

Expand All @@ -44,19 +50,16 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('coverage-visualizer.show', loadAndApply),
vscode.commands.registerCommand('coverage-visualizer.clear', clearCoverage),
vscode.commands.registerCommand('coverage-visualizer.showDashboard', () => {
if (currentReport) {
showDashboard(currentReport, context);
} else {
loadAndApply().then(() => {
if (currentReport) showDashboard(currentReport, context);
});
}
if (currentReport) showDashboard(currentReport, context);
showDashboardPending = true;
loadAndApply();
}),
vscode.window.onDidChangeActiveTextEditor(editor => {
if (editor && currentReport) applyToEditor(editor, currentReport);
}),
vscode.workspace.onDidChangeConfiguration(e => {
if (!e.affectsConfiguration('coverageVisualizer')) return;
cachedPytestEnv = undefined;
coveredDecoration.dispose();
uncoveredDecoration.dispose();
createDecorations();
Expand Down Expand Up @@ -108,6 +111,19 @@ function setupWatchers(context: vscode.ExtensionContext) {
});
context.subscriptions.push(w);
});

const testWatcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(root, '{test_*.py,*_test.py,tests/**/*.py,test/**/*.py,**/conftest.py}')
);
const debouncedTestRun = () => {
if (!getConfig().autoRunOnTestChange) return;
clearTimeout(testChangeTimer);
testChangeTimer = setTimeout(() => runPytestSilently(root.uri.fsPath), 2000);
};
testWatcher.onDidChange(debouncedTestRun);
testWatcher.onDidCreate(debouncedTestRun);
testWatcher.onDidDelete(debouncedTestRun);
context.subscriptions.push(testWatcher);
}

async function loadAndApply() {
Expand Down Expand Up @@ -144,7 +160,12 @@ async function loadAndApply() {
numStatements: filteredTotal,
});

updateDashboard(report);
if (showDashboardPending && extensionContext) {
showDashboard(report, extensionContext);
showDashboardPending = false;
} else {
updateDashboard(report);
}
}

function resolvePython(workspaceFolder: string): string {
Expand All @@ -164,6 +185,54 @@ function checkPython(python: string, cwd: string, code: string): Promise<boolean
});
}

function spawnPytest(python: string, args: string[], workspaceFolder: string) {
coverageRunInProgress = true;
showRunningStatusBar();
coverageOutputChannel ??= vscode.window.createOutputChannel('Coverage Run');
coverageOutputChannel.appendLine(`\n${'─'.repeat(60)}`);
coverageOutputChannel.appendLine(`$ ${python} ${args.join(' ')}\n`);

runningProc = spawn(python, args, { cwd: workspaceFolder });
runningProc.stdout!.on('data', (d: Buffer) => coverageOutputChannel!.append(d.toString()));
runningProc.stderr!.on('data', (d: Buffer) => coverageOutputChannel!.append(d.toString()));
runningProc.on('close', code => {
runningProc = undefined;
coverageRunInProgress = false;
coverageOutputChannel!.appendLine(`\n[exited ${code ?? '?'}]`);
if (code !== 0) {
clearStatusBar();
vscode.window.showWarningMessage('pytest failed — check the Coverage Run output panel.');
} else {
loadAndApply();
}
});
}

async function runPytestSilently(workspaceFolder: string) {
if (coverageRunInProgress) return;
coverageRunInProgress = true;
try {
const python = resolvePython(workspaceFolder);
if (!cachedPytestEnv) {
const [hasPytestCov, hasCoverage] = await Promise.all([
checkPython(python, workspaceFolder, 'import pytest_cov'),
checkPython(python, workspaceFolder, 'import coverage'),
]);
cachedPytestEnv = { hasPytestCov, hasCoverage };
}
if (!cachedPytestEnv.hasCoverage) {
coverageRunInProgress = false;
return;
}
const args = cachedPytestEnv.hasPytestCov
? ['-m', 'pytest', '--cov=.', '--cov-report=']
: ['-m', 'coverage', 'run', '-m', 'pytest'];
spawnPytest(python, args, workspaceFolder);
} catch {
coverageRunInProgress = false;
}
}

async function handleNoCoverage(workspaceFolder: string) {
if (noCoveragePromptActive) return;
noCoveragePromptActive = true;
Expand All @@ -188,25 +257,11 @@ async function handleNoCoverage(workspaceFolder: string) {
if (choice !== 'Run pytest') return;

const args = hasPytestCov
? ['-m', 'pytest', '--cov=.', '--cov-report=json']
? ['-m', 'pytest', '--cov=.', '--cov-report=']
: ['-m', 'coverage', 'run', '-m', 'pytest'];

coverageRunInProgress = true;
coverageOutputChannel ??= vscode.window.createOutputChannel('Coverage Run');
coverageOutputChannel.clear();
coverageOutputChannel.show(true);
coverageOutputChannel.appendLine(`$ ${python} ${args.join(' ')}\n`);

const proc = spawn(python, args, { cwd: workspaceFolder });
proc.stdout.on('data', (d: Buffer) => coverageOutputChannel!.append(d.toString()));
proc.stderr.on('data', (d: Buffer) => coverageOutputChannel!.append(d.toString()));
proc.on('close', code => {
coverageRunInProgress = false;
coverageOutputChannel!.appendLine(`\n[exited ${code ?? '?'}]`);
if (code !== 0) {
vscode.window.showWarningMessage('pytest failed — check the Coverage Run output panel.');
}
});
coverageOutputChannel?.show(true);
spawnPytest(python, args, workspaceFolder);
} finally {
noCoveragePromptActive = false;
}
Expand All @@ -217,22 +272,17 @@ async function detectAndParse(
): Promise<{ report: CoverageReport; formatUsed: string } | undefined> {

const jsonPath = path.join(workspaceFolder, 'coverage.json');
if (fs.existsSync(jsonPath)) {
try {
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) as RawCoverageJson;
return { report: parseCoverageJson(raw), formatUsed: 'coverage.json' };
} catch { /* fall through */ }
}
const sqlitePath = path.join(workspaceFolder, '.coverage');

const xmlPath = path.join(workspaceFolder, 'coverage.xml');
if (fs.existsSync(xmlPath)) {
try {
return { report: parseCoverageXml(fs.readFileSync(xmlPath, 'utf-8')), formatUsed: 'coverage.xml' };
} catch { /* fall through */ }
}
const jsonExists = fs.existsSync(jsonPath);
const sqliteExists = fs.existsSync(sqlitePath);

const sqlitePath = path.join(workspaceFolder, '.coverage');
if (fs.existsSync(sqlitePath)) {
// Prefer .coverage → coverage json when .coverage is newer than coverage.json.
// This ensures branch coverage config from the project is always respected.
const sqliteIsNewer = sqliteExists && jsonExists &&
fs.statSync(sqlitePath).mtimeMs > fs.statSync(jsonPath).mtimeMs;

if (sqliteExists && (!jsonExists || sqliteIsNewer)) {
const python = resolvePython(workspaceFolder);
const generated = await new Promise<boolean>(resolve => {
exec(`"${python}" -m coverage json`, { cwd: workspaceFolder }, err => resolve(!err));
Expand All @@ -249,9 +299,24 @@ async function detectAndParse(
vscode.window.showErrorMessage(
`Coverage Visualizer: Failed to read .coverage — ${err instanceof Error ? err.message : String(err)}`
);
return undefined;
}
}

if (jsonExists) {
try {
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) as RawCoverageJson;
return { report: parseCoverageJson(raw), formatUsed: 'coverage.json' };
} catch { /* fall through */ }
}

const xmlPath = path.join(workspaceFolder, 'coverage.xml');
if (fs.existsSync(xmlPath)) {
try {
return { report: parseCoverageXml(fs.readFileSync(xmlPath, 'utf-8')), formatUsed: 'coverage.xml' };
} catch { /* fall through */ }
}

return undefined;
}

Expand Down Expand Up @@ -302,6 +367,9 @@ function linesToDecorations(lines: number[]): vscode.DecorationOptions[] {
}

export function deactivate() {
clearTimeout(reloadTimer);
clearTimeout(testChangeTimer);
runningProc?.kill();
coveredDecoration?.dispose();
uncoveredDecoration?.dispose();
coverageOutputChannel?.dispose();
Expand Down
Loading
Loading