diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index b2ddc5e..0d87f22 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -331,12 +331,6 @@ class VitestExtension { this.disposables = [ vscode.workspace.onDidChangeConfiguration((event) => { const configName = reloadConfigNames.find((x) => event.affectsConfiguration(x)) - if ( - event.affectsConfiguration('vitest.runtime') || - event.affectsConfiguration('deno.enabled') - ) { - clearCachedRuntime() - } if (configName) { this.defineTestProfiles(false).catch((error) => { log.error('[API]', `Failed to reload Vitest after "${configName}" has changed`, error) @@ -511,7 +505,7 @@ class VitestExtension { } catch (err) { log.error(err) vscode.window.showErrorMessage( - `Vitest: The file was not processed by Vite yet. Try running the tests first${options.length > 1 ? ' or select a different environment' : ''}.`, + `Vitest: The file was not processed by Vite yet. Try starting the continuous run first${options.length > 1 ? ' or select a different environment' : ''}.`, ) } }, diff --git a/packages/extension/src/schemaProvider.ts b/packages/extension/src/schemaProvider.ts index 6fe2776..e1a4fe8 100644 --- a/packages/extension/src/schemaProvider.ts +++ b/packages/extension/src/schemaProvider.ts @@ -21,6 +21,7 @@ export class TransformSchemaProvider this.disposables.push(this._onDidChangeEvents) } + // This is called by vscode to clear the internal cache public onDidChange = this._onDidChangeEvents.event public emitChange(uri: vscode.Uri) { diff --git a/packages/extension/src/spawn/ws.ts b/packages/extension/src/spawn/ws.ts index 35b6229..94e60bd 100644 --- a/packages/extension/src/spawn/ws.ts +++ b/packages/extension/src/spawn/ws.ts @@ -17,6 +17,7 @@ import { } from '../constants' import { log } from '../log' import { createVitestRpc } from './rpc' +import { resolve } from 'pathe' export type WsConnectionMetadata = Omit & { ws: WebSocket @@ -94,7 +95,12 @@ export function onWsConnection( rpc: api, workspaceSource: message.workspaceSource, handlers, - projects: message.projects, + projects: message.projects.map((p) => { + if (p.dir) { + p.dir = resolve(pkg.cwd, p.dir) + } + return p + }), ws, pkg, async dispose() { diff --git a/packages/extension/src/testTree.ts b/packages/extension/src/testTree.ts index 535ac09..7121bfe 100644 --- a/packages/extension/src/testTree.ts +++ b/packages/extension/src/testTree.ts @@ -222,6 +222,10 @@ export class TestTree extends vscode.Disposable { private recursiveDelete(item: vscode.TestItem) { if (!item.parent) return + + // Clean up children first so no stale entries remain in Maps + this.cleanupChildren(item) + item.parent.children.delete(item.id) this.flatTestItems.delete(item.id) const data = getTestData(item) @@ -235,6 +239,19 @@ export class TestTree extends vscode.Disposable { if (!item.parent.children.size) this.recursiveDelete(item.parent) } + private cleanupChildren(item: vscode.TestItem) { + item.children.forEach((child) => { + this.cleanupChildren(child) + this.flatTestItems.delete(child.id) + const data = getTestData(child) + if (data instanceof TestFile) { + this.testItemsByFile.delete(data.filepath) + this.fileItems.delete(child.id) + } + if (data instanceof TestFolder) this.folderItems.delete(child.id) + }) + } + public getAPIFromTestItem(testItem: vscode.TestItem) { return getAPIFromTestItem(testItem) } diff --git a/packages/extension/src/watcher.ts b/packages/extension/src/watcher.ts index 1a81785..32bf034 100644 --- a/packages/extension/src/watcher.ts +++ b/packages/extension/src/watcher.ts @@ -53,23 +53,97 @@ export class ExtensionWatcher extends vscode.Disposable { const changeQueue = new Map() const createQueue = new Map() + const scheduleCreateDeleteFlush = () => { + this.scheduleFlush(`create-delete:${folder.index}`, async () => { + const deleteBatch = new Map(deleteQueue) + const createBatch = new Map(createQueue) + deleteQueue.clear() + createQueue.clear() + + // Process creates first so new items exist in the tree + // before deletes remove old ones (prevents parent folders + // from being orphaned during renames) + if (createBatch.size) { + log.verbose?.(`[VSCODE] Flushing ${createBatch.size} created items`) + + const apis = this.apisByFolder.get(folder) || [] + const roots = apis.flatMap((api) => + api.config.projects.map((p) => normalize(p.dir || p.root)), + ) + const openedFiles = vscode.workspace.textDocuments.map((d) => normalize(d.uri.fsPath)) + + const entries = [...createBatch.entries()] + const types = await Promise.allSettled( + entries.map(([path, uri]) => this.getFsType(path, uri)), + ) + + const files: string[] = [] + const folders: [string, vscode.Uri][] = [] + for (let i = 0; i < entries.length; i++) { + const result = types[i] + if (result.status !== 'fulfilled' || !result.value) { + continue + } + if (result.value === 'file') { + files.push(entries[i][0]) + } else { + folders.push(entries[i]) + } + } + + const folderFiles = await Promise.allSettled( + folders.map(([, uri]) => this.readFilesRecursively(uri, roots)), + ) + + const seen = new Set(files) + for (const result of folderFiles) { + if (result.status !== 'fulfilled') { + continue + } + for (const file of result.value) { + seen.add(file) + } + } + + seen.forEach((file) => { + apis.forEach((api) => { + const metadata = api.getPotentialTestFileMetadata(file) + metadata.forEach((meta) => { + this.testTree.getOrCreateFileTestItem(api, meta, file) + + // If file is open and not a continuous run, + // Collect its tests immidetly, otherwise ignore + if ( + openedFiles.includes(file) && + !api.getPersistentProcessMeta() && + !api.isSpawningPersistentProcess + ) { + api.collectTests(meta.project, file) + } + }) + }) + }) + } + + if (deleteBatch.size) { + log.verbose?.(`[VSCODE] Flushing ${deleteBatch.size} deleted items`) + for (const [path, uri] of deleteBatch) { + this.transformSchemaProvider.emitChange(uri) + // We don't know if it was a file or a folder + this.testTree.removeFile(path) + this.testTree.removeFolder(path) + } + } + }) + } + watcher.onDidDelete((uri) => { const path = normalize(uri.fsPath) if (this.isCommondIgnore(path)) { return } deleteQueue.set(path, uri) - this.scheduleFlush(`delete:${folder.name}`, () => { - const batch = new Map(deleteQueue) - deleteQueue.clear() - log.verbose?.(`[VSCODE] Flushing ${batch.size} deleted items`) - for (const [path, uri] of batch) { - this.transformSchemaProvider.emitChange(uri) - // We don't know if it is a file or a folder - this.testTree.removeFile(path) - this.testTree.removeFolder(path) - } - }) + scheduleCreateDeleteFlush() }) watcher.onDidChange((uri) => { @@ -78,16 +152,23 @@ export class ExtensionWatcher extends vscode.Disposable { return } changeQueue.set(path, uri) - this.scheduleFlush(`change:${folder.name}`, async () => { + this.scheduleFlush(`change:${folder.index}`, async () => { const batch = new Map(changeQueue) changeQueue.clear() log.verbose?.(`[VSCODE] Flushing ${batch.size} changed items`) const apis = this.apisByFolder.get(folder) || [] - for (const [path, uri] of batch) { - const type = await this.getFsType(api, path, uri) - if (type !== 'file') { + + const entries = [...batch.entries()] + const types = await Promise.allSettled( + entries.map(([path, uri]) => this.getFsType(path, uri)), + ) + + for (let i = 0; i < entries.length; i++) { + const result = types[i] + if (result.status !== 'fulfilled' || result.value !== 'file') { continue } + const [path, uri] = entries[i] this.transformSchemaProvider.emitChange(uri) apis.forEach((api) => { @@ -99,6 +180,7 @@ export class ExtensionWatcher extends vscode.Disposable { return } + // If runner is persistent, the tests will be collected at runtime if (api.getPersistentProcessMeta() || api.isSpawningPersistentProcess) { return } @@ -118,50 +200,7 @@ export class ExtensionWatcher extends vscode.Disposable { return } createQueue.set(path, uri) - this.scheduleFlush(`create:${folder.name}`, async () => { - const batch = new Map(createQueue) - createQueue.clear() - log.verbose?.(`[VSCODE] Flushing ${batch.size} created items`) - - const apis = this.apisByFolder.get(folder) || [] - const roots = apis.flatMap((api) => - // TODO: resolve should be done on the worker side - api.config.projects.map((p) => normalize(resolve(api.config.cwd, p.dir || p.root))), - ) - const openedFiles = vscode.workspace.textDocuments.map((d) => normalize(d.uri.fsPath)) - - const allFiles: string[] = [] - for (const [path, uri] of batch) { - const type = await this.getFsType(api, path, uri) - if (!type) { - continue - } - if (type === 'file') { - allFiles.push(path) - } else { - allFiles.push(...(await this.readFilesRecursively(uri, roots))) - } - } - - allFiles.forEach((file) => { - apis.forEach((api) => { - const metadata = api.getPotentialTestFileMetadata(file) - metadata.forEach((meta) => { - this.testTree.getOrCreateFileTestItem(api, meta, file) - - // If file is open and not a continuous run, - // Collect its tests immidetly, otherwise ignore - if ( - openedFiles.includes(file) && - !api.getPersistentProcessMeta() && - !api.isSpawningPersistentProcess - ) { - api.collectTests(meta.project, file) - } - }) - }) - }) - }) + scheduleCreateDeleteFlush() }) } @@ -179,10 +218,6 @@ export class ExtensionWatcher extends vscode.Disposable { ) } - private relative(api: VitestProcessAPI, uri: vscode.Uri) { - return relative(api.workspaceFolder.uri.fsPath, uri.fsPath) - } - private isCommondIgnore(path: string) { return ( path.includes('/node_modules/') || @@ -201,6 +236,7 @@ export class ExtensionWatcher extends vscode.Disposable { } const entries = await vscode.workspace.fs.readDirectory(uri) const files: string[] = [] + const subdirs: vscode.Uri[] = [] for (const [name, type] of entries) { const childUri = vscode.Uri.joinPath(uri, name) if ( @@ -210,7 +246,7 @@ export class ExtensionWatcher extends vscode.Disposable { if (this.isCommondIgnore(normalize(childUri.fsPath))) { continue } - files.push(...(await this.readFilesRecursively(childUri, roots))) + subdirs.push(childUri) } else if ( type === vscode.FileType.File || type === (vscode.FileType.File | vscode.FileType.SymbolicLink) @@ -218,10 +254,18 @@ export class ExtensionWatcher extends vscode.Disposable { files.push(normalize(childUri.fsPath)) } } + const results = await Promise.allSettled( + subdirs.map((child) => this.readFilesRecursively(child, roots)), + ) + for (const result of results) { + if (result.status === 'fulfilled') { + files.push(...result.value) + } + } return files } - private async getFsType(api: VitestProcessAPI, path: string, uri: vscode.Uri) { + private async getFsType(path: string, uri: vscode.Uri) { if (this.isCommondIgnore(path)) { return null } diff --git a/test/e2e/runner.test.ts b/test/e2e/runner.test.ts index 8f4f09f..5066c21 100644 --- a/test/e2e/runner.test.ts +++ b/test/e2e/runner.test.ts @@ -2,7 +2,7 @@ import { readFileSync, rmSync } from 'node:fs' import { beforeAll, beforeEach, describe, onTestFailed } from 'vitest' import { expect } from '@playwright/test' import { test } from './utils/helper' -import { editFile } from './utils/tester' +import { editFile, renameFile } from './utils/tester' // Vitst extension doesn't work with CI flag beforeAll(() => { @@ -251,3 +251,28 @@ describe('continuous testing', () => { expect(errors).toEqual(['1000 != 2']) }) }) + +test('renaming a folder back preserves test items', async ({ launch }) => { + const { tester } = await launch({ + workspacePath: './samples/basic-v4', + }) + + await tester.tree.expand('test/deep/deeper') + + const deepTest = tester.tree.getFileItem('deep.test.ts') + await expect(deepTest.locator).toBeVisible() + + // Rename deeper -> deeperer + renameFile('samples/basic-v4/test/deep/deeper', 'samples/basic-v4/test/deep/deeperer') + + await tester.tree.expand('test/deep/deeperer') + const renamedTest = tester.tree.getFileItem('deep.test.ts') + await expect(renamedTest.locator).toBeVisible() + + // Rename back deeperer -> deeper + renameFile('samples/basic-v4/test/deep/deeperer', 'samples/basic-v4/test/deep/deeper') + + await tester.tree.expand('test/deep/deeper') + const restoredTest = tester.tree.getFileItem('deep.test.ts') + await expect(restoredTest.locator).toBeVisible() +}) diff --git a/test/e2e/utils/tester.ts b/test/e2e/utils/tester.ts index 31b315b..1558d81 100644 --- a/test/e2e/utils/tester.ts +++ b/test/e2e/utils/tester.ts @@ -139,6 +139,7 @@ export class TesterTestItem { const originalFiles = new Map() const createdFiles = new Set() +const renamedPaths = new Map() afterEach(() => { originalFiles.forEach((content, file) => { fs.writeFileSync(file, content, 'utf-8') @@ -146,8 +147,12 @@ afterEach(() => { createdFiles.forEach((file) => { if (fs.existsSync(file)) fs.unlinkSync(file) }) + renamedPaths.forEach((originalPath, currentPath) => { + if (fs.existsSync(currentPath)) fs.renameSync(currentPath, originalPath) + }) originalFiles.clear() createdFiles.clear() + renamedPaths.clear() }) export function editFile(file: string, callback: (content: string) => string) { @@ -155,3 +160,8 @@ export function editFile(file: string, callback: (content: string) => string) { if (!originalFiles.has(file)) originalFiles.set(file, content) fs.writeFileSync(file, callback(content), 'utf-8') } + +export function renameFile(from: string, to: string) { + if (!renamedPaths.has(from)) renamedPaths.set(to, from) + fs.renameSync(from, to) +}