Skip to content

Commit 87df330

Browse files
Will-Sommerspokey
andauthored
Allow for assertions against intentional non-match (#705)
* Allow for assertions against intentional non-match * Rename yml key for new test * Address PR feedback * Change asserted on property `errorName` * Address PR feedback pt.2 * Rename error field * Re-add empty line * Change error checking code to work * Fix ci Co-authored-by: Pokey Rule <[email protected]>
1 parent 358cf95 commit 87df330

File tree

9 files changed

+120
-17
lines changed

9 files changed

+120
-17
lines changed

docs/contributing/test-case-recorder.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,19 @@ command run, and the final state, all in the form of a yaml document. See
1313
1. Add a voice command for recording to your personal talon files:
1414
- `cursorless record: user.vscode("cursorless.recordTestCase")`
1515
- We don't want to commit this so add it to your own repository.
16-
1. If you'd like to be able to do tests which check the navigation map, you should also add the following to your personal talon files:
16+
1. If you'd like to be able to record tests which check the navigation map, please add the following to your personal talon files:
1717

1818
- https://github.com/pokey/pokey_talon/blob/9298c25dd6d28fd9fcf5ed39f305bc6b93e5f229/apps/vscode/vscode.talon#L468
1919
- https://github.com/pokey/pokey_talon/blob/49643bfa8f62cbec18b5ddad1658f5a28785eb01/apps/vscode/vscode.py#L203-L205
2020

2121
It is quite unlikely you'll need this second step. Most tests don't check the navigation map.
2222

23+
1. If you'd like to be able to record tests which assert on non-matches, please add another command to your personal talon files. See the two files links above for context. Add the command below to your to your `vscode.py` and ensure that there is a matching Talon command.
24+
25+
```
26+
actions.user.vscode_with_plugin("cursorless.recordTestCase", {"recordErrors": True})
27+
```
28+
2329
## Recording new tests
2430

2531
1. Start debugging (F5)

src/core/commandRunner/CommandRunner.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,6 @@ export default class CommandRunner {
106106
getNodeAtLocation: this.graph.getNodeAtLocation,
107107
};
108108

109-
const selections = processTargets(processedTargetsContext, targets);
110-
111109
if (this.graph.testCaseRecorder.isActive()) {
112110
const context = {
113111
targets,
@@ -122,6 +120,8 @@ export default class CommandRunner {
122120
);
123121
}
124122

123+
const selections = processTargets(processedTargetsContext, targets);
124+
125125
const {
126126
returnValue,
127127
thatMark: newThatMark,
@@ -137,7 +137,7 @@ export default class CommandRunner {
137137

138138
return returnValue;
139139
} catch (e) {
140-
this.graph.testCaseRecorder.commandErrorHook();
140+
await this.graph.testCaseRecorder.commandErrorHook(e as Error);
141141
const err = e as Error;
142142
if ((err as Error).name === "ActionableError") {
143143
(err as ActionableError).showErrorMessage();

src/errors.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,17 @@ export class ActionableError extends Error {
5454
});
5555
}
5656
}
57+
/**
58+
* Throw this error if you have attempted to match based on a language scope but have not
59+
* returned a match.
60+
*/
61+
export class NoContainingScopeError extends Error {
62+
/**
63+
*
64+
* @param scopeType The scopeType for the failed match to show to the user
65+
*/
66+
constructor(scopeType: string) {
67+
super(`Couldn't find containing ${scopeType}.`);
68+
this.name = "NoContainingScopeError";
69+
}
70+
}

src/processTargets/modifiers/processModifier.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "../../typings/Types";
1818
import { processSurroundingPair } from "./surroundingPair";
1919
import { getNodeMatcher } from "../../languages/getNodeMatcher";
20+
import { NoContainingScopeError } from "../../errors";
2021

2122
export type SelectionWithEditorWithContext = {
2223
selection: SelectionWithEditor;
@@ -99,7 +100,7 @@ function processScopeType(
99100
);
100101

101102
if (result == null) {
102-
throw new Error(`Couldn't find containing ${modifier.scopeType}`);
103+
throw new NoContainingScopeError(modifier.scopeType);
103104
}
104105

105106
return result;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
languageId: ruby
2+
command:
3+
version: 1
4+
spokenForm: take funk
5+
action: setSelection
6+
targets:
7+
- type: primitive
8+
modifier: {type: containingScope, scopeType: namedFunction, includeSiblings: false}
9+
initialState:
10+
documentContents: |2
11+
[1, 2, 3, [4, 5, 6]]
12+
selections:
13+
- anchor: {line: 0, character: 11}
14+
active: {line: 0, character: 11}
15+
marks: {}
16+
finalState: null
17+
returnValue: null
18+
fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: containingScope, scopeType: namedFunction, includeSiblings: false}, isImplicit: false}]
19+
thrownError: {name: NoContainingScopeError}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
languageId: ruby
2+
command:
3+
version: 1
4+
spokenForm: take list
5+
action: setSelection
6+
targets:
7+
- type: primitive
8+
modifier: {type: containingScope, scopeType: list, includeSiblings: false}
9+
initialState:
10+
documentContents: |2
11+
[1, 2, 3, [4, 5, 6]]
12+
selections:
13+
- anchor: {line: 0, character: 0}
14+
active: {line: 0, character: 0}
15+
marks: {}
16+
finalState: null
17+
returnValue: null
18+
fullTargets: [{type: primitive, mark: {type: cursor}, selectionType: token, position: contents, insideOutsideType: inside, modifier: {type: containingScope, scopeType: list, includeSiblings: false}, isImplicit: false}]
19+
thrownError: {name: NoContainingScopeError}

src/test/suite/recorded.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,33 @@ async function runTest(file: string) {
106106
// Assert that recorded decorations are present
107107
checkMarks(fixture.initialState.marks, readableHatMap);
108108

109+
if (fixture.thrownError != null) {
110+
await assert.rejects(
111+
async () => {
112+
await vscode.commands.executeCommand(
113+
"cursorless.command",
114+
fixture.command
115+
);
116+
},
117+
(err: Error) => {
118+
assert.strictEqual(err.name, fixture.thrownError?.name);
119+
return true;
120+
}
121+
);
122+
return;
123+
}
124+
109125
const returnValue = await vscode.commands.executeCommand(
110126
"cursorless.command",
111127
fixture.command
112128
);
113129

114130
const marks =
115-
fixture.finalState.marks == null
131+
fixture.finalState!.marks == null
116132
? undefined
117133
: marksToPlainObject(
118134
extractTargetedMarks(
119-
Object.keys(fixture.finalState.marks) as string[],
135+
Object.keys(fixture.finalState!.marks) as string[],
120136
readableHatMap
121137
)
122138
);

src/testUtil/TestCase.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export type TestCaseContext = {
2626
hatTokenMap: ReadOnlyHatMap;
2727
};
2828

29+
export type ThrownError = {
30+
name: string;
31+
};
32+
2933
export type TestCaseFixture = {
3034
languageId: string;
3135
command: TestCaseCommand;
@@ -36,7 +40,10 @@ export type TestCaseFixture = {
3640
marksToCheck?: string[];
3741

3842
initialState: TestCaseSnapshot;
39-
finalState: TestCaseSnapshot;
43+
/** The final state after a command is issued. Undefined if we are testing a non-match(error) case. */
44+
finalState?: TestCaseSnapshot;
45+
/** Used to assert if an error has been thrown. */
46+
thrownError?: ThrownError;
4047
returnValue: unknown;
4148
/** Inferred full targets added for context; not currently used in testing */
4249
fullTargets: Target[];
@@ -46,7 +53,8 @@ export class TestCase {
4653
languageId: string;
4754
fullTargets: Target[];
4855
initialState: TestCaseSnapshot | null = null;
49-
finalState: TestCaseSnapshot | null = null;
56+
finalState?: TestCaseSnapshot;
57+
thrownError?: ThrownError;
5058
returnValue: unknown = null;
5159
targetKeys: string[];
5260
private _awaitingFinalMarkInfo: boolean;
@@ -132,7 +140,10 @@ export class TestCase {
132140
}
133141

134142
toYaml() {
135-
if (this.initialState == null || this.finalState == null) {
143+
if (
144+
this.initialState == null ||
145+
(this.finalState == null && this.thrownError == null)
146+
) {
136147
throw Error("Two snapshots must be taken before serializing");
137148
}
138149
const fixture: TestCaseFixture = {
@@ -143,6 +154,7 @@ export class TestCase {
143154
finalState: this.finalState,
144155
returnValue: this.returnValue,
145156
fullTargets: this.fullTargets,
157+
thrownError: this.thrownError,
146158
};
147159
return serialize(fixture);
148160
}

src/testUtil/TestCaseRecorder.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,18 @@ interface RecordTestCaseCommandArg {
4747
* Whether to flash a background for calibrating a video recording
4848
*/
4949
showCalibrationDisplay?: boolean;
50+
51+
/**
52+
* Whether we should record a tests which yield errors in addition to tests
53+
* which do not error.
54+
*/
55+
recordErrors?: boolean;
5056
}
5157

5258
export class TestCaseRecorder {
5359
private active: boolean = false;
5460
private workspacePath: string | null;
55-
private workSpaceFolder: string | null;
61+
private workspaceName: string | null;
5662
private fixtureRoot: string | null;
5763
private targetDirectory: string | null = null;
5864
private testCase: TestCase | null = null;
@@ -62,6 +68,7 @@ export class TestCaseRecorder {
6268
private startTimestamp?: bigint;
6369
private extraSnapshotFields?: ExtraSnapshotField[];
6470
private paused: boolean = false;
71+
private isErrorTest: boolean = false;
6572
private calibrationStyle = vscode.window.createTextEditorDecorationType({
6673
backgroundColor: CALIBRATION_DISPLAY_BACKGROUND_COLOR,
6774
});
@@ -74,7 +81,7 @@ export class TestCaseRecorder {
7481
? graph.extensionContext.extensionPath
7582
: vscode.workspace.workspaceFolders?.[0].uri.path ?? null;
7683

77-
this.workSpaceFolder = this.workspacePath
84+
this.workspaceName = this.workspacePath
7885
? path.basename(this.workspacePath)
7986
: null;
8087

@@ -168,6 +175,7 @@ export class TestCaseRecorder {
168175
isSilent = false,
169176
extraSnapshotFields = [],
170177
showCalibrationDisplay = false,
178+
recordErrors: isErrorTest = false,
171179
} = arg ?? {};
172180

173181
if (directory != null) {
@@ -187,6 +195,7 @@ export class TestCaseRecorder {
187195
this.isHatTokenMapTest = isHatTokenMapTest;
188196
this.isSilent = isSilent;
189197
this.extraSnapshotFields = extraSnapshotFields;
198+
this.isErrorTest = isErrorTest;
190199
this.paused = false;
191200

192201
vscode.window.showInformationMessage(
@@ -286,11 +295,13 @@ export class TestCaseRecorder {
286295

287296
private async promptSubdirectory(): Promise<string | undefined> {
288297
if (
289-
this.workspacePath == null ||
298+
this.workspaceName == null ||
290299
this.fixtureRoot == null ||
291-
this.workSpaceFolder !== "cursorless-vscode"
300+
!["cursorless-vscode", "cursorless"].includes(this.workspaceName)
292301
) {
293-
throw new Error("Can't prompt for subdirectory");
302+
throw new Error(
303+
'"Cursorless record" must be run from within cursorless directory'
304+
);
294305
}
295306

296307
const subdirectories = walkDirsSync(this.fixtureRoot).concat("/");
@@ -351,8 +362,13 @@ export class TestCaseRecorder {
351362
return filePath;
352363
}
353364

354-
commandErrorHook() {
355-
this.testCase = null;
365+
async commandErrorHook(error: Error) {
366+
if (this.isErrorTest && this.testCase) {
367+
this.testCase.thrownError = { name: error.name };
368+
await this.finishTestCase();
369+
} else {
370+
this.testCase = null;
371+
}
356372
}
357373

358374
dispose() {

0 commit comments

Comments
 (0)