Skip to content

Commit

Permalink
Recorded test refactor (cursorless-dev#2369)
Browse files Browse the repository at this point in the history
## Checklist

- [ ] I have added
[tests](https://www.cursorless.org/docs/contributing/test-case-recorder/)
- [ ] I have updated the
[docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and
[cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet)
- [ ] I have not broken the cheatsheet

---------

Co-authored-by: Cedric Halbronn <[email protected]>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Pokey Rule <[email protected]>
  • Loading branch information
4 people authored Jun 4, 2024
1 parent 9c79d0a commit 78f3894
Show file tree
Hide file tree
Showing 12 changed files with 420 additions and 291 deletions.
2 changes: 2 additions & 0 deletions packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
"vscode-uri": "^3.0.8"
},
"devDependencies": {
"@types/chai": "^4.3.14",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "4.17.0",
"@types/mocha": "^10.0.6",
"@types/sinon": "^17.0.3",
"chai": "^5.1.0",
"cross-spawn": "7.0.3",
"fast-check": "3.17.0",
"js-yaml": "^4.1.0",
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export * from "./testUtil/isTesting";
export * from "./testUtil/testConstants";
export * from "./testUtil/getFixturePaths";
export * from "./testUtil/getCursorlessRepoRoot";
export * from "./testUtil/runRecordedTest";
export * from "./testUtil/serialize";
export * from "./testUtil/shouldUpdateFixtures";
export * from "./testUtil/TestCaseSnapshot";
Expand Down
346 changes: 346 additions & 0 deletions packages/common/src/testUtil/runRecordedTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
import {
Command,
CommandResponse,
ExcludableSnapshotField,
ExtraSnapshotField,
FakeCommandServerApi,
Fallback,
HatTokenMap,
IDE,
Position,
PositionPlainObject,
ReadOnlyHatMap,
Selection,
SelectionPlainObject,
SerializedMarks,
SpyIDE,
TargetPlainObject,
TestCaseFixtureLegacy,
TestCaseSnapshot,
TextEditor,
TokenHat,
clientSupportsFallback,
extractTargetedMarks,
marksToPlainObject,
omitByDeep,
plainObjectToRange,
rangeToPlainObject,
serializeTestFixture,
shouldUpdateFixtures,
splitKey,
spyIDERecordedValuesToPlainObject,
storedTargetKeys,
} from "@cursorless/common";
import { assert } from "chai";
import * as yaml from "js-yaml";
import { isUndefined } from "lodash";
import { promises as fsp } from "node:fs";

function createPosition(position: PositionPlainObject) {
return new Position(position.line, position.character);
}

function createSelection(selection: SelectionPlainObject): Selection {
const active = createPosition(selection.active);
const anchor = createPosition(selection.anchor);
return new Selection(anchor, active);
}

export interface TestHelpers {
hatTokenMap: HatTokenMap;

// FIXME: Remove this once we have a better way to get this function
// accessible from our tests
takeSnapshot(
excludeFields: ExcludableSnapshotField[],
extraFields: ExtraSnapshotField[],
editor: TextEditor,
ide: IDE,
marks: SerializedMarks | undefined,
): Promise<TestCaseSnapshot>;

setStoredTarget(
editor: TextEditor,
key: string,
targets: TargetPlainObject[] | undefined,
): void;

commandServerApi: FakeCommandServerApi;
}

interface RunRecordedTestOpts {
/**
* The path to the test fixture
*/
path: string;

/**
* The spy IDE
*/
spyIde: SpyIDE;

/**
* Open a new editor to use for running a recorded test
*
* @param content The content of the new editor
* @param languageId The language id of the new editor
* @returns A text editor
*/
openNewTestEditor: (
content: string,
languageId: string,
) => Promise<TextEditor>;

/**
* Sleep for a certain number of milliseconds, exponentially
* increasing the sleep time each time we re-run the test
*
* @param ms The base sleep time
* @returns A promise that resolves after sleeping
*/
sleepWithBackoff: (ms: number) => Promise<void>;

/**
* Test helper functions returned by the Cursorless extension
*/
testHelpers: TestHelpers;

/**
* Run a cursorless command using the ide's command mechanism
* @param command The Cursorless command to run
* @returns The result of the command
*/
runCursorlessCommand: (
command: Command,
) => Promise<CommandResponse | unknown>;
}

export async function runRecordedTest({
path,
spyIde,
openNewTestEditor,
sleepWithBackoff,
testHelpers,
runCursorlessCommand,
}: RunRecordedTestOpts) {
const buffer = await fsp.readFile(path);
const fixture = yaml.load(buffer.toString()) as TestCaseFixtureLegacy;
const excludeFields: ExcludableSnapshotField[] = [];

// FIXME The snapshot gets messed up with timing issues when running the recorded tests
// "Couldn't find token default.a"
const usePrePhraseSnapshot = false;

const { hatTokenMap, takeSnapshot, setStoredTarget, commandServerApi } =
testHelpers;

const editor = spyIde.getEditableTextEditor(
await openNewTestEditor(
fixture.initialState.documentContents,
fixture.languageId,
),
);

if (fixture.postEditorOpenSleepTimeMs != null) {
await sleepWithBackoff(fixture.postEditorOpenSleepTimeMs);
}

await editor.setSelections(
fixture.initialState.selections.map(createSelection),
);

for (const storedTargetKey of storedTargetKeys) {
const key = `${storedTargetKey}Mark` as const;
setStoredTarget(editor, storedTargetKey, fixture.initialState[key]);
}

if (fixture.initialState.clipboard) {
spyIde.clipboard.writeText(fixture.initialState.clipboard);
}

commandServerApi.setFocusedElementType(fixture.focusedElementType);

// Ensure that the expected hats are present
await hatTokenMap.allocateHats(
getTokenHats(fixture.initialState.marks, spyIde.activeTextEditor!),
);

const readableHatMap = await hatTokenMap.getReadableMap(usePrePhraseSnapshot);

// Assert that recorded decorations are present
checkMarks(fixture.initialState.marks, readableHatMap);

let returnValue: unknown;
let fallback: Fallback | undefined;

try {
returnValue = await runCursorlessCommand({
...fixture.command,
usePrePhraseSnapshot,
});
if (clientSupportsFallback(fixture.command)) {
const commandResponse = returnValue as CommandResponse;
returnValue =
"returnValue" in commandResponse
? commandResponse.returnValue
: undefined;
fallback =
"fallback" in commandResponse ? commandResponse.fallback : undefined;
}
} catch (err) {
const error = err as Error;

if (shouldUpdateFixtures()) {
const outputFixture = {
...fixture,
finalState: undefined,
decorations: undefined,
returnValue: undefined,
thrownError: { name: error.name },
};

await fsp.writeFile(path, serializeTestFixture(outputFixture));
} else if (fixture.thrownError != null) {
assert.strictEqual(
error.name,
fixture.thrownError.name,
"Unexpected thrown error",
);
} else {
throw error;
}

return;
}

if (fixture.postCommandSleepTimeMs != null) {
await sleepWithBackoff(fixture.postCommandSleepTimeMs);
}

const marks =
fixture.finalState?.marks == null
? undefined
: marksToPlainObject(
extractTargetedMarks(
Object.keys(fixture.finalState.marks),
readableHatMap,
),
);

if (fixture.finalState?.clipboard == null) {
excludeFields.push("clipboard");
}

for (const storedTargetKey of storedTargetKeys) {
const key = `${storedTargetKey}Mark` as const;
if (fixture.finalState?.[key] == null) {
excludeFields.push(key);
}
}

// FIXME Visible ranges are not asserted, see:
// https://github.com/cursorless-dev/cursorless/issues/160
const { visibleRanges, ...resultState } = await takeSnapshot(
excludeFields,
[],
spyIde.activeTextEditor!,
spyIde,
marks,
);

const rawSpyIdeValues = spyIde.getSpyValues(fixture.ide?.flashes != null);
const actualSpyIdeValues =
rawSpyIdeValues == null
? undefined
: spyIDERecordedValuesToPlainObject(rawSpyIdeValues);

if (shouldUpdateFixtures()) {
const outputFixture: TestCaseFixtureLegacy = {
...fixture,
finalState: resultState,
returnValue,
fallback,
ide: actualSpyIdeValues,
thrownError: undefined,
};

await fsp.writeFile(path, serializeTestFixture(outputFixture));
} else {
if (fixture.thrownError != null) {
throw Error(
`Expected error ${fixture.thrownError.name} but none was thrown`,
);
}

assert.deepStrictEqual(
resultState,
fixture.finalState,
"Unexpected final state",
);

assert.deepStrictEqual(
returnValue,
fixture.returnValue,
"Unexpected return value",
);

assert.deepStrictEqual(
fallback,
fixture.fallback,
"Unexpected fallback value",
);

assert.deepStrictEqual(
omitByDeep(actualSpyIdeValues, isUndefined),
fixture.ide,
"Unexpected ide captured values",
);
}
}

function checkMarks(
marks: SerializedMarks | undefined,
hatTokenMap: ReadOnlyHatMap,
) {
if (marks == null) {
return;
}

Object.entries(marks).forEach(([key, token]) => {
const { hatStyle, character } = splitKey(key);
const currentToken = hatTokenMap.getToken(hatStyle, character);
assert(currentToken != null, `Mark "${hatStyle} ${character}" not found`);
assert.deepStrictEqual(rangeToPlainObject(currentToken.range), token);
});
}

function getTokenHats(
marks: SerializedMarks | undefined,
editor: TextEditor,
): TokenHat[] {
if (marks == null) {
return [];
}

return Object.entries(marks).map(([key, token]) => {
const { hatStyle, character } = splitKey(key);
const range = plainObjectToRange(token);

return {
hatStyle,
grapheme: character,
token: {
editor,
range,
offsets: {
start: editor.document.offsetAt(range.start),
end: editor.document.offsetAt(range.end),
},
text: editor.document.getText(range),
},

// NB: We don't care about the hat range for this test
hatRange: range,
};
});
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { EnforceUndefined } from "@cursorless/common";
import { BaseTarget, CommonTargetParameters } from "./BaseTarget";

/**
Expand All @@ -18,5 +19,6 @@ export class ImplicitTarget extends BaseTarget<CommonTargetParameters> {
getTrailingDelimiterTarget = () => undefined;
getRemovalRange = () => this.contentRange;

protected getCloneParameters = () => this.state;
protected getCloneParameters: () => EnforceUndefined<CommonTargetParameters> =
() => this.state;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { EnforceUndefined } from "@cursorless/common";
import { BaseTarget, CommonTargetParameters } from "./BaseTarget";

/**
Expand All @@ -15,5 +16,6 @@ export class RawSelectionTarget extends BaseTarget<CommonTargetParameters> {
getTrailingDelimiterTarget = () => undefined;
getRemovalRange = () => this.contentRange;

protected getCloneParameters = () => this.state;
protected getCloneParameters: () => EnforceUndefined<CommonTargetParameters> =
() => this.state;
}
Loading

0 comments on commit 78f3894

Please sign in to comment.