Skip to content

Commit

Permalink
Test Case Recorder (#87)
Browse files Browse the repository at this point in the history
  • Loading branch information
brxck committed Jul 31, 2021
1 parent 9dcf9c0 commit 3e617af
Show file tree
Hide file tree
Showing 23 changed files with 1,206 additions and 272 deletions.
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[];
extraArgs: any[];
};

type TestCaseContext = {
spokenForm: string;
thatMark: ThatMark;
targets: Target[];
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) {
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;
}
}

0 comments on commit 3e617af

Please sign in to comment.