diff --git a/src/documentation/DocumentationManager.ts b/src/documentation/DocumentationManager.ts index 920a5923e..ab95f90f8 100644 --- a/src/documentation/DocumentationManager.ts +++ b/src/documentation/DocumentationManager.ts @@ -21,6 +21,7 @@ import contextKeys from "../contextKeys"; export class DocumentationManager implements vscode.Disposable { private previewEditor?: DocumentationPreviewEditor; private editorUpdatedContentEmitter = new vscode.EventEmitter(); + private editorRenderedEmitter = new vscode.EventEmitter(); constructor( private readonly extension: vscode.ExtensionContext, @@ -28,6 +29,7 @@ export class DocumentationManager implements vscode.Disposable { ) {} onPreviewDidUpdateContent = this.editorUpdatedContentEmitter.event; + onPreviewDidRenderContent = this.editorRenderedEmitter.event; async launchDocumentationPreview(): Promise { if (!contextKeys.supportsDocumentationRendering) { @@ -45,6 +47,9 @@ export class DocumentationManager implements vscode.Disposable { this.previewEditor.onDidUpdateContent(content => { this.editorUpdatedContentEmitter.fire(content); }), + this.previewEditor.onDidRenderContent(() => { + this.editorRenderedEmitter.fire(); + }), this.previewEditor.onDidDispose(() => { subscriptions.forEach(d => d.dispose()); this.previewEditor = undefined; diff --git a/src/documentation/DocumentationPreviewEditor.ts b/src/documentation/DocumentationPreviewEditor.ts index 350678a67..83d51314a 100644 --- a/src/documentation/DocumentationPreviewEditor.ts +++ b/src/documentation/DocumentationPreviewEditor.ts @@ -19,11 +19,17 @@ import { RenderNode, WebviewMessage } from "./webview/WebviewMessage"; import { WorkspaceContext } from "../WorkspaceContext"; import { RenderDocumentationRequest } from "../sourcekit-lsp/extensions/RenderDocumentationRequest"; +export enum PreviewEditorConstant { + VIEW_TYPE = "swift.previewDocumentationEditor", + TITLE = "Preview Swift Documentation", +} + export class DocumentationPreviewEditor implements vscode.Disposable { private readonly webviewPanel: vscode.WebviewPanel; private subscriptions: vscode.Disposable[] = []; private disposeEmitter = new vscode.EventEmitter(); + private renderEmitter = new vscode.EventEmitter(); private updateContentEmitter = new vscode.EventEmitter(); constructor( @@ -33,8 +39,8 @@ export class DocumentationPreviewEditor implements vscode.Disposable { const swiftDoccRenderPath = this.extension.asAbsolutePath("assets/swift-docc-render"); // Create and hook up events for the WebviewPanel this.webviewPanel = vscode.window.createWebviewPanel( - "swift.previewDocumentationEditor", - "Preview Swift Documentation", + PreviewEditorConstant.VIEW_TYPE, + PreviewEditorConstant.TITLE, { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, { enableScripts: true, @@ -59,12 +65,12 @@ export class DocumentationPreviewEditor implements vscode.Disposable { this.webviewPanel.webview.html = documentationHTML; this.subscriptions.push( this.webviewPanel.webview.onDidReceiveMessage(this.receiveMessage.bind(this)), - vscode.window.onDidChangeActiveTextEditor(editor => - this.renderDocumentation(editor) - ), - vscode.window.onDidChangeTextEditorSelection(event => - this.renderDocumentation(event.textEditor) - ), + vscode.window.onDidChangeActiveTextEditor(editor => { + this.renderDocumentation(editor); + }), + vscode.window.onDidChangeTextEditorSelection(event => { + this.renderDocumentation(event.textEditor); + }), this.webviewPanel.onDidDispose(this.dispose.bind(this)) ); // Reveal the editor, but don't change the focus of the active text editor @@ -79,6 +85,9 @@ export class DocumentationPreviewEditor implements vscode.Disposable { /** An event that is fired when the Documentation Preview Editor updates its content */ onDidUpdateContent = this.updateContentEmitter.event; + /** An event that is fired when the Documentation Preview Editor renders its content */ + onDidRenderContent = this.renderEmitter.event; + reveal() { this.webviewPanel.reveal(); } @@ -102,6 +111,9 @@ export class DocumentationPreviewEditor implements vscode.Disposable { case "loaded": this.renderDocumentation(vscode.window.activeTextEditor); break; + case "rendered": + this.renderEmitter.fire(); + break; } } diff --git a/src/utilities/commands.ts b/src/utilities/commands.ts index cc994f76c..e8a6cf2b4 100644 --- a/src/utilities/commands.ts +++ b/src/utilities/commands.ts @@ -16,4 +16,5 @@ export enum Workbench { ACTION_DEBUG_CONTINUE = "workbench.action.debug.continue", ACTION_CLOSEALLEDITORS = "workbench.action.closeAllEditors", ACTION_RELOADWINDOW = "workbench.action.reloadWindow", + ACTION_PREVIOUSEDITORINGROUP = "workbench.action.previousEditorInGroup", } diff --git a/test/integration-tests/documentation/DocumentationManager.test.ts b/test/integration-tests/documentation/DocumentationManager.test.ts deleted file mode 100644 index a7b276a89..000000000 --- a/test/integration-tests/documentation/DocumentationManager.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the VS Code Swift open source project -// -// Copyright (c) 2024 the VS Code Swift project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of VS Code Swift project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import * as vscode from "vscode"; -import { expect } from "chai"; -import { folderContextPromise, globalWorkspaceContextPromise } from "../extension.test"; -import { waitForNoRunningTasks } from "../../utilities"; -import { testAssetUri } from "../../fixtures"; -import { FolderContext } from "../../../src/FolderContext"; -import { WorkspaceContext } from "../../../src/WorkspaceContext"; -import { Commands } from "../../../src/commands"; -import { Workbench } from "../../../src/utilities/commands"; -import { RenderNode } from "../../../src/documentation/webview/WebviewMessage"; -import contextKeys from "../../../src/contextKeys"; - -suite("Documentation Preview Editor", function () { - this.timeout(5000); // Tests are short, but rely on SourceKit-LSP: give 5 seconds for each one - - let folderContext: FolderContext; - let workspaceContext: WorkspaceContext; - - suiteSetup(async function () { - workspaceContext = await globalWorkspaceContextPromise; - await waitForNoRunningTasks(); - folderContext = await folderContextPromise("SlothCreatorExample"); - await workspaceContext.focusFolder(folderContext); - }); - - suiteTeardown(async () => { - await vscode.commands.executeCommand(Workbench.ACTION_CLOSEALLEDITORS); - }); - - test("renders documentation for an opened Swift file", async function () { - if (!contextKeys.supportsDocumentationRendering) { - this.skip(); - return; - } - - // Open a Swift file before we launch the documentation preview - await vscode.window.showTextDocument( - testAssetUri("SlothCreatorExample/Sources/SlothCreator/Models/Sloth.swift") - ); - - // Launch the documentation preview and wait for the content to update - await expect(vscode.commands.executeCommand(Commands.PREVIEW_DOCUMENTATION)).to.eventually - .be.true; - - // Wait for the content to be updated - const renderedContent = await waitForNextContentUpdate(workspaceContext); - const uri = vscode.Uri.parse(renderedContent.identifier.url); - expect(uri.path).to.equal("/documentation/Sloth/Sloth"); - }); -}); - -function waitForNextContentUpdate(context: WorkspaceContext): Promise { - return new Promise(resolve => { - context.documentation.onPreviewDidUpdateContent(resolve); - }); -} diff --git a/test/integration-tests/documentation/DocumentationPreview.test.ts b/test/integration-tests/documentation/DocumentationPreview.test.ts new file mode 100644 index 000000000..64e7d083f --- /dev/null +++ b/test/integration-tests/documentation/DocumentationPreview.test.ts @@ -0,0 +1,347 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2024 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import * as vscode from "vscode"; +import contextKeys from "../../../src/contextKeys"; +import { expect } from "chai"; +import { folderContextPromise, globalWorkspaceContextPromise } from "../extension.test"; +import { waitForNoRunningTasks } from "../../utilities"; +import { testAssetUri } from "../../fixtures"; +import { FolderContext } from "../../../src/FolderContext"; +import { WorkspaceContext } from "../../../src/WorkspaceContext"; +import { Commands } from "../../../src/commands"; +import { Workbench } from "../../../src/utilities/commands"; +import { RenderNode } from "../../../src/documentation/webview/WebviewMessage"; +import { PreviewEditorConstant } from "../../../src/documentation/DocumentationPreviewEditor"; + +suite("Documentation Preview", function () { + // Tests are short, but rely on SourceKit-LSP: give 30 seconds for each one + this.timeout(30 * 1000); + + let folderContext: FolderContext; + let workspaceContext: WorkspaceContext; + + suiteSetup(async function () { + workspaceContext = await globalWorkspaceContextPromise; + await waitForNoRunningTasks(); + folderContext = await folderContextPromise("SlothCreatorExample"); + await workspaceContext.focusFolder(folderContext); + }); + + suiteTeardown(async () => { + await vscode.commands.executeCommand(Workbench.ACTION_CLOSEALLEDITORS); + }); + + setup(function () { + if (!contextKeys.supportsDocumentationRendering) { + this.skip(); + } + }); + + async function editRenderTest( + position: vscode.Position, + expectedEdit: string, + editor: vscode.TextEditor + ) { + // Set up test promise + const contentPromise = waitForNextContentUpdate(workspaceContext); + + // Edit the focused text document, appending expected edit at the end of provided position + await editor.edit(editBuilder => editBuilder.insert(position, expectedEdit)); + + // Update the cursor position to the end of the inserted text + const newCursorPos = new vscode.Position( + position.line, + position.character + expectedEdit.length + ); + editor.selection = new vscode.Selection(newCursorPos, newCursorPos); + + await expect(waitForRender(workspaceContext)).to.eventually.be.true; + const updatedContent = await contentPromise; + const updatedContentString = JSON.stringify(updatedContent, null, 2); + expect(updatedContentString, `${updatedContentString}`).to.include(expectedEdit); + } + + async function initialRenderTest( + uri: string, + expectedContent: string, + editToCheck: string + ): Promise { + // Set up content promise before file set up + const contentPromise = waitForNextContentUpdate(workspaceContext); + + // Open a Swift file before we launch the documentation preview + const swiftFileUri = testAssetUri(uri); + const initPos = new vscode.Position(0, 0); + const editor = await vscode.window.showTextDocument(swiftFileUri, { + selection: new vscode.Selection(initPos, initPos), + }); + + // Check if the webview panel is visible, if running in isolation the preview command has to + // be executed, otherwise we can proceed with the test steps reusing the preview panel + if (!findTab(PreviewEditorConstant.VIEW_TYPE, PreviewEditorConstant.TITLE)) { + // Launch the documentation preview and wait for render to complete + await expect(vscode.commands.executeCommand(Commands.PREVIEW_DOCUMENTATION)).to + .eventually.be.true; + } + await expect(waitForRender(workspaceContext)).to.eventually.be.true; + + // Wait for the test promise to complete + const updatedContent = await contentPromise; + const updatedContentString = JSON.stringify(updatedContent, null, 2); + + // Assert that the content text contain the right content + expect(updatedContentString, `${updatedContentString}`).to.include(expectedContent); + expect(updatedContentString, `${updatedContentString}`).to.not.include(editToCheck); + return editor; + } + + test("renders documentation for an opened Swift file + edit rendering", async function () { + // Check for initial Render + const expectedEdit = "my edit: swift file"; + const editor = await initialRenderTest( + "SlothCreatorExample/Sources/SlothCreator/Models/Sloth.swift", + "A model representing a sloth.", + expectedEdit + ); + + // Set up test promise + let contentPromise = waitForNextContentUpdate(workspaceContext); + const insertPos = new vscode.Position(2, 32); + + // Edit the focused text document, appending expected edit at the end of position + await editor.edit(editBuilder => editBuilder.insert(insertPos, expectedEdit)); + + // Update the cursor position to the end of the inserted text + const newCursorPos = new vscode.Position( + insertPos.line, + insertPos.character + expectedEdit.length + ); + editor.selection = new vscode.Selection(newCursorPos, newCursorPos); + + // FIXME: We are off by 1 right now... so need to do 1 more action + // FIXME: Also the off by 1 behaviour is consistent only if on cached-run (second run and onwards) + await expect(waitForRender(workspaceContext)).to.eventually.be.true; + let updatedContent = await contentPromise; + let updatedContentString = JSON.stringify(updatedContent, null, 2); + expect(updatedContentString, `${updatedContentString}`).to.not.include(expectedEdit); + + // Set up test promise, and change selection which will trigger an update + contentPromise = waitForNextContentUpdate(workspaceContext); + const initPos = new vscode.Position(0, 0); + editor.selection = new vscode.Selection(initPos, initPos); + + // Wait for render and test promise to complete + await expect(waitForRender(workspaceContext)).to.eventually.be.true; + updatedContent = await contentPromise; + updatedContentString = JSON.stringify(updatedContent, null, 2); + expect(updatedContentString, `${updatedContentString}`).to.include(expectedEdit); + }); + + test("Cursor switch: Opened Swift file, documentation to symbol, symbol edit rendering", async function () { + // Check for initial Render + const expectedSymbol = "comfortLevel"; + const editor = await initialRenderTest( + "SlothCreatorExample/Sources/SlothCreator/Models/Habitat.swift", + "The habitat where sloths live.", + expectedSymbol + ); + + // Set up test promise, and change to a location of a symbol: comfortLevel + const contentPromise = waitForNextContentUpdate(workspaceContext); + const symbolPos = new vscode.Position(25, 15); + editor.selection = new vscode.Selection(symbolPos, symbolPos); + + // Wait for render and test promise to complete + await expect(waitForRender(workspaceContext)).to.eventually.be.true; + const updatedContent = await contentPromise; + const updatedContentString = JSON.stringify(updatedContent, null, 2); + expect(updatedContentString, `${updatedContentString}`).to.include(expectedSymbol); + + // Insert edit at the desired position and assert for change: comfortLevel symbol + await editRenderTest(new vscode.Position(25, 27), "Atlantis", editor); + }); + + test("renders documentation for a tutorial overview file + edit rendering", async function () { + // Check for initial Render + const expectedEdit = "my edit: tutorial overview"; + const editor = await initialRenderTest( + "SlothCreatorExample/Sources/SlothCreator/SlothCreator.docc/Tutorials/SlothCreator.tutorial", + "Meet SlothCreator", + expectedEdit + ); + + // Insert edit at the desired position and assert for change + await editRenderTest(new vscode.Position(2, 128), expectedEdit, editor); + }); + + test("renders documentation for a single tutorial file + edit rendering", async function () { + // Check for initial Render + const expectedEdit = "my edit: single tutorial"; + const editor = await initialRenderTest( + "SlothCreatorExample/Sources/SlothCreator/SlothCreator.docc/Tutorials/Creating Custom Sloths.tutorial", + "Creating Custom Sloths", + expectedEdit + ); + + // Insert edit at the desired position and assert for change + await editRenderTest(new vscode.Position(2, 109), expectedEdit, editor); + }); + + test("renders documentation for a generic markdown file + edit rendering", async function () { + // Check for initial Render + const expectedEdit = "my edit: generic markdown"; + const editor = await initialRenderTest( + "SlothCreatorExample/Sources/SlothCreator/SlothCreator.docc/GettingStarted.md", + "Getting Started with Sloths", + expectedEdit + ); + + // Insert edit at the desired position and assert for change + await editRenderTest(new vscode.Position(2, 25), expectedEdit, editor); + }); + + test("renders documentation for a symbol linkage markdown file + edit rendering", async function () { + // FIXME: This feature is not implemented yet + this.skip(); + // Check for initial Render + const expectedEdit = "my edit: symbol linkage markdown"; + const editor = await initialRenderTest( + "SlothCreatorExample/Sources/SlothCreator/SlothCreator.docc/SlothCreator.md", + "Catalog sloths you find", + expectedEdit + ); + + // Insert edit at the desired position and assert for change + await editRenderTest(new vscode.Position(2, 33), expectedEdit, editor); + }); + + test("renders documentation for a symbol providing markdown file + edit rendering", async function () { + // FIXME: This feature is not implemented yet + this.skip(); + // Check for initial Render + const expectedEdit = "my edit: symbol providing markdown"; + const editor = await initialRenderTest( + "SlothCreatorExample/Sources/SlothCreator/SlothCreator.docc/Extensions/Sloth.md", + "Creating a Sloth", + expectedEdit + ); + + // Insert edit at the desired position and assert for change + await editRenderTest(new vscode.Position(4, 14), expectedEdit, editor); + }); + + test("Focus switch: visible tab", async function () { + // Check for initial Render + const contentInTab2 = "Creating Custom Sloths"; + const expectedContent = "Meet SlothCreator"; + await initialRenderTest( + "SlothCreatorExample/Sources/SlothCreator/SlothCreator.docc/Tutorials/SlothCreator.tutorial", + expectedContent, + contentInTab2 + ); + + // Open tab 2 in the same tab group as the webview renderer + const webviewTabNullable = findTab( + PreviewEditorConstant.VIEW_TYPE, + PreviewEditorConstant.TITLE + ); + expect(webviewTabNullable).to.not.be.undefined; + const webviewTab = webviewTabNullable!; + const newTutorialUri = testAssetUri( + "SlothCreatorExample/Sources/SlothCreator/SlothCreator.docc/Tutorials/Creating Custom Sloths.tutorial" + ); + await vscode.window.showTextDocument(newTutorialUri, { + viewColumn: webviewTab.group.viewColumn, + }); + + // Set up test promise, and swap back to the previous editor (webview panel) + const contentPromise = waitForNextContentUpdate(workspaceContext); + await vscode.commands.executeCommand(Workbench.ACTION_PREVIOUSEDITORINGROUP); + + // Wait for render and assert webview panel retains render of last focused editor when the panel is visible + await expect(waitForRender(workspaceContext)).to.eventually.be.true; + const updatedContent = await contentPromise; + const updatedContentString = JSON.stringify(updatedContent, null, 2); + // FIXME: This feature is not implemented yet + expect(updatedContentString, `${updatedContentString}`).to.include(expectedContent); + }); + + test("Focus switch: Swift extension", async function () { + // FIXME: This feature is not implemented yet + this.skip(); + // Check for initial Render + const extensionContent = "Food that a sloth can consume"; + await initialRenderTest( + "SlothCreatorExample/Sources/SlothCreator/Models/Sloth.swift", + "A model representing a sloth.", + extensionContent + ); + + // Set up test promise, and open an extension Swift file + const contentPromise = waitForNextContentUpdate(workspaceContext); + const extensionUri = testAssetUri( + "SlothCreatorExample/Sources/SlothCreator/Models/Food.swift" + ); + const initPos = new vscode.Position(0, 0); + await vscode.window.showTextDocument(extensionUri, { + selection: new vscode.Selection(initPos, initPos), + }); + + // Wait for render and assert webview panel to displayed that no documentation is available + await expect(waitForRender(workspaceContext)).to.eventually.be.true; + const updatedContent = await contentPromise; + const updatedContentString = JSON.stringify(updatedContent, null, 2); + expect(updatedContentString, `${updatedContentString}`).to.include( + "Documentation is not available." + ); + }); +}); + +function waitForNextContentUpdate(context: WorkspaceContext): Promise { + return new Promise(resolve => { + const disposable = context.documentation.onPreviewDidUpdateContent( + (renderNode: RenderNode) => { + resolve(renderNode); + disposable.dispose(); + } + ); + }); +} + +function waitForRender(context: WorkspaceContext): Promise { + return new Promise(resolve => { + const disposable = context.documentation.onPreviewDidRenderContent(() => { + resolve(true); + disposable.dispose(); + }); + }); +} + +function findTab(viewType: string, title: string): vscode.Tab | undefined { + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + // Check if the tab is of type TabInputWebview and matches the viewType and title + if ( + tab.input instanceof vscode.TabInputWebview && + tab.input.viewType.includes(viewType) && + tab.label === title + ) { + // We are not checking if tab is active, so return true as long as the if clause is true + return tab; + } + } + } + return undefined; +}