diff --git a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts index 6d70b8e3c7..24d73103f1 100644 --- a/packages/cursorless-engine/src/api/CursorlessEngineApi.ts +++ b/packages/cursorless-engine/src/api/CursorlessEngineApi.ts @@ -23,6 +23,7 @@ export interface CursorlessEngine { storedTargets: StoredTargetMap; hatTokenMap: HatTokenMap; injectIde: (ide: IDE) => void; + loadLanguage(languageId: string): Promise; addCommandRunnerDecorator: ( commandRunnerDecorator: CommandRunnerDecorator, ) => void; diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 9f63c477f3..84df58d156 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -154,6 +154,7 @@ export async function createCursorlessEngine({ storedTargets, hatTokenMap, injectIde: (ide) => injectedIde.setIde(ide), + loadLanguage: (languageId) => languageDefinitions.loadLanguage(languageId), addCommandRunnerDecorator: (decorator: CommandRunnerDecorator) => { commandRunnerDecorators.push(decorator); }, diff --git a/packages/cursorless-engine/src/languages/LanguageDefinitions.ts b/packages/cursorless-engine/src/languages/LanguageDefinitions.ts index c416eb802c..97af8664f0 100644 --- a/packages/cursorless-engine/src/languages/LanguageDefinitions.ts +++ b/packages/cursorless-engine/src/languages/LanguageDefinitions.ts @@ -122,15 +122,14 @@ export class LanguageDefinitionsImpl implements LanguageDefinitions { return; } - const definition = - (await LanguageDefinition.create( - this.ide, - this.treeSitterQueryProvider, - this.treeSitter, - languageId, - )) ?? LANGUAGE_UNDEFINED; - - this.languageDefinitions.set(languageId, definition); + const definition = await LanguageDefinition.create( + this.ide, + this.treeSitterQueryProvider, + this.treeSitter, + languageId, + ); + + this.languageDefinitions.set(languageId, definition ?? LANGUAGE_UNDEFINED); } private async reloadLanguageDefinitions(): Promise { diff --git a/packages/cursorless-vscode-e2e/src/endToEndTestSetup.ts b/packages/cursorless-vscode-e2e/src/endToEndTestSetup.ts index b1c56b7d69..4d13c122a8 100644 --- a/packages/cursorless-vscode-e2e/src/endToEndTestSetup.ts +++ b/packages/cursorless-vscode-e2e/src/endToEndTestSetup.ts @@ -1,6 +1,9 @@ -import type { IDE } from "@cursorless/common"; +import type { IDE, NormalizedIDE } from "@cursorless/common"; import { shouldUpdateFixtures, sleep, SpyIDE } from "@cursorless/common"; -import { getCursorlessApi } from "@cursorless/vscode-common"; +import { + getCursorlessApi, + resetReusableEditor, +} from "@cursorless/vscode-common"; import type { Context } from "mocha"; import * as sinon from "sinon"; @@ -29,7 +32,7 @@ export function endToEndTestSetup( suite.timeout(timeout); suite.retries(retries); - let originalIde: IDE; + let originalIde: NormalizedIDE; let injectIde: (ide: IDE) => void; let spyIde: SpyIDE | undefined; @@ -50,6 +53,10 @@ export function endToEndTestSetup( injectIde(originalIde); }); + suiteTeardown(() => { + resetReusableEditor(); + }); + return { getSpy() { if (spyIde == null) { diff --git a/packages/cursorless-vscode-e2e/src/suite/intraCellSetSelection.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/intraCellSetSelection.vscode.test.ts index 7b237192b8..ae98c1c1ee 100644 --- a/packages/cursorless-vscode-e2e/src/suite/intraCellSetSelection.vscode.test.ts +++ b/packages/cursorless-vscode-e2e/src/suite/intraCellSetSelection.vscode.test.ts @@ -1,5 +1,6 @@ -import { LATEST_VERSION } from "@cursorless/common"; +import { LATEST_VERSION, splitKey } from "@cursorless/common"; import { + getCellIndex, getCursorlessApi, openNewNotebookEditor, runCursorlessCommand, @@ -16,11 +17,22 @@ suite("Within cell set selection", async function () { }); async function runTest() { - const { hatTokenMap } = (await getCursorlessApi()).testHelpers!; + const { hatTokenMap, toVscodeEditor } = (await getCursorlessApi()) + .testHelpers!; - await openNewNotebookEditor(['"hello world"']); + const notebook = await openNewNotebookEditor(['"hello world"']); await hatTokenMap.allocateHats(); + const hatMap = await hatTokenMap.getReadableMap(false); + const targetHat = hatMap.getEntries().find(([, token]) => { + const editor = toVscodeEditor(token.editor); + return ( + getCellIndex(notebook, editor.document) === 0 && token.text === "world" + ); + }); + + assert(targetHat != null, 'Expected a default hat for "world" in the cell'); + const { hatStyle, character } = splitKey(targetHat[0]); await runCursorlessCommand({ version: LATEST_VERSION, @@ -31,8 +43,8 @@ async function runTest() { type: "primitive", mark: { type: "decoratedSymbol", - symbolColor: "default", - character: "r", + symbolColor: hatStyle, + character, }, }, }, diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts index da342d6b36..9466ff14d4 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/assertCalledWithScopeInfo.ts @@ -1,18 +1,27 @@ -import type { ScopeType, ScopeTypeInfo } from "@cursorless/common"; -import * as sinon from "sinon"; +import { type ScopeType, type ScopeTypeInfo } from "@cursorless/common"; import { assert } from "chai"; -import { sleepWithBackoff } from "../../endToEndTestSetup"; import { isEqual } from "lodash-es"; +import type { SinonSpy } from "sinon"; +import { waitFor } from "../waitFor"; export async function assertCalledWithScopeInfo( - fake: sinon.SinonSpy<[scopeInfos: T[]], void>, + fake: SinonSpy<[scopeInfos: T[]], void>, ...expectedScopeInfos: T[] ) { - await sleepWithBackoff(100); - sinon.assert.called(fake); + const scopeInfos = await waitForScopeInfos(fake, (scopeInfos) => + expectedScopeInfos.every((expectedScopeInfo) => { + const actualScopeInfo = scopeInfos.find((scopeInfo) => + isEqual(scopeInfo.scopeType, expectedScopeInfo.scopeType), + ); + + return ( + actualScopeInfo != null && isEqual(actualScopeInfo, expectedScopeInfo) + ); + }), + ); for (const expectedScopeInfo of expectedScopeInfos) { - const actualScopeInfo = fake.lastCall.args[0].find((scopeInfo) => + const actualScopeInfo = scopeInfos.find((scopeInfo) => isEqual(scopeInfo.scopeType, expectedScopeInfo.scopeType), ); assert.isDefined(actualScopeInfo); @@ -23,19 +32,36 @@ export async function assertCalledWithScopeInfo( } export async function assertCalledWithoutScopeInfo( - fake: sinon.SinonSpy<[scopeInfos: T[]], void>, + fake: SinonSpy<[scopeInfos: T[]], void>, ...scopeTypes: ScopeType[] ) { - await sleepWithBackoff(100); - sinon.assert.called(fake); + const scopeInfos = await waitForScopeInfos(fake, (scopeInfos) => + scopeTypes.every( + (scopeType) => + scopeInfos.find((scopeInfo) => + isEqual(scopeInfo.scopeType, scopeType), + ) == null, + ), + ); for (const scopeType of scopeTypes) { assert.isUndefined( - fake.lastCall.args[0].find((scopeInfo) => - isEqual(scopeInfo.scopeType, scopeType), - ), + scopeInfos.find((scopeInfo) => isEqual(scopeInfo.scopeType, scopeType)), ); } fake.resetHistory(); } + +async function waitForScopeInfos( + fake: SinonSpy<[scopeInfos: T[]], void>, + predicate: (scopeInfos: T[]) => boolean, +): Promise { + const success = await waitFor( + () => fake.called && predicate(fake.lastCall.args[0]), + ); + if (success) { + return fake.lastCall.args[0]; + } + assert.fail("Timed out waiting for expected scope info"); +} diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts index 6b9699bb8c..920d709cbf 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeProvider/runCustomRegexScopeInfoTest.ts @@ -33,10 +33,6 @@ export async function runCustomRegexScopeInfoTest() { await assertCalledWithScopeInfo(fake, unsupported); await openNewEditor(contents); - // The scope provider relies on the open document event (among others) to - // update available scopes. Add a short sleep here to give it time to - // trigger. - await sleep(100); await assertCalledWithScopeInfo(fake, present); await unlink(cursorlessTalonStateJsonPath); diff --git a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/scopeVisualizer.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/scopeVisualizer.vscode.test.ts index eca98b1570..4452ffba4e 100644 --- a/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/scopeVisualizer.vscode.test.ts +++ b/packages/cursorless-vscode-e2e/src/suite/scopeVisualizer/scopeVisualizer.vscode.test.ts @@ -1,14 +1,20 @@ +import { asyncSafety } from "@cursorless/common"; +import { getCursorlessApi } from "@cursorless/vscode-common"; import { commands } from "vscode"; import { endToEndTestSetup } from "../../endToEndTestSetup"; import { runBasicMultilineContentTest } from "./runBasicMultilineContentTest"; import { runBasicRemovalTest } from "./runBasicRemovalTest"; import { runNestedMultilineContentTest } from "./runNestedMultilineContentTest"; import { runUpdateTest } from "./runUpdateTest"; -import { asyncSafety } from "@cursorless/common"; -suite("scope visualizer", async function () { +suite("scope visualizer", function () { endToEndTestSetup(this); + suiteSetup(async () => { + const { ide } = (await getCursorlessApi()).testHelpers!; + ide.configuration.mockConfiguration("decorationDebounceDelayMs", 0); + }); + teardown(() => commands.executeCommand("cursorless.hideScopeVisualizer")); test( diff --git a/packages/cursorless-vscode-e2e/src/suite/tutorial/tutorial.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/tutorial/tutorial.vscode.test.ts index 3571c56d50..71d07b7e34 100644 --- a/packages/cursorless-vscode-e2e/src/suite/tutorial/tutorial.vscode.test.ts +++ b/packages/cursorless-vscode-e2e/src/suite/tutorial/tutorial.vscode.test.ts @@ -3,19 +3,20 @@ import { LATEST_VERSION, asyncSafety, getSnapshotForComparison, - sleep, } from "@cursorless/common"; import { getRecordedTestsDirPath, loadFixture } from "@cursorless/node-common"; import { getCursorlessApi, runCursorlessCommand, + type SpyWebViewEvent, } from "@cursorless/vscode-common"; +import { isEqual } from "lodash-es"; import assert from "node:assert"; import path from "path"; import sinon from "sinon"; import { commands } from "vscode"; -import { endToEndTestSetup, sleepWithBackoff } from "../../endToEndTestSetup"; -import { isEqual } from "lodash-es"; +import { endToEndTestSetup } from "../../endToEndTestSetup"; +import { waitFor } from "../waitFor"; suite("tutorial", async function () { const { getSpy } = endToEndTestSetup(this); @@ -73,56 +74,42 @@ async function runBasicTutorialTest(spyIde: SpyIDE) { ); await checkStepSetup(fixtures[0]); - // Allow for debounce - await sleep(100); - - // Another sleep just in case - await sleepWithBackoff(50); - // We allow duplicate messages because they're idempotent. Not sure why some // platforms get the init message twice but it doesn't matter. - const result = getTutorialWebviewEventLog(); + // This is the initial message that the webview sends to the extension. // Seeing this means that the javascript in the webview successfully loaded. - assert( - result.some((e) => - isEqual(e, { - type: "messageReceived", - data: { - type: "getInitialState", - }, - }), - ), + await waitForEvent( + getTutorialWebviewEventLog, + (e) => e.type === "messageReceived" && e.data.type === "getInitialState", ); // This is the response from the extension to the webview's initial message. - assert( - result.some((e) => - isEqual(e, { - type: "messageSent", - data: { - type: "doingTutorial", - hasErrors: false, - id: "tutorial-1-basics", - stepNumber: 0, - stepContent: [ - [ - { - type: "string", - value: "Say ", - }, - { - type: "command", - value: "take cap", - }, - ], + await waitForEvent(getTutorialWebviewEventLog, (e) => + isEqual(e, { + type: "messageSent", + data: { + type: "doingTutorial", + hasErrors: false, + id: "tutorial-1-basics", + stepNumber: 0, + stepContent: [ + [ + { + type: "string", + value: "Say ", + }, + { + type: "command", + value: "take cap", + }, ], - stepCount: 11, - title: "Introduction", - preConditionsMet: true, - }, - }), - ), + ], + stepCount: 11, + title: "Introduction", + preConditionsMet: true, + }, + }), ); // Check that we focus the tutorial webview when the user starts the tutorial @@ -149,18 +136,10 @@ async function runBasicTutorialTest(spyIde: SpyIDE) { usePrePhraseSnapshot: false, }); - // Allow for debounce - await sleep(100); - - // Another sleep just in case - await sleepWithBackoff(50); - - assert( - getTutorialWebviewEventLog().some( - (message) => - message.type === "messageSent" && - message.data.preConditionsMet === false, - ), + await waitForEvent( + getTutorialWebviewEventLog, + (event) => + event.type === "messageSent" && event.data.preConditionsMet === false, ); // Test resuming tutorial @@ -234,3 +213,15 @@ const runNoOpCursorlessCommand = () => }, usePrePhraseSnapshot: false, }); + +async function waitForEvent( + getTutorialWebviewEventLog: () => SpyWebViewEvent[], + predicate: (event: SpyWebViewEvent) => boolean, +) { + const success = await waitFor(() => + getTutorialWebviewEventLog().some(predicate), + ); + if (!success) { + assert.fail("Timed out waiting for tutorial event log"); + } +} diff --git a/packages/cursorless-vscode-e2e/src/suite/waitFor.ts b/packages/cursorless-vscode-e2e/src/suite/waitFor.ts new file mode 100644 index 0000000000..4158b603bc --- /dev/null +++ b/packages/cursorless-vscode-e2e/src/suite/waitFor.ts @@ -0,0 +1,23 @@ +import { sleepWithBackoff } from "../endToEndTestSetup"; + +type Predicate = () => boolean | Promise; + +/** + * Waits for a predicate to become true, checking periodically with an + * increasing delay between checks. Returns true if the predicate becomes true + * within the given number of iterations, and false otherwise. + */ +export async function waitFor( + predicate: Predicate, + iterations = 20, +): Promise { + for (let i = 0; i < iterations; i++) { + if (await Promise.resolve(predicate())) { + return true; + } + + await sleepWithBackoff(25); + } + + return false; +} diff --git a/packages/cursorless-vscode/src/constructTestHelpers.ts b/packages/cursorless-vscode/src/constructTestHelpers.ts index 34bdef326e..ac43ef2fee 100644 --- a/packages/cursorless-vscode/src/constructTestHelpers.ts +++ b/packages/cursorless-vscode/src/constructTestHelpers.ts @@ -37,11 +37,13 @@ export function constructTestHelpers( scopeProvider: ScopeProvider, vscodeTutorial: VscodeTutorial, injectIde: (ide: IDE) => void, + loadLanguage: (languageId: string) => Promise, ): VscodeTestHelpers | undefined { return { commandServerApi: commandServerApi!, ide: normalizedIde, injectIde, + loadLanguage, scopeProvider, toVscodeEditor, diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index a84b257e4d..63a195f7e5 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -93,6 +93,7 @@ export async function activate( hatTokenMap, scopeProvider, injectIde, + loadLanguage, addCommandRunnerDecorator, customSpokenFormGenerator, } = await createCursorlessEngine(engineProps); @@ -185,6 +186,7 @@ export async function activate( scopeProvider, vscodeTutorial, injectIde, + loadLanguage, ) : undefined, }; diff --git a/packages/vscode-common/src/TestHelpers.ts b/packages/vscode-common/src/TestHelpers.ts index 04e04de498..d9a5484975 100644 --- a/packages/vscode-common/src/TestHelpers.ts +++ b/packages/vscode-common/src/TestHelpers.ts @@ -12,6 +12,7 @@ import type { SpyWebViewEvent } from "./SpyWebViewEvent"; export interface VscodeTestHelpers extends TestHelpers { ide: NormalizedIDE; injectIde(ide: IDE): void; + loadLanguage(languageId: string): Promise; clearCache(): void; scopeProvider: ScopeProvider; diff --git a/packages/vscode-common/src/testUtil/getReusableEditor.ts b/packages/vscode-common/src/testUtil/getReusableEditor.ts index 25881425c7..53acb1376e 100644 --- a/packages/vscode-common/src/testUtil/getReusableEditor.ts +++ b/packages/vscode-common/src/testUtil/getReusableEditor.ts @@ -1,12 +1,5 @@ -import { - commands, - EndOfLine, - languages, - Range, - window, - type TextEditor, -} from "vscode"; -import { getCursorlessApi, getParseTreeApi } from "../getExtensionApi"; +import { EndOfLine, Range, window, type TextEditor } from "vscode"; +import { getCursorlessApi } from "../getExtensionApi"; import { closeUiElements } from "./closeUiElements"; import { openNewEditor } from "./openNewEditor"; @@ -15,36 +8,20 @@ let editor: TextEditor | undefined; export async function getReusableEditor( content: string, languageId = "plaintext", - openBeside = false, ): Promise { - await closeUiElements(); - - if (openBeside) { - return await openNewEditor(content, languageId, true); - } - - if (editor == null) { + // Current editor is not fit for purpose, open a new one + if ( + editor == null || + editor !== window.activeTextEditor || + editor.document.languageId !== languageId + ) { editor = await openNewEditor(content, languageId); return editor; } + await closeUiElements(); (await getCursorlessApi()).testHelpers!.clearCache(); - // If the editor is not already active, make it active and close all other editors - if (editor !== window.activeTextEditor) { - editor = await window.showTextDocument(editor.document); - // Close other groups - await commands.executeCommand("workbench.action.closeEditorsInOtherGroups"); - // Close other editors in the same group - await commands.executeCommand("workbench.action.closeOtherEditors"); - } - - // If the editor is not already the right language, change its language and reload the parse tree - if (editor.document.languageId !== languageId) { - await languages.setTextDocumentLanguage(editor.document, languageId); - await (await getParseTreeApi()).loadLanguage(languageId); - } - // Replace the entire contents of the editor with the new content await editor.edit((editBuilder) => { editBuilder.replace( @@ -63,3 +40,7 @@ export async function getReusableEditor( return editor; } + +export function resetReusableEditor() { + editor = undefined; +} diff --git a/packages/vscode-common/src/testUtil/openNewEditor.ts b/packages/vscode-common/src/testUtil/openNewEditor.ts index d1de4de5d0..904b53990d 100644 --- a/packages/vscode-common/src/testUtil/openNewEditor.ts +++ b/packages/vscode-common/src/testUtil/openNewEditor.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { getCursorlessApi, getParseTreeApi } from "../getExtensionApi"; +import { getCursorlessApi } from "../getExtensionApi"; import { closeUiElements } from "./closeUiElements"; export async function openNewEditor( @@ -18,9 +18,8 @@ export async function openNewEditor( content, }); - await (await getParseTreeApi()).loadLanguage(languageId); - (await getCursorlessApi()).testHelpers!.clearCache(); + await (await getCursorlessApi()).testHelpers!.loadLanguage(languageId); const editor = await vscode.window.showTextDocument( document, @@ -41,12 +40,12 @@ export async function openNewEditor( * Open a new notebook editor with the given cells * @param cellContents A list of strings each of which will become the contents * of a cell in the notebook - * @param language The language id to use for all the cells in the notebook + * @param languageId The language id to use for all the cells in the notebook * @returns notebook */ export async function openNewNotebookEditor( cellContents: string[], - language: string = "plaintext", + languageId: string = "plaintext", ) { await vscode.commands.executeCommand("workbench.action.closeAllEditors"); @@ -58,15 +57,14 @@ export async function openNewNotebookEditor( new vscode.NotebookCellData( vscode.NotebookCellKind.Code, contents, - language, + languageId, ), ), ), ); - await (await getParseTreeApi()).loadLanguage(language); - (await getCursorlessApi()).testHelpers!.clearCache(); + await (await getCursorlessApi()).testHelpers!.loadLanguage(languageId); // FIXME: There seems to be some timing issue when you create a notebook // editor