diff --git a/assets/test/documentation-live-preview/Package.swift b/assets/test/documentation-live-preview/Package.swift new file mode 100644 index 000000000..82f5c8a15 --- /dev/null +++ b/assets/test/documentation-live-preview/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "documentation-live-preview", + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "Library", + targets: ["Library"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "Library"), + ] +) diff --git a/assets/test/documentation-live-preview/Sources/Library/Library.docc/GettingStarted.md b/assets/test/documentation-live-preview/Sources/Library/Library.docc/GettingStarted.md new file mode 100644 index 000000000..0de13a7cf --- /dev/null +++ b/assets/test/documentation-live-preview/Sources/Library/Library.docc/GettingStarted.md @@ -0,0 +1,3 @@ +# Getting Started + +This is the getting started page. \ No newline at end of file diff --git a/assets/test/documentation-live-preview/Sources/Library/Library.docc/Tutorial.tutorial b/assets/test/documentation-live-preview/Sources/Library/Library.docc/Tutorial.tutorial new file mode 100644 index 000000000..00f6ffb13 --- /dev/null +++ b/assets/test/documentation-live-preview/Sources/Library/Library.docc/Tutorial.tutorial @@ -0,0 +1,5 @@ +@Tutorial(time: 30) { + @Intro(title: "Library") { + Library Tutorial + } +} diff --git a/assets/test/documentation-live-preview/Sources/Library/Library.docc/TutorialOverview.tutorial b/assets/test/documentation-live-preview/Sources/Library/Library.docc/TutorialOverview.tutorial new file mode 100644 index 000000000..3cef7bbde --- /dev/null +++ b/assets/test/documentation-live-preview/Sources/Library/Library.docc/TutorialOverview.tutorial @@ -0,0 +1,5 @@ +@Tutorials(name: "SlothCreator") { + @Intro(title: "Meet Library") { + Library Tutorial Overview + } +} diff --git a/assets/test/documentation-live-preview/Sources/Library/Library.swift b/assets/test/documentation-live-preview/Sources/Library/Library.swift new file mode 100644 index 000000000..2f9e68f77 --- /dev/null +++ b/assets/test/documentation-live-preview/Sources/Library/Library.swift @@ -0,0 +1,16 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +/// The entry point for this arbitrary library. +/// +/// Used for testing the Documentation Live Preview. +public struct EntryPoint { + /// The name of this EntryPoint + public let name: String + + /// Creates a new EntryPoint + /// - Parameter name: the name of this entry point + public init(name: String) { + self.name = name + } +} \ No newline at end of file diff --git a/assets/test/documentation-live-preview/UnsupportedFile.txt b/assets/test/documentation-live-preview/UnsupportedFile.txt new file mode 100644 index 000000000..c18a55cae --- /dev/null +++ b/assets/test/documentation-live-preview/UnsupportedFile.txt @@ -0,0 +1 @@ +Used to test Live Preview with an unsupported file. \ No newline at end of file diff --git a/src/documentation/DocumentationPreviewEditor.ts b/src/documentation/DocumentationPreviewEditor.ts index 2b2a8fb2f..5e24d4fe2 100644 --- a/src/documentation/DocumentationPreviewEditor.ts +++ b/src/documentation/DocumentationPreviewEditor.ts @@ -98,6 +98,7 @@ export class DocumentationPreviewEditor implements vscode.Disposable { private activeTextEditor?: vscode.TextEditor; private activeTextEditorSelection?: vscode.Selection; private subscriptions: vscode.Disposable[] = []; + private isDisposed: boolean = false; private disposeEmitter = new vscode.EventEmitter(); private renderEmitter = new vscode.EventEmitter(); @@ -133,6 +134,7 @@ export class DocumentationPreviewEditor implements vscode.Disposable { } dispose() { + this.isDisposed = true; this.subscriptions.forEach(subscription => subscription.dispose()); this.subscriptions = []; this.webviewPanel.dispose(); @@ -140,6 +142,9 @@ export class DocumentationPreviewEditor implements vscode.Disposable { } private postMessage(message: WebviewMessage) { + if (this.isDisposed) { + return; + } if (message.type === "update-content") { this.updateContentEmitter.fire(message.content); } diff --git a/test/integration-tests/documentation/DocumentationLivePreview.test.ts b/test/integration-tests/documentation/DocumentationLivePreview.test.ts new file mode 100644 index 000000000..a196d46ad --- /dev/null +++ b/test/integration-tests/documentation/DocumentationLivePreview.test.ts @@ -0,0 +1,225 @@ +//===----------------------------------------------------------------------===// +// +// 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 * as path from "path"; +import contextKeys from "../../../src/contextKeys"; +import { expect } from "chai"; +import { activateExtensionForSuite, folderInRootWorkspace } from "../utilities/testutilities"; +import { waitForNoRunningTasks } from "../../utilities/tasks"; +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 { + RenderNodeContent, + WebviewContent, +} from "../../../src/documentation/webview/WebviewMessage"; +import { PreviewEditorConstant } from "../../../src/documentation/DocumentationPreviewEditor"; + +suite("Documentation Live 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; + + activateExtensionForSuite({ + async setup(ctx) { + workspaceContext = ctx; + await waitForNoRunningTasks(); + folderContext = await folderInRootWorkspace("documentation-live-preview", ctx); + await ctx.focusFolder(folderContext); + }, + }); + + setup(function () { + if (!contextKeys.supportsDocumentationLivePreview) { + this.skip(); + } + }); + + teardown(async function () { + await vscode.commands.executeCommand(Workbench.ACTION_CLOSEALLEDITORS); + }); + + test("renders documentation for an opened Swift file", async function () { + const { webviewContent } = await launchLivePreviewEditor(workspaceContext, { + filePath: "Sources/Library/Library.swift", + position: new vscode.Position(0, 0), + }); + expect(renderNodeString(webviewContent)).to.include( + "The entry point for this arbitrary library." + ); + }); + + test("renders documentation when moving the cursor within an opened Swift file", async function () { + const { textEditor } = await launchLivePreviewEditor(workspaceContext, { + filePath: "Sources/Library/Library.swift", + position: new vscode.Position(0, 0), + }); + // Move the cursor to the comment above EntryPoint.name + let webviewContent = await moveCursor(workspaceContext, { + textEditor, + position: new vscode.Position(7, 12), + }); + expect(renderNodeString(webviewContent)).to.include("The name of this EntryPoint"); + // Move the cursor to the comment above EntryPoint.init(name:) + webviewContent = await moveCursor(workspaceContext, { + textEditor, + position: new vscode.Position(10, 18), + }); + expect(renderNodeString(webviewContent)).to.include("Creates a new EntryPoint"); + }); + + test("renders documentation when editing an opened Swift file", async function () { + const { textEditor } = await launchLivePreviewEditor(workspaceContext, { + filePath: "Sources/Library/Library.swift", + position: new vscode.Position(0, 0), + }); + // Edit the comment above EntryPoint + const webviewContent = await editDocument(workspaceContext, textEditor, editBuilder => { + editBuilder.replace(new vscode.Selection(3, 29, 3, 38), "absolutely amazing"); + }); + expect(renderNodeString(webviewContent)).to.include( + "The entry point for this absolutely amazing library." + ); + }); + + test("renders documentation for an opened Markdown article", async function () { + const { webviewContent } = await launchLivePreviewEditor(workspaceContext, { + filePath: "Sources/Library/Library.docc/GettingStarted.md", + position: new vscode.Position(0, 0), + }); + expect(renderNodeString(webviewContent)).to.include("This is the getting started page."); + }); + + test("renders documentation for an opened tutorial overview", async function () { + const { webviewContent } = await launchLivePreviewEditor(workspaceContext, { + filePath: "Sources/Library/Library.docc/TutorialOverview.tutorial", + position: new vscode.Position(0, 0), + }); + expect(renderNodeString(webviewContent)).to.include("Library Tutorial Overview"); + }); + + test("renders documentation for an opened tutorial", async function () { + const { webviewContent } = await launchLivePreviewEditor(workspaceContext, { + filePath: "Sources/Library/Library.docc/Tutorial.tutorial", + position: new vscode.Position(0, 0), + }); + expect(renderNodeString(webviewContent)).to.include("Library Tutorial"); + }); + + test("displays an error for an unsupported active document", async function () { + const { webviewContent } = await launchLivePreviewEditor(workspaceContext, { + filePath: "UnsupportedFile.txt", + position: new vscode.Position(0, 0), + }); + expect(webviewContent).to.have.property("type").that.equals("error"); + expect(webviewContent) + .to.have.property("errorMessage") + .that.equals(PreviewEditorConstant.UNSUPPORTED_EDITOR_ERROR_MESSAGE); + }); +}); + +async function launchLivePreviewEditor( + workspaceContext: WorkspaceContext, + options: { + filePath: string; + position: vscode.Position; + } +): Promise<{ textEditor: vscode.TextEditor; webviewContent: WebviewContent }> { + if (findTab(PreviewEditorConstant.VIEW_TYPE, PreviewEditorConstant.TITLE)) { + throw new Error("The live preview editor cannot be launched twice in a single test"); + } + const contentUpdatePromise = waitForNextContentUpdate(workspaceContext); + const renderedPromise = waitForNextRender(workspaceContext); + // Open up the test file before launching live preview + const fileUri = testAssetUri(path.join("documentation-live-preview", options.filePath)); + const selection = new vscode.Selection(options.position, options.position); + const textEditor = await vscode.window.showTextDocument(fileUri, { selection: selection }); + // Launch the documentation preview and wait for it to render + expect(await vscode.commands.executeCommand(Commands.PREVIEW_DOCUMENTATION)).to.be.true; + const [webviewContent] = await Promise.all([contentUpdatePromise, renderedPromise]); + return { textEditor, webviewContent }; +} + +async function editDocument( + workspaceContext: WorkspaceContext, + textEditor: vscode.TextEditor, + callback: (editBuilder: vscode.TextEditorEdit) => void +): Promise { + const contentUpdatePromise = waitForNextContentUpdate(workspaceContext); + const renderedPromise = waitForNextRender(workspaceContext); + await expect(textEditor.edit(callback)).to.eventually.be.true; + const [webviewContent] = await Promise.all([contentUpdatePromise, renderedPromise]); + return webviewContent; +} + +async function moveCursor( + workspaceContext: WorkspaceContext, + options: { + textEditor: vscode.TextEditor; + position: vscode.Position; + } +): Promise { + const contentUpdatePromise = waitForNextContentUpdate(workspaceContext); + const renderedPromise = waitForNextRender(workspaceContext); + options.textEditor.selection = new vscode.Selection(options.position, options.position); + const [webviewContent] = await Promise.all([contentUpdatePromise, renderedPromise]); + return webviewContent; +} + +function renderNodeString(webviewContent: WebviewContent): string { + expect(webviewContent).to.have.property("type").that.equals("render-node"); + return JSON.stringify((webviewContent as RenderNodeContent).renderNode); +} + +function waitForNextContentUpdate(context: WorkspaceContext): Promise { + return new Promise(resolve => { + const disposable = context.documentation.onPreviewDidUpdateContent( + (content: WebviewContent) => { + resolve(content); + disposable.dispose(); + } + ); + }); +} + +function waitForNextRender(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; +}