diff --git a/apps/vscode/src/test/code-cell-symbols.test.ts b/apps/vscode/src/test/code-cell-symbols.test.ts index 0607a357..f229d60e 100644 --- a/apps/vscode/src/test/code-cell-symbols.test.ts +++ b/apps/vscode/src/test/code-cell-symbols.test.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import * as assert from "assert"; -import { openUniqueExampleDocument, wait } from "./test-utils"; +import { openAndShowUniqueExamplesDocument, wait } from "./test-utils"; +import { DisposableStore } from "core"; /** * Creates a fake document symbol provider that returns DocumentSymbol[] for virtual docs. @@ -69,6 +70,8 @@ function flattenSymbolNames(symbols: vscode.DocumentSymbol[]): string[] { } suite("Code Cell Symbols", function () { + const disposables = new DisposableStore(); + setup(async function () { await vscode.workspace .getConfiguration("quarto") @@ -77,6 +80,7 @@ suite("Code Cell Symbols", function () { }); teardown(async function () { + disposables.clear(); await vscode.workspace .getConfiguration("quarto") .update("symbols.showCodeCellsInOutline", undefined); @@ -102,106 +106,88 @@ suite("Code Cell Symbols", function () { // Register BEFORE opening the document // Use both scheme and language like the formatting tests - const provider = vscode.languages.registerDocumentSymbolProvider( + disposables.add(vscode.languages.registerDocumentSymbolProvider( { scheme: "file", pattern: "**/.vdoc.*" }, createFakeDocumentSymbolProvider(fakeSymbols) - ); + )); await wait(100); - const { doc, cleanup } = await openUniqueExampleDocument("format/basics.qmd"); - try { - await wait(800); + const { doc } = await openAndShowUniqueExamplesDocument("format/basics.qmd", disposables); + await wait(800); - const symbols = await vscode.commands.executeCommand( - "vscode.executeDocumentSymbolProvider", - doc.uri - ); + const symbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + doc.uri + ); - const names = flattenSymbolNames(symbols); - assert.ok( - names.includes("my_function"), - `Expected 'my_function' in symbols, got: ${names.join(", ")}` - ); - assert.ok( - names.includes("my_variable"), - `Expected 'my_variable' in symbols, got: ${names.join(", ")}` - ); - assert.ok( - names.includes("(code cell)"), - `Expected '(code cell)' in symbols, got: ${names.join(", ")}` - ); - } finally { - provider.dispose(); - await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); - cleanup(); - } + const names = flattenSymbolNames(symbols); + assert.ok( + names.includes("my_function"), + `Expected 'my_function' in symbols, got: ${names.join(", ")}` + ); + assert.ok( + names.includes("my_variable"), + `Expected 'my_variable' in symbols, got: ${names.join(", ")}` + ); + assert.ok( + names.includes("(code cell)"), + `Expected '(code cell)' in symbols, got: ${names.join(", ")}` + ); }); test("handles SymbolInformation[] from embedded provider", async function () { const symbolNames = ["info_function", "info_class"]; // Register BEFORE opening the document - const provider = vscode.languages.registerDocumentSymbolProvider( + disposables.add(vscode.languages.registerDocumentSymbolProvider( { scheme: "file", pattern: "**/.vdoc.*" }, createFakeSymbolInformationProvider(symbolNames) - ); + )); await wait(100); - const { doc, cleanup } = await openUniqueExampleDocument("format/basics.qmd"); - try { - await wait(800); + const { doc } = await openAndShowUniqueExamplesDocument("format/basics.qmd", disposables); + await wait(800); - const symbols = await vscode.commands.executeCommand( - "vscode.executeDocumentSymbolProvider", - doc.uri - ); + const symbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + doc.uri + ); - const names = flattenSymbolNames(symbols); - assert.ok( - names.includes("(code cell)"), - `Expected '(code cell)' in symbols, got: ${names.join(", ")}` - ); - assert.ok( - names.includes("info_function"), - `Expected 'info_function' in symbols, got: ${names.join(", ")}` - ); - assert.ok( - names.includes("info_class"), - `Expected 'info_class' in symbols, got: ${names.join(", ")}` - ); - } finally { - provider.dispose(); - await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); - cleanup(); - } + const names = flattenSymbolNames(symbols); + assert.ok( + names.includes("(code cell)"), + `Expected '(code cell)' in symbols, got: ${names.join(", ")}` + ); + assert.ok( + names.includes("info_function"), + `Expected 'info_function' in symbols, got: ${names.join(", ")}` + ); + assert.ok( + names.includes("info_class"), + `Expected 'info_class' in symbols, got: ${names.join(", ")}` + ); }); test("handles undefined from embedded provider without error", async function () { // Register BEFORE opening the document - const provider = vscode.languages.registerDocumentSymbolProvider( + disposables.add(vscode.languages.registerDocumentSymbolProvider( { scheme: "file", pattern: "**/.vdoc.*" }, createUndefinedSymbolProvider() - ); + )); await wait(100); - const { doc, cleanup } = await openUniqueExampleDocument("format/basics.qmd"); - try { - await wait(800); + const { doc } = await openAndShowUniqueExamplesDocument("format/basics.qmd", disposables); + await wait(800); - const symbols = await vscode.commands.executeCommand( - "vscode.executeDocumentSymbolProvider", - doc.uri - ); + const symbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + doc.uri + ); - const names = flattenSymbolNames(symbols); - assert.ok( - names.includes("(code cell)"), - `Expected '(code cell)' to still appear even when embedded provider returns undefined, got: ${names.join(", ")}` - ); - } finally { - provider.dispose(); - await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); - cleanup(); - } + const names = flattenSymbolNames(symbols); + assert.ok( + names.includes("(code cell)"), + `Expected '(code cell)' to still appear even when embedded provider returns undefined, got: ${names.join(", ")}` + ); }); }); diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index edf25e96..e1184525 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -1,8 +1,7 @@ import * as assert from "assert"; import * as vscode from "vscode"; -import { randomUUID } from "crypto"; import { LanguageClient } from "vscode-languageclient/node"; -import { examplesUri, raceTimeout } from "./test-utils"; +import { raceTimeout, openUniqueExamplesDocument } from "./test-utils"; import { testLanguageClient } from "./fixtures/test-language-client"; import { DidUpdateDiagnosticsEvent, EmbeddedDiagnosticsManager, VdocDisposeReason } from "../providers/diagnostics"; import { VIRTUAL_DOC_TEMP_DIRECTORY, deleteDocument, quartoVdocDir } from "../vdoc/vdoc-tempfile"; @@ -22,8 +21,6 @@ function createTestManager(disposables: DisposableStore, timeoutMs?: number) { suite("Diagnostics", function () { const disposables = new DisposableStore(); - /** Test docs to be deleted during teardown. See the note on {@link openExampleTextDocument} */ - const toDelete: vscode.TextDocument[] = []; /** All vdoc URIs created during tests, to check for leaks during teardown. */ const vdocUris: vscode.Uri[] = []; let client: LanguageClient; @@ -44,7 +41,6 @@ suite("Diagnostics", function () { teardown(async function () { disposables.clear(); - await Promise.all(toDelete.map(doc => deleteDocument(doc))); await client.stop(); await vscode.commands.executeCommand("workbench.action.closeAllEditors"); @@ -64,7 +60,7 @@ suite("Diagnostics", function () { }); test("receives diagnostics in the .qmd for embedded languages", async function () { - const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd", toDelete); + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd", disposables); assert.strictEqual(event.diagnostics.length, 1, "Expected one diagnostic"); assert.strictEqual( @@ -80,7 +76,7 @@ suite("Diagnostics", function () { }); test("updates diagnostics when .qmd edited", async function () { - const { uri, event, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-none.qmd", toDelete); + const { uri, event, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-none.qmd", disposables); assert.strictEqual( event.diagnostics.length, @@ -106,14 +102,13 @@ suite("Diagnostics", function () { test("receives diagnostics for multiple languages independently", async function () { this.timeout(15000); - const doc = await openExampleTextDocument("diagnostics-multilang.qmd", toDelete); - const uri = doc.uri; + const doc = await openUniqueExamplesDocument("diagnostics-multilang.qmd", disposables); // Subscribe before showing so we don't miss events fired during document open. const events: DidUpdateDiagnosticsEvent[] = []; const gotBoth = new Promise((resolve) => { const listener = manager.onDidUpdateDiagnostics((e) => { - if (isUriEqual(e.documentUri, uri)) { + if (isUriEqual(e.documentUri, doc.uri)) { events.push(e); if (events.length >= 2) { listener.dispose(); @@ -129,17 +124,15 @@ suite("Diagnostics", function () { assert.strictEqual(result, true, "Timed out waiting for multi-language diagnostics"); // The final published diagnostics should contain entries from both languages. - const finalDiagnostics = vscode.languages.getDiagnostics(uri); - assert.ok( - finalDiagnostics.length >= 2, - `Expected at least 2 diagnostics (one per language), got ${finalDiagnostics.length}` - ); + assert.ok(events.length === 2, `Expected 2 diagnostics events (one per language), got ${events.length}`); + assert.ok(events[0].diagnostics.length === 1, "Expected one diagnostic when the first language's diagnostics are received"); + assert.ok(events[1].diagnostics.length === 2, "Expected two diagnostics when the second language's diagnostics are received"); }); test("times out for unresponsive language servers without blocking others", async function () { // Julia has no language server registered in tests, so it will time out. // Python should still get its diagnostics independently. - const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-timeout.qmd", toDelete); + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-timeout.qmd", disposables); // Python diagnostics should be present despite Julia timing out. assert.ok( @@ -154,7 +147,7 @@ suite("Diagnostics", function () { test("cleans up vdoc after timeout when language server does not respond", async function () { const shortTimeoutManager = createTestManager(disposables, 200); - const doc = await openExampleTextDocument("diagnostics-julia-only.qmd", toDelete); + const doc = await openUniqueExamplesDocument("diagnostics-julia-only.qmd", disposables); const disposal = nextVdocDisposal(shortTimeoutManager, "timeout", "julia"); await vscode.window.showTextDocument(doc); @@ -169,14 +162,15 @@ suite("Diagnostics", function () { test("clears stale diagnostics after timeout", async function () { const shortTimeoutManager = createTestManager(disposables, 200); - const { uri, doc } = await openAndAwaitDiagnostics( - shortTimeoutManager, "diagnostics-timeout.qmd", toDelete + const { uri, doc, event } = await openAndAwaitDiagnostics( + shortTimeoutManager, "diagnostics-timeout.qmd", disposables ); - assert.ok(vscode.languages.getDiagnostics(uri).length >= 1, "Should have Python diagnostics initially"); + assert.ok(event.diagnostics.length >= 1, "Should have Python diagnostics initially"); // Wait for the initial Julia timeout before editing, // otherwise nextDiagnostics catches that event instead. - await raceTimeout(nextVdocDisposal(shortTimeoutManager, "timeout", "julia"), 2000); + const disposal = await raceTimeout(nextVdocDisposal(shortTimeoutManager, "timeout", "julia"), 2000); + assert.ok(disposal, "Expected Julia vdoc to be disposed via timeout before editing"); // Delete the Python cell, keeping only Julia (which will timeout). const cleared = nextDiagnostics(shortTimeoutManager, uri); @@ -190,14 +184,15 @@ suite("Diagnostics", function () { ); }); - const event = await raceTimeout(cleared, 3000); - assert.ok(event, "Expected diagnostics update after timeout"); - assert.strictEqual(event.diagnostics.length, 0, - "Stale Python diagnostics should be cleared after Julia-only timeout"); + const clearedEvent = await raceTimeout(cleared, 3000); + assert.ok(clearedEvent, "Expected diagnostics update after timeout"); + assert.strictEqual(clearedEvent.diagnostics.length, 0, + "Stale Python diagnostics should be cleared after Julia-only timeout, got:\n" + + JSON.stringify(clearedEvent.diagnostics)); }); test("clears diagnostics when error is fixed", async function () { - const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd", toDelete); + const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd", disposables); const cleared = nextDiagnostics(manager, uri); const editor = await vscode.window.showTextDocument(doc); @@ -216,7 +211,7 @@ suite("Diagnostics", function () { }); test("cleans up vdoc after diagnostics are received", async function () { - const doc = await openExampleTextDocument("diagnostics-python-undefined.qmd", toDelete); + const doc = await openUniqueExamplesDocument("diagnostics-python-undefined.qmd", disposables); const disposal = nextVdocDisposal(manager, "diagnostics-received", "python"); await vscode.window.showTextDocument(doc); @@ -230,7 +225,7 @@ suite("Diagnostics", function () { test("cleans up vdoc when document is closed", async function () { // Julia (no LS in tests) so the vdoc stays alive long enough to be // disposed by closing the document rather than by receiving diagnostics. - const doc = await openExampleTextDocument("diagnostics-julia-only.qmd", toDelete); + const doc = await openUniqueExamplesDocument("diagnostics-julia-only.qmd", disposables); const disposal = nextVdocDisposal(manager, "session-removed", "julia"); await vscode.window.showTextDocument(doc); @@ -245,7 +240,7 @@ suite("Diagnostics", function () { }); test("reports diagnostics from multiple cells of the same language", async function () { - const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-multicell.qmd", toDelete); + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-multicell.qmd", disposables); const diagnostics = event.diagnostics; assert.strictEqual(diagnostics.length, 2, "Expected one diagnostic per cell"); @@ -259,7 +254,7 @@ suite("Diagnostics", function () { }); test("maps diagnostic line numbers correctly with content above cell", async function () { - const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd", toDelete); + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd", disposables); const diagnostics = event.diagnostics; assert.strictEqual(diagnostics.length, 1, "Expected one diagnostic"); @@ -271,7 +266,7 @@ suite("Diagnostics", function () { }); test("clears diagnostics when all executable cells are removed", async function () { - const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd", toDelete); + const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd", disposables); // Remove the entire code cell, leaving only markdown. const cleared = nextDiagnostics(manager, uri); @@ -294,7 +289,7 @@ suite("Diagnostics", function () { }); test("clears diagnostics when document is closed", async function () { - const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd", toDelete); + const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd", disposables); const cleared = nextDiagnostics(manager, uri); await deleteDocument(doc); @@ -310,7 +305,7 @@ suite("Diagnostics", function () { suite("vdoc location", () => { test("places typescript vdoc in local directory", async function () { - const { uri, event } = await openAndAwaitVdocActivation(manager, "diagnostics-typescript.qmd", "ts", toDelete); + const { uri, event } = await openAndAwaitVdocActivation(manager, "diagnostics-typescript.qmd", "ts", disposables); const expectedDir = quartoVdocDir(uri.fsPath); assert.ok( event.uri.fsPath.startsWith(expectedDir), @@ -319,7 +314,7 @@ suite("Diagnostics", function () { }); test("places python vdoc in global temp directory", async function () { - const { event } = await openAndAwaitVdocActivation(manager, "diagnostics-python-undefined.qmd", "python", toDelete); + const { event } = await openAndAwaitVdocActivation(manager, "diagnostics-python-undefined.qmd", "python", disposables); assert.ok( event.uri.fsPath.startsWith(VIRTUAL_DOC_TEMP_DIRECTORY), `Expected Python vdoc in global temp dir (${VIRTUAL_DOC_TEMP_DIRECTORY}), got ${event.uri.fsPath}` @@ -332,7 +327,7 @@ suite("Diagnostics", function () { // code, tags, and relatedInformation (one URI pointing at the vdoc file // itself, one pointing at an external path). test("maps all diagnostic fields from the language server", async function () { - const { uri, event } = await openAndAwaitDiagnostics(manager, "diagnostics-rich.qmd", toDelete); + const { uri, event } = await openAndAwaitDiagnostics(manager, "diagnostics-rich.qmd", disposables); const rich = event.diagnostics.find(d => d.message.includes("rich diagnostic")); assert.ok(rich, "Expected a rich diagnostic"); @@ -367,26 +362,6 @@ suite("Diagnostics", function () { }); }); -/** - * Copy a fixture file to a unique URI and open it. - * - * VS Code keeps text documents in memory even after their editors are closed, - * so a fixture opened by one test remains in `workspace.textDocuments` for - * subsequent tests. When `EmbeddedDiagnosticsManager` is constructed it - * notifies the language server about all already-open documents. - * - * Copying to a fresh URI guarantees the document has never been seen before, - * and lets us delete it to fire onDidCloseTextDocument events. - */ -async function openExampleTextDocument(fixture: string, toDelete: vscode.TextDocument[]): Promise { - const source = examplesUri(fixture); - const dest = Uri.joinPath(source, "..", `tmp-${randomUUID()}-${fixture}`); - await vscode.workspace.fs.copy(source, dest); - const doc = await vscode.workspace.openTextDocument(dest); - toDelete.push(doc); - return doc; -} - function isUriEqual(a: vscode.Uri, b: vscode.Uri) { return a.toString() === b.toString(); } @@ -440,8 +415,8 @@ function nextVdocActivation( } /** Open a .qmd fixture and wait for its first diagnostics event. */ -async function openAndAwaitDiagnostics(manager: EmbeddedDiagnosticsManager, fixture: string, toDelete: vscode.TextDocument[]) { - const doc = await openExampleTextDocument(fixture, toDelete); +async function openAndAwaitDiagnostics(manager: EmbeddedDiagnosticsManager, fixture: string, disposables: DisposableStore) { + const doc = await openUniqueExamplesDocument(fixture, disposables); const diagnostics = nextDiagnostics(manager, doc.uri); await vscode.window.showTextDocument(doc); const event = await raceTimeout(diagnostics, 4000); @@ -452,8 +427,8 @@ async function openAndAwaitDiagnostics(manager: EmbeddedDiagnosticsManager, fixt } /** Open a .qmd fixture and wait for its virtual document to activate for a given language. */ -async function openAndAwaitVdocActivation(manager: EmbeddedDiagnosticsManager, fixture: string, language: string, toDelete: vscode.TextDocument[]) { - const doc = await openExampleTextDocument(fixture, toDelete); +async function openAndAwaitVdocActivation(manager: EmbeddedDiagnosticsManager, fixture: string, language: string, disposables: DisposableStore) { + const doc = await openUniqueExamplesDocument(fixture, disposables); const activation = nextVdocActivation(manager, doc.uri, language); await vscode.window.showTextDocument(doc); const event = await raceTimeout(activation, 4000); diff --git a/apps/vscode/src/test/test-utils.ts b/apps/vscode/src/test/test-utils.ts index 3fc0918c..b701f58b 100644 --- a/apps/vscode/src/test/test-utils.ts +++ b/apps/vscode/src/test/test-utils.ts @@ -1,6 +1,8 @@ +import { DisposableStore } from "core"; import * as fs from "fs"; import * as path from "path"; import * as vscode from "vscode"; +import { deleteDocument } from "../vdoc/vdoc-tempfile"; /** @@ -51,8 +53,8 @@ export async function openAndShowUri( } /** - * Opens a unique on-disk copy of an example file and returns it along with a - * `cleanup` function that deletes the copy. + * Creates a unique on-disk copy of an example file and registers a callback + * with a disposable store to delete the copy on dispose. * * Use this instead of `openAndShowExamplesTextDocument` when a test exercises a * provider command that caches results per document URI, such as @@ -64,22 +66,30 @@ export async function openAndShowUri( * actually runs. * * The copy is created alongside the original example file so workspace-relative - * behavior (LSP, configuration) is preserved. Always call `cleanup()` in a - * `finally` block. + * behavior (LSP, configuration) is preserved. Always dispose `disposables` in + * the `teardown` hook. */ -export async function openUniqueExampleDocument(fileName: string) { - const sourcePath = path.join(WORKSPACE_PATH, fileName); +export async function openAndShowUniqueExamplesDocument(fileName: string, disposables: DisposableStore) { + const doc = await openUniqueExamplesDocument(fileName, disposables); + const editor = await vscode.window.showTextDocument(doc); + return { doc, editor }; +} + +export async function openUniqueExamplesDocument(fileName: string, disposables: DisposableStore) { + const sourceUri = examplesUri(fileName); const extension = path.extname(fileName); const uniqueName = `${path.basename(fileName, extension)}-${Date.now()}-${Math.random().toString(36).slice(2)}${extension}`; - const uniquePath = path.join(path.dirname(sourcePath), uniqueName); - fs.copyFileSync(sourcePath, uniquePath); - - const { doc, editor } = await openAndShowUri(vscode.Uri.file(uniquePath)); - return { - doc, - editor, - cleanup: () => fs.rmSync(uniquePath, { force: true }), - }; + const uniqueUri = vscode.Uri.joinPath(sourceUri, "..", uniqueName); + + /** + * Ensure that the copy is deleted on dispose (usually, on test `teardown`). + * See the notes in {@link deleteDocument} for why we have to use that function. + */ + disposables.add({ dispose: () => deleteDocument(doc) }); + + fs.copyFileSync(sourceUri.fsPath, uniqueUri.fsPath); + const doc = await vscode.workspace.openTextDocument(uniqueUri); + return doc; } export const APPROX_TIME_TO_OPEN_VISUAL_EDITOR = 1700;