Skip to content

Commit 78f3894

Browse files
saidelikeCedric Halbronnpre-commit-ci-lite[bot]pokey
authored
Recorded test refactor (#2369)
## 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 <cedric.halbronn@nccgroup.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Pokey Rule <755842+pokey@users.noreply.github.com>
1 parent 9c79d0a commit 78f3894

File tree

12 files changed

+420
-291
lines changed

12 files changed

+420
-291
lines changed

packages/common/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020
"vscode-uri": "^3.0.8"
2121
},
2222
"devDependencies": {
23+
"@types/chai": "^4.3.14",
2324
"@types/js-yaml": "^4.0.9",
2425
"@types/lodash": "4.17.0",
2526
"@types/mocha": "^10.0.6",
2627
"@types/sinon": "^17.0.3",
28+
"chai": "^5.1.0",
2729
"cross-spawn": "7.0.3",
2830
"fast-check": "3.17.0",
2931
"js-yaml": "^4.1.0",

packages/common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export * from "./testUtil/isTesting";
6363
export * from "./testUtil/testConstants";
6464
export * from "./testUtil/getFixturePaths";
6565
export * from "./testUtil/getCursorlessRepoRoot";
66+
export * from "./testUtil/runRecordedTest";
6667
export * from "./testUtil/serialize";
6768
export * from "./testUtil/shouldUpdateFixtures";
6869
export * from "./testUtil/TestCaseSnapshot";
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
import {
2+
Command,
3+
CommandResponse,
4+
ExcludableSnapshotField,
5+
ExtraSnapshotField,
6+
FakeCommandServerApi,
7+
Fallback,
8+
HatTokenMap,
9+
IDE,
10+
Position,
11+
PositionPlainObject,
12+
ReadOnlyHatMap,
13+
Selection,
14+
SelectionPlainObject,
15+
SerializedMarks,
16+
SpyIDE,
17+
TargetPlainObject,
18+
TestCaseFixtureLegacy,
19+
TestCaseSnapshot,
20+
TextEditor,
21+
TokenHat,
22+
clientSupportsFallback,
23+
extractTargetedMarks,
24+
marksToPlainObject,
25+
omitByDeep,
26+
plainObjectToRange,
27+
rangeToPlainObject,
28+
serializeTestFixture,
29+
shouldUpdateFixtures,
30+
splitKey,
31+
spyIDERecordedValuesToPlainObject,
32+
storedTargetKeys,
33+
} from "@cursorless/common";
34+
import { assert } from "chai";
35+
import * as yaml from "js-yaml";
36+
import { isUndefined } from "lodash";
37+
import { promises as fsp } from "node:fs";
38+
39+
function createPosition(position: PositionPlainObject) {
40+
return new Position(position.line, position.character);
41+
}
42+
43+
function createSelection(selection: SelectionPlainObject): Selection {
44+
const active = createPosition(selection.active);
45+
const anchor = createPosition(selection.anchor);
46+
return new Selection(anchor, active);
47+
}
48+
49+
export interface TestHelpers {
50+
hatTokenMap: HatTokenMap;
51+
52+
// FIXME: Remove this once we have a better way to get this function
53+
// accessible from our tests
54+
takeSnapshot(
55+
excludeFields: ExcludableSnapshotField[],
56+
extraFields: ExtraSnapshotField[],
57+
editor: TextEditor,
58+
ide: IDE,
59+
marks: SerializedMarks | undefined,
60+
): Promise<TestCaseSnapshot>;
61+
62+
setStoredTarget(
63+
editor: TextEditor,
64+
key: string,
65+
targets: TargetPlainObject[] | undefined,
66+
): void;
67+
68+
commandServerApi: FakeCommandServerApi;
69+
}
70+
71+
interface RunRecordedTestOpts {
72+
/**
73+
* The path to the test fixture
74+
*/
75+
path: string;
76+
77+
/**
78+
* The spy IDE
79+
*/
80+
spyIde: SpyIDE;
81+
82+
/**
83+
* Open a new editor to use for running a recorded test
84+
*
85+
* @param content The content of the new editor
86+
* @param languageId The language id of the new editor
87+
* @returns A text editor
88+
*/
89+
openNewTestEditor: (
90+
content: string,
91+
languageId: string,
92+
) => Promise<TextEditor>;
93+
94+
/**
95+
* Sleep for a certain number of milliseconds, exponentially
96+
* increasing the sleep time each time we re-run the test
97+
*
98+
* @param ms The base sleep time
99+
* @returns A promise that resolves after sleeping
100+
*/
101+
sleepWithBackoff: (ms: number) => Promise<void>;
102+
103+
/**
104+
* Test helper functions returned by the Cursorless extension
105+
*/
106+
testHelpers: TestHelpers;
107+
108+
/**
109+
* Run a cursorless command using the ide's command mechanism
110+
* @param command The Cursorless command to run
111+
* @returns The result of the command
112+
*/
113+
runCursorlessCommand: (
114+
command: Command,
115+
) => Promise<CommandResponse | unknown>;
116+
}
117+
118+
export async function runRecordedTest({
119+
path,
120+
spyIde,
121+
openNewTestEditor,
122+
sleepWithBackoff,
123+
testHelpers,
124+
runCursorlessCommand,
125+
}: RunRecordedTestOpts) {
126+
const buffer = await fsp.readFile(path);
127+
const fixture = yaml.load(buffer.toString()) as TestCaseFixtureLegacy;
128+
const excludeFields: ExcludableSnapshotField[] = [];
129+
130+
// FIXME The snapshot gets messed up with timing issues when running the recorded tests
131+
// "Couldn't find token default.a"
132+
const usePrePhraseSnapshot = false;
133+
134+
const { hatTokenMap, takeSnapshot, setStoredTarget, commandServerApi } =
135+
testHelpers;
136+
137+
const editor = spyIde.getEditableTextEditor(
138+
await openNewTestEditor(
139+
fixture.initialState.documentContents,
140+
fixture.languageId,
141+
),
142+
);
143+
144+
if (fixture.postEditorOpenSleepTimeMs != null) {
145+
await sleepWithBackoff(fixture.postEditorOpenSleepTimeMs);
146+
}
147+
148+
await editor.setSelections(
149+
fixture.initialState.selections.map(createSelection),
150+
);
151+
152+
for (const storedTargetKey of storedTargetKeys) {
153+
const key = `${storedTargetKey}Mark` as const;
154+
setStoredTarget(editor, storedTargetKey, fixture.initialState[key]);
155+
}
156+
157+
if (fixture.initialState.clipboard) {
158+
spyIde.clipboard.writeText(fixture.initialState.clipboard);
159+
}
160+
161+
commandServerApi.setFocusedElementType(fixture.focusedElementType);
162+
163+
// Ensure that the expected hats are present
164+
await hatTokenMap.allocateHats(
165+
getTokenHats(fixture.initialState.marks, spyIde.activeTextEditor!),
166+
);
167+
168+
const readableHatMap = await hatTokenMap.getReadableMap(usePrePhraseSnapshot);
169+
170+
// Assert that recorded decorations are present
171+
checkMarks(fixture.initialState.marks, readableHatMap);
172+
173+
let returnValue: unknown;
174+
let fallback: Fallback | undefined;
175+
176+
try {
177+
returnValue = await runCursorlessCommand({
178+
...fixture.command,
179+
usePrePhraseSnapshot,
180+
});
181+
if (clientSupportsFallback(fixture.command)) {
182+
const commandResponse = returnValue as CommandResponse;
183+
returnValue =
184+
"returnValue" in commandResponse
185+
? commandResponse.returnValue
186+
: undefined;
187+
fallback =
188+
"fallback" in commandResponse ? commandResponse.fallback : undefined;
189+
}
190+
} catch (err) {
191+
const error = err as Error;
192+
193+
if (shouldUpdateFixtures()) {
194+
const outputFixture = {
195+
...fixture,
196+
finalState: undefined,
197+
decorations: undefined,
198+
returnValue: undefined,
199+
thrownError: { name: error.name },
200+
};
201+
202+
await fsp.writeFile(path, serializeTestFixture(outputFixture));
203+
} else if (fixture.thrownError != null) {
204+
assert.strictEqual(
205+
error.name,
206+
fixture.thrownError.name,
207+
"Unexpected thrown error",
208+
);
209+
} else {
210+
throw error;
211+
}
212+
213+
return;
214+
}
215+
216+
if (fixture.postCommandSleepTimeMs != null) {
217+
await sleepWithBackoff(fixture.postCommandSleepTimeMs);
218+
}
219+
220+
const marks =
221+
fixture.finalState?.marks == null
222+
? undefined
223+
: marksToPlainObject(
224+
extractTargetedMarks(
225+
Object.keys(fixture.finalState.marks),
226+
readableHatMap,
227+
),
228+
);
229+
230+
if (fixture.finalState?.clipboard == null) {
231+
excludeFields.push("clipboard");
232+
}
233+
234+
for (const storedTargetKey of storedTargetKeys) {
235+
const key = `${storedTargetKey}Mark` as const;
236+
if (fixture.finalState?.[key] == null) {
237+
excludeFields.push(key);
238+
}
239+
}
240+
241+
// FIXME Visible ranges are not asserted, see:
242+
// https://github.com/cursorless-dev/cursorless/issues/160
243+
const { visibleRanges, ...resultState } = await takeSnapshot(
244+
excludeFields,
245+
[],
246+
spyIde.activeTextEditor!,
247+
spyIde,
248+
marks,
249+
);
250+
251+
const rawSpyIdeValues = spyIde.getSpyValues(fixture.ide?.flashes != null);
252+
const actualSpyIdeValues =
253+
rawSpyIdeValues == null
254+
? undefined
255+
: spyIDERecordedValuesToPlainObject(rawSpyIdeValues);
256+
257+
if (shouldUpdateFixtures()) {
258+
const outputFixture: TestCaseFixtureLegacy = {
259+
...fixture,
260+
finalState: resultState,
261+
returnValue,
262+
fallback,
263+
ide: actualSpyIdeValues,
264+
thrownError: undefined,
265+
};
266+
267+
await fsp.writeFile(path, serializeTestFixture(outputFixture));
268+
} else {
269+
if (fixture.thrownError != null) {
270+
throw Error(
271+
`Expected error ${fixture.thrownError.name} but none was thrown`,
272+
);
273+
}
274+
275+
assert.deepStrictEqual(
276+
resultState,
277+
fixture.finalState,
278+
"Unexpected final state",
279+
);
280+
281+
assert.deepStrictEqual(
282+
returnValue,
283+
fixture.returnValue,
284+
"Unexpected return value",
285+
);
286+
287+
assert.deepStrictEqual(
288+
fallback,
289+
fixture.fallback,
290+
"Unexpected fallback value",
291+
);
292+
293+
assert.deepStrictEqual(
294+
omitByDeep(actualSpyIdeValues, isUndefined),
295+
fixture.ide,
296+
"Unexpected ide captured values",
297+
);
298+
}
299+
}
300+
301+
function checkMarks(
302+
marks: SerializedMarks | undefined,
303+
hatTokenMap: ReadOnlyHatMap,
304+
) {
305+
if (marks == null) {
306+
return;
307+
}
308+
309+
Object.entries(marks).forEach(([key, token]) => {
310+
const { hatStyle, character } = splitKey(key);
311+
const currentToken = hatTokenMap.getToken(hatStyle, character);
312+
assert(currentToken != null, `Mark "${hatStyle} ${character}" not found`);
313+
assert.deepStrictEqual(rangeToPlainObject(currentToken.range), token);
314+
});
315+
}
316+
317+
function getTokenHats(
318+
marks: SerializedMarks | undefined,
319+
editor: TextEditor,
320+
): TokenHat[] {
321+
if (marks == null) {
322+
return [];
323+
}
324+
325+
return Object.entries(marks).map(([key, token]) => {
326+
const { hatStyle, character } = splitKey(key);
327+
const range = plainObjectToRange(token);
328+
329+
return {
330+
hatStyle,
331+
grapheme: character,
332+
token: {
333+
editor,
334+
range,
335+
offsets: {
336+
start: editor.document.offsetAt(range.start),
337+
end: editor.document.offsetAt(range.end),
338+
},
339+
text: editor.document.getText(range),
340+
},
341+
342+
// NB: We don't care about the hat range for this test
343+
hatRange: range,
344+
};
345+
});
346+
}

packages/cursorless-engine/src/processTargets/targets/ImplicitTarget.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EnforceUndefined } from "@cursorless/common";
12
import { BaseTarget, CommonTargetParameters } from "./BaseTarget";
23

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

21-
protected getCloneParameters = () => this.state;
22+
protected getCloneParameters: () => EnforceUndefined<CommonTargetParameters> =
23+
() => this.state;
2224
}

packages/cursorless-engine/src/processTargets/targets/RawSelectionTarget.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EnforceUndefined } from "@cursorless/common";
12
import { BaseTarget, CommonTargetParameters } from "./BaseTarget";
23

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

18-
protected getCloneParameters = () => this.state;
19+
protected getCloneParameters: () => EnforceUndefined<CommonTargetParameters> =
20+
() => this.state;
1921
}

0 commit comments

Comments
 (0)