-
-
Notifications
You must be signed in to change notification settings - Fork 84
Test Case Recorder #87
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
Changes from all commits
f7587e3
815e829
ed4ee0d
3d0e8b0
e9b60e4
efb696c
e95a993
b06f27f
c51def0
cc0e4bf
9d65fde
fa52794
35a8ce5
0471c7f
09a743f
43a4473
829882d
087c7fd
50ff1e4
f6db9e4
be29b40
84326b4
c6cd5ec
ca334b2
01b8553
c5c3d9b
060d91d
08ebc6b
076da28
52f4408
ff6a1e1
f585de0
b8df923
decc22f
16f67bb
0b8df41
3f188a3
a255e0b
8ce8f86
39d62f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
} |
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[]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a funny smell to have There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's basically like a type safe There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
} |
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; | ||
} | ||
} |
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; | ||
} | ||
} |
There was a problem hiding this comment.
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