Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Test Case Recorder #87

Merged
merged 40 commits into from
Jul 31, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f7587e3
Very preliminary steps
pokey Jul 3, 2021
815e829
Write test case info to file
brxck Jul 7, 2021
ed4ee0d
Save only target decorations
brxck Jul 7, 2021
3d0e8b0
Don't use yaml refs in test case fixtures
brxck Jul 8, 2021
e9b60e4
Record clipboard contents in snapshots
brxck Jul 9, 2021
efb696c
Run recorded test cases
brxck Jul 10, 2021
e95a993
Only save clipboard contents in copy/paste actions
brxck Jul 13, 2021
b06f27f
Reuse navigation map key creation
brxck Jul 13, 2021
c51def0
Add inferred full targets to fixture
brxck Jul 13, 2021
cc0e4bf
Move test fixtures from src to root
brxck Jul 14, 2021
9d65fde
Remove setting up navigation map
brxck Jul 14, 2021
fa52794
Attempt to mock clipboard (and fail)
brxck Jul 14, 2021
35a8ce5
Add navigationMap to graph
pokey Jul 13, 2021
0471c7f
Attempt to mock navigation map construction
brxck Jul 14, 2021
09a743f
Store "that mark" in test case fixture
brxck Jul 15, 2021
43a4473
Check nav map and improve that mark handling
brxck Jul 17, 2021
829882d
Give up for the night lol
brxck Jul 17, 2021
087c7fd
Support testing that mark
brxck Jul 18, 2021
50ff1e4
Add mockable clipboard layer
brxck Jul 18, 2021
f6db9e4
Test command return value
brxck Jul 18, 2021
be29b40
Only save targeted that marks to fixture
brxck Jul 18, 2021
84326b4
Name fixture and include Talon command
brxck Jul 18, 2021
c6cd5ec
Only save visible ranges for folding actions
brxck Jul 18, 2021
ca334b2
Start refactoring test cases
brxck Jul 27, 2021
01b8553
Fix that mark detection on compound targets
brxck Jul 27, 2021
c5c3d9b
Improve navigation map handling
brxck Jul 28, 2021
060d91d
Clarify thatMark exclusion conditions
brxck Jul 28, 2021
08ebc6b
Rename fixture fields
brxck Jul 28, 2021
076da28
Rephrase 'serialize' as 'to plain object'
brxck Jul 28, 2021
52f4408
Add issue for visible range testing
brxck Jul 28, 2021
ff6a1e1
Revert makeGraph export change
brxck Jul 28, 2021
f585de0
Improve fixture writing and move directory
brxck Jul 30, 2021
b8df923
Add test case folder selection
brxck Jul 30, 2021
decc22f
Create test case recorder class
brxck Jul 30, 2021
16f67bb
Ask for fixture directory after recording
brxck Jul 30, 2021
0b8df41
Move file handling to test case recorder
brxck Jul 30, 2021
3f188a3
Cleanup
pokey Jul 31, 2021
a255e0b
More cleanup
pokey Jul 31, 2021
8ce8f86
cleanup
pokey Jul 31, 2021
39d62f5
swap anchor with active
pokey Jul 31, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 5 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"type": "extensionHost",
"request": "launch",
"args": [
"--disable-extension",
"asvetliakov.vscode-neovim",
"--extensionDevelopmentPath=${workspaceFolder}"
],
"outFiles": [
Expand All @@ -22,6 +24,8 @@
"type": "extensionHost",
"request": "launch",
"args": [
"--disable-extension",
"asvetliakov.vscode-neovim",
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test/suite/index"
],
Expand All @@ -31,4 +35,4 @@
"preLaunchTask": "${defaultBuildTask}"
}
]
}
}
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@
{
"command": "cursorless.recomputeDecorationStyles",
"title": "Cursorless: Recompute decoration styles"
},
{
"command": "cursorless.recordTestCase",
"title": "Cursorless: Record test case"
}
],
"colors": [
Expand Down Expand Up @@ -196,15 +200,19 @@
},
"devDependencies": {
"@types/glob": "^7.1.3",
"@types/js-yaml": "^4.0.2",
"@types/mocha": "^8.0.4",
"@types/node": "^12.11.7",
"@types/sinon": "^10.0.2",
"@types/vscode": "^1.53.0",
"@typescript-eslint/eslint-plugin": "^4.9.0",
"@typescript-eslint/parser": "^4.9.0",
"esbuild": "^0.11.12",
"eslint": "^7.15.0",
"glob": "^7.1.6",
"glob": "^7.1.7",
"js-yaml": "^4.1.0",
"mocha": "^8.1.3",
"sinon": "^11.1.1",
"typescript": "^4.1.2",
"vscode-test": "^1.4.1"
},
Expand Down
12 changes: 12 additions & 0 deletions src/Clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as vscode from "vscode";

/**
* A mockable layer over the vscode clipboard
*
* For unknown reasons it's not possible to mock the clipboard directly.
* Use this instead of vscode.env.clipboard so it can be mocked in testing.
**/
export class Clipboard {
static readText = vscode.env.clipboard.readText;
static writeText = vscode.env.clipboard.writeText;
}
15 changes: 12 additions & 3 deletions src/NavigationMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,24 @@ export default class NavigationMap {
[coloredSymbol: string]: Token;
} = {};

private getKey(color: SymbolColor, character: string) {
static getKey(color: SymbolColor, character: string) {
return `${color}.${character}`;
}

static splitKey(key: string) {
const [color, character] = key.split(".");
return { color: color as SymbolColor, character };
}

public addToken(color: SymbolColor, character: string, token: Token) {
this.map[this.getKey(color, character)] = token;
this.map[NavigationMap.getKey(color, character)] = token;
}

public getToken(color: SymbolColor, character: string) {
return this.map[this.getKey(color, character)];
return this.map[NavigationMap.getKey(color, character)];
}

public clear() {
this.map = {};
}
}
122 changes: 122 additions & 0 deletions src/TestCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as path from "path";
import * as fs from "fs";
import * as vscode from "vscode";
import NavigationMap from "./NavigationMap";
import { ThatMark } from "./ThatMark";
import { ActionType, PartialTarget, Target } from "./Types";
import { extractTargetedMarks } from "./extractTargetedMarks";
import { marksToPlainObject, SerializedMarks } from "./toPlainObject";
import { takeSnapshot, TestCaseSnapshot } from "./takeSnapshot";
import serialize from "./serialize";

type TestCaseCommand = {
actionName: ActionType;
partialTargets: PartialTarget[];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For completeness I'd be tempted to also capture the full target, even if we don't end up using it for anything

extraArgs: any[];
};

type TestCaseContext = {
spokenForm: string;
thatMark: ThatMark;
targets: Target[];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a funny smell to have targets here but the actual cursorless command somewhere else

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow. Do you mean you would rather construct test case with a single argument/type ex. { command, talonCommand, thatMark, targets } ?

navigationMap: NavigationMap;
};

export type TestCaseFixture = {
spokenForm: string;
command: TestCaseCommand;
languageId: string;
marks: SerializedMarks;
initialState: TestCaseSnapshot;
finalState: TestCaseSnapshot;
returnValue: unknown;
/** Inferred full targets added for context; not currently used in testing */
fullTargets: Target[];
};

export class TestCase {
spokenForm: string;
command: TestCaseCommand;
languageId: string;
fullTargets: Target[];
marks: SerializedMarks;
context: TestCaseContext;
initialState: TestCaseSnapshot | null = null;
finalState: TestCaseSnapshot | null = null;
returnValue: unknown = null;

constructor(command: TestCaseCommand, context: TestCaseContext) {
const activeEditor = vscode.window.activeTextEditor!;
const { navigationMap, targets, spokenForm } = context;
const targetedMarks = extractTargetedMarks(targets, navigationMap);

this.spokenForm = spokenForm;
this.command = command;
this.languageId = activeEditor.document.languageId;
this.marks = marksToPlainObject(targetedMarks);
this.fullTargets = targets;
this.context = context;
}

private includesThatMark(target: Target) {
if (target.type === "primitive" && target.mark.type === "that") {
return true;
} else if (target.type === "list") {
return target.elements.some(this.includesThatMark, this);
} else if (target.type === "range") {
return [target.start, target.end].some(this.includesThatMark, this);
}
return false;
}

private getExcludedFields(context?: { initialSnapshot?: boolean }) {
const excludableFields = {
clipboard: !["copy", "paste"].includes(this.command.actionName),
thatMark:
context?.initialSnapshot &&
!this.fullTargets.some(this.includesThatMark, this),
visibleRanges: ![
"fold",
"unfold",
"scrollToBottom",
"scrollToCenter",
"scrollToTop",
].includes(this.command.actionName),
};

return Object.keys(excludableFields).filter(
(field) => excludableFields[field]
);
}

toYaml() {
if (this.initialState == null || this.finalState == null) {
throw Error("Two snapshots must be taken before serializing");
}
const fixture: TestCaseFixture = {
spokenForm: this.spokenForm,
languageId: this.languageId,
command: this.command,
marks: this.marks,
initialState: this.initialState,
finalState: this.finalState,
returnValue: this.returnValue,
fullTargets: this.fullTargets,
};
return serialize(fixture);
}

async recordInitialState() {
const excludeFields = this.getExcludedFields({ initialSnapshot: true });
this.initialState = await takeSnapshot(
this.context.thatMark,
excludeFields
);
}

async recordFinalState(returnValue: unknown) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why unknown instead of any here? Tbh I don't really know the difference 😅

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's basically like a type safe any. You can use unknown in the same places you would use any, but it requires you to assert a type if you want to do anything with it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL thanks

const excludeFields = this.getExcludedFields();
this.returnValue = returnValue;
this.finalState = await takeSnapshot(this.context.thatMark, excludeFields);
}
}
159 changes: 159 additions & 0 deletions src/TestCaseRecorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import * as vscode from "vscode";
import * as path from "path";
import * as fs from "fs";
import { TestCase } from "./TestCase";
import { walkDirsSync } from "./test/suite/walkSync";

export class TestCaseRecorder {
active: boolean = false;
outPath: string | null = null;
spokenForm: string | null = null;
workspacePath: string | null;
workSpaceFolder: string | null;
fixtureRoot: string | null;
fixtureSubdirectory: string | null = null;

constructor(extensionContext: vscode.ExtensionContext) {
this.workspacePath =
extensionContext.extensionMode === vscode.ExtensionMode.Development
? extensionContext.extensionPath
: vscode.workspace.workspaceFolders?.[0].uri.path ?? null;

this.workSpaceFolder = this.workspacePath
? path.basename(this.workspacePath)
: null;

this.fixtureRoot = this.workspacePath
? path.join(this.workspacePath, "src/test/suite/fixtures/recorded")
: null;
}

start(): Promise<void> {
this.active = true;
return this.promptSpokenForm();
}

async finish(testCase: TestCase): Promise<string | null> {
this.active = false;
const outPath = await this.promptSubdirectory();
const fixture = testCase.toYaml();

if (outPath) {
this.writeToFile(outPath, fixture);
} else {
this.showFixture(fixture);
}

return outPath;
}

private async writeToFile(outPath: string, fixture: string) {
fs.writeFileSync(outPath, fixture);
vscode.window
.showInformationMessage("Cursorless test case saved.", "View")
.then(async (action) => {
if (action === "View") {
const document = await vscode.workspace.openTextDocument(outPath);
await vscode.window.showTextDocument(document);
}
});
}

private async showFixture(fixture: string) {
const document = await vscode.workspace.openTextDocument({
language: "yaml",
content: fixture,
});
await vscode.window.showTextDocument(document, {
viewColumn: vscode.ViewColumn.Beside,
});
}

private async promptSpokenForm(): Promise<void> {
const result = await vscode.window.showInputBox({
prompt: "Talon Command",
ignoreFocusOut: true,
validateInput: (input) => (input.trim().length > 0 ? null : "Required"),
});

// Inputs return undefined when a user cancels by hitting 'escape'
if (result === undefined) {
this.active = false;
return;
}

this.spokenForm = result;
}

private async promptSubdirectory(): Promise<string | null> {
if (
this.workspacePath == null ||
this.fixtureRoot == null ||
this.workSpaceFolder !== "cursorless-vscode"
) {
return null;
}

const subdirectories = walkDirsSync(this.fixtureRoot).concat("/");

const createNewSubdirectory = "Create new folder →";
const subdirectorySelection = await vscode.window.showQuickPick([
...subdirectories,
createNewSubdirectory,
]);

if (subdirectorySelection === undefined) {
return null;
} else if (subdirectorySelection === createNewSubdirectory) {
return this.promptNewSubdirectory();
} else {
this.fixtureSubdirectory = subdirectorySelection;
return this.promptFileName();
}
}

private async promptNewSubdirectory(): Promise<string | null> {
if (this.fixtureRoot == null) {
throw new Error("Missing fixture root. Not in cursorless workspace?");
}

const subdirectory = await vscode.window.showInputBox({
prompt: "New Folder Name",
ignoreFocusOut: true,
validateInput: (input) => (input.trim().length > 0 ? null : "Required"),
});

if (subdirectory === undefined) {
return this.promptSubdirectory(); // go back a prompt
}

this.fixtureSubdirectory = subdirectory;
return this.promptFileName();
}

private async promptFileName(): Promise<string | null> {
if (this.fixtureRoot == null) {
throw new Error("Missing fixture root. Not in cursorless workspace?");
}

const filename = await vscode.window.showInputBox({
prompt: "Fixture Filename",
});

if (filename === undefined || this.fixtureSubdirectory == null) {
return this.promptSubdirectory(); // go back a prompt
}

const targetDirectory = path.join(
this.fixtureRoot,
this.fixtureSubdirectory
);

if (!fs.existsSync(targetDirectory)) {
fs.mkdirSync(targetDirectory);
}

this.outPath = path.join(targetDirectory, `${filename}.yml`);
return this.outPath;
}
}
13 changes: 13 additions & 0 deletions src/ThatMark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { SelectionWithEditor } from "./Types";

export class ThatMark {
private mark: SelectionWithEditor[] = [];

set(value: SelectionWithEditor[]) {
this.mark = value;
}

get() {
return this.mark;
}
}