Repository: Idered/typescript-expect-plugin
Branch: master
Commit: 8e2674460c99
Files: 17
Total size: 19.3 KB
Directory structure:
gitextract_2hbh9kz3/
├── .github/
│ └── workflows/
│ └── main.yml
├── .gitignore
├── .vscode/
│ └── settings.json
├── LICENSE
├── README.md
├── package.json
├── src/
│ ├── adapter.ts
│ ├── consts.ts
│ ├── get-expect-tag.ts
│ ├── get-temporary-file.ts
│ ├── index.ts
│ ├── language-service-proxy-builder.ts
│ ├── message-bag.ts
│ ├── parse-comment.ts
│ ├── plugin-module-factory.ts
│ └── visit.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/main.yml
================================================
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Begin CI...
uses: actions/checkout@v2
- name: Use Node 12
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Use cached node_modules
uses: actions/cache@v1
with:
path: node_modules
key: nodeModules-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
nodeModules-
- name: Install dependencies
run: yarn install --frozen-lockfile
env:
CI: true
- name: Build
run: yarn build
env:
CI: true
================================================
FILE: .gitignore
================================================
node_modules
lib
*.log
================================================
FILE: .vscode/settings.json
================================================
{
"editor.formatOnSave": true,
"typescript.tsdk": "node_modules\\typescript\\lib"
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2020 Kasper Mikiewicz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
Typescript Expect Plugin
Be lazy, write simple tests in comments.
## Editor support
✅ VS Code - flawlessly works in `Problems` panel.
⏹ Sublime Text - could not get it to work but it might be possible.
❔ Atom - not tested.
⛔ `tsc` - plugins are disabled during build. It should work with webpack ts loader.
## Quick start
```sh
npm install typescript-expect-plugin
```
1. Add plugin to `tsconfig.json`:
```ts
{
"compilerOptions": {
"plugins": [{ "name": "typescript-expect-plugin" }]
},
}
```
2. Change VS Code typescript to workspace version:

## Usage
## WARNING
> ⚠Tests are executed after each file change - not save. Be careful if you're going to test functions that remove or change files in your local system
---
This plugin adds support for `@expect` JSDoc tag. It has the following usage pattern:
```tsx
/**
* @expect [PARAMS] CONDITION CONDITION_PARAMETER
*/
```
- `[PARAMS]` - for example `[2, 4]` will spread two arguments to tested function.
- `CONDITION` - check function from jest expect library. Use `ctrl+space` to see autocomplete suggestions.
- `CONDITION_PARAMETER` - argument passed to `CONDITION` function.
## Examples
```tsx
/**
* @expect [2, 4] toBe 6
* @expect [2, 2] toBeGreaterThan 3
* @expect [2, 2] toBeLessThan 3
* @expect [2, 22] toEqual 24
*/
export function sum(a: number, b: number) {
return a + b;
}
/**
* @expect [[2, 4, 8], 4] toBeTruthy
* @expect [[2, 4, 8], 12] toBeFalsy
*/
export function has(haystack: any[], needle: any) {
return haystack.includes(needle);
}
/**
* @expect [[2, 8], [9, 12]] toEqual [2, 8, 9, 12]
*/
export function join(arr1: any[], arr2: any[]) {
return [...arr1, ...arr2];
}
/**
* @expect [{"firstName": "John"}, "lastName", "Doe"] toHaveProperty "lastName", "Doe Doe"
*/
export function withProp(obj: Record, key: string, value: any) {
return {...obj, [key]: value}
}
```
> ### Test objects

> ### Test arrays

## Author
Hey there, I'm Kasper. If you wish to get notified about more cool typescript or react projects/tips you can follow me on twitter.
================================================
FILE: package.json
================================================
{
"name": "typescript-expect-plugin",
"description": "Be lazy, write simple tests in comments",
"version": "0.3.1",
"main": "lib/index.js",
"author": "Kasper Mikiewicz",
"license": "MIT",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"prepare": "tsc"
},
"files": [
"lib"
],
"keywords": [
"typescript",
"typescript-plugin",
"test",
"jest",
"expect"
],
"repository": {
"type": "git",
"url": "git+https://github.com/idered/typescript-expect-plugin.git"
},
"homepage": "https://github.com/idered/typescript-expect-plugin#readme",
"bugs": {
"url": "https://github.com/idered/typescript-expect-plugin/issues"
},
"engines": {
"node": ">=10"
},
"dependencies": {
"byots": "^4.0.0-dev.20200523.13.57",
"expect": "^26.0.1",
"read-pkg-up": "^7.0.1",
"tmp": "^0.2.1",
"ts-node": "^8.10.1"
},
"peerDependencies": {
"typescript": ">=3.7.0"
},
"devDependencies": {
"@types/node": "^14.0.1",
"@types/tmp": "^0.2.0",
"typescript": "^3.9.2"
}
}
================================================
FILE: src/adapter.ts
================================================
import ts, { ScriptElementKind } from "typescript";
import bts from "byots";
import { register } from "ts-node";
import visit from "./visit";
import { MessageBag } from "./message-bag";
import {
EXPECT_KEYWORDS,
TS_LANGSERVICE_EXPECT_DIAGNOSTIC_ERROR_CODE,
} from "./consts";
register({
compilerOptions: {
target: "ESNext",
},
});
export type ESLintAdapterOptions = {
logger: (msg: string) => void;
getSourceFile: (fileName: string) => ts.SourceFile | undefined;
getProgram?: () => ts.Program;
};
export class Adapter {
private readonly logger: (msg: string) => void;
private readonly getSourceFile: (
fileName: string
) => ts.SourceFile | undefined;
private messageBag: MessageBag;
public constructor({ logger, getSourceFile }: ESLintAdapterOptions) {
this.logger = logger;
this.getSourceFile = getSourceFile;
this.messageBag = new MessageBag();
}
public getSemanticDiagnostics(
delegate: ts.LanguageService["getSemanticDiagnostics"],
fileName: string
): ReturnType {
const original = delegate(fileName);
try {
this.messageBag.clear();
const sourceFile = this.getSourceFile(fileName);
if (!sourceFile) return original;
visit(sourceFile, this.messageBag);
const diagnostics = original.length
? []
: this.transformErrorsToDiagnostics(sourceFile);
return [...original, ...diagnostics];
} catch (error) {
this.logger(error.message ? error.message : "unknown error");
return original;
}
}
public getCompletionsAtPosition(
delegate: ts.LanguageService["getCompletionsAtPosition"],
fileName: string,
position: number,
options: ts.GetCompletionsAtPositionOptions
): ReturnType {
let original = delegate(fileName, position, options);
const source = (this.getSourceFile(fileName) as unknown) as bts.SourceFile;
const token = bts.getTokenAtPosition(source, position);
if (bts.isJSDocTag(token) && original) {
original.entries = [
...original.entries,
{
kind: ScriptElementKind.keyword,
kindModifiers: "",
name: "expect",
sortText: "0",
},
];
}
if (bts.isInComment(source, position) && bts.isJSDoc(token)) {
if (!original) {
original = {
entries: [],
isGlobalCompletion: false,
isMemberCompletion: false,
isNewIdentifierLocation: false,
};
}
const tag = token.tags?.find(
(item) =>
item.end + item.comment?.length + 1 >= position &&
item.pos <= position
);
const isExpectTag =
tag && tag.comment && tag.tagName.escapedText === "expect";
if (isExpectTag) {
const hasNotKeyword = tag.comment?.includes("not");
const fnKeyword =
EXPECT_KEYWORDS.find((keyword) => tag.comment?.includes(keyword)) ||
"";
const keywordPosition =
tag.end + tag.comment.indexOf(fnKeyword) + fnKeyword.length;
original.entries = [
...original.entries,
...[
...(hasNotKeyword || fnKeyword ? [] : ["not"]),
...(fnKeyword && keywordPosition !== position
? []
: EXPECT_KEYWORDS),
].map((name) => ({
kind: ScriptElementKind.functionElement,
name,
kindModifiers: "",
sortText: "0",
})),
];
}
}
return original;
}
public getQuickInfoAtPosition(
delegate: ts.LanguageService["getQuickInfoAtPosition"],
fileName: string,
position: number
): ReturnType {
const original = delegate(fileName, position);
// Remove expect tags when user hover function name
if (original) {
original.tags = original.tags?.filter((item) => item.name !== "expect");
}
return original;
}
public getCompletionEntryDetails(
delegate: ts.LanguageService["getCompletionEntryDetails"],
fileName: string,
position: number,
entryName: string,
formatOptions: ts.FormatCodeOptions | ts.FormatCodeSettings,
source: string,
preferences: ts.UserPreferences
): ReturnType {
const original = delegate(
fileName,
position,
entryName,
formatOptions,
source,
preferences
);
// Remove expect tags for autocomplete popup
if (original) {
original.tags = original.tags?.filter((item) => item.name !== "expect");
}
return original;
}
public getSignatureHelpItems(
delegate: ts.LanguageService["getSignatureHelpItems"],
fileName: string,
position: number,
options: ts.SignatureHelpItemsOptions
): ReturnType {
const original = delegate(fileName, position, options);
// Remove expect tags for autocomplete popup
if (original) {
original.items = original.items.map((item) => ({
...item,
// Remove expect tags from signature tooltip
tags: item.tags?.filter((item) => item.name !== "expect"),
}));
}
return original;
}
private transformErrorsToDiagnostics(
sourceFile: ts.SourceFile
): ts.Diagnostic[] {
return this.messageBag.messages.map((item) => ({
category: ts.DiagnosticCategory.Error,
file: sourceFile,
messageText: item.content,
start: item.pos,
length: item.end - item.pos - 1,
code: TS_LANGSERVICE_EXPECT_DIAGNOSTIC_ERROR_CODE,
}));
}
}
================================================
FILE: src/consts.ts
================================================
export const TS_LANGSERVICE_EXPECT_DIAGNOSTIC_ERROR_CODE = 50555;
export const EXPECT_KEYWORDS = [
"toBeDefined",
"toBe",
"toBeCloseTo",
"toBeFalsy",
"toBeGreaterThan",
"toBeGreaterThanOrEqual",
"toBeLessThan",
"toBeLessThanOrEqual",
"toBeNaN",
"toBeNull",
"toBeTruthy",
"toBeUndefined",
"toContain",
"toContainEqual",
"toEqual",
"toHaveLength",
"toHaveProperty",
"toMatchObject",
"toStrictEqual",
"toThrow",
];
================================================
FILE: src/get-expect-tag.ts
================================================
import ts from "typescript";
export function isJSDocExpectTag(tag: ts.JSDocTag): tag is ts.JSDocTag {
return tag.tagName.escapedText === "expect";
}
export function getJSDocExpectTags(node: ts.Node): readonly ts.JSDocTag[] {
return ts.getAllJSDocTags(node, isJSDocExpectTag);
}
================================================
FILE: src/get-temporary-file.ts
================================================
import ts from "typescript";
import { writeFileSync } from "fs";
import tmp from "tmp";
tmp.setGracefulCleanup();
function getTemporaryFile(node: ts.FunctionDeclaration) {
const sourceFile = node.getSourceFile();
const tempFile = tmp.fileSync({ postfix: ".ts" });
const filepath = tempFile.name.split(".").slice(0, -1).join(".");
writeFileSync(tempFile.name, sourceFile.getFullText());
const fileModule = require(filepath);
return fileModule;
}
export default getTemporaryFile;
================================================
FILE: src/index.ts
================================================
import { pluginModuleFactory } from "./plugin-module-factory";
export = pluginModuleFactory;
================================================
FILE: src/language-service-proxy-builder.ts
================================================
import ts from "typescript/lib/tsserverlibrary";
export type LanguageServiceMethodWrapper = (
delegate: ts.LanguageService[K],
info?: ts.server.PluginCreateInfo
) => ts.LanguageService[K];
export class LanguageServiceProxyBuilder {
private readonly wrappers: any[] = [];
private readonly info: ts.server.PluginCreateInfo;
public constructor(info: ts.server.PluginCreateInfo) {
this.info = info;
}
public wrap<
K extends keyof ts.LanguageService,
Q extends LanguageServiceMethodWrapper
>(name: K, wrapper: Q) {
this.wrappers.push({ name, wrapper });
return this;
}
public build(): ts.LanguageService {
const ret = this.info.languageService;
this.wrappers.forEach(({ name, wrapper }) => {
(ret as any)[name] = wrapper(
this.info.languageService[name as keyof ts.LanguageService],
this.info
);
});
return ret;
}
}
================================================
FILE: src/message-bag.ts
================================================
export type Message = {
pos: number;
end: number;
content: string;
};
export class MessageBag {
messages: Message[] = [];
clear() {
this.messages = [];
}
add(message: Message) {
this.messages = [...this.messages, message];
}
}
================================================
FILE: src/parse-comment.ts
================================================
import { Matchers } from "expect";
const exp = /^(\[.+\])\s{1}((not\.?)?(?:\w+))[\s{1}]?(.+)?/;
function parseComment(comment: string) {
if (!comment) return;
const match = comment.match(exp);
if (!match) return;
const [, params, method, , result] = match;
const matcher = (method as unknown) as keyof Pick, "toEqual">;
const isUndefined = result === undefined || result === "undefined";
return {
params: JSON.parse(params),
matcher,
result: isUndefined ? [undefined] : JSON.parse(`[${result}]`),
};
}
export default parseComment;
================================================
FILE: src/plugin-module-factory.ts
================================================
import typescript from "typescript/lib/tsserverlibrary";
import { LanguageServiceProxyBuilder } from "./language-service-proxy-builder";
import { Adapter } from "./adapter";
// TODO: Use provided typescript
const create = (ts: typeof typescript) => (
info: ts.server.PluginCreateInfo
): ts.LanguageService => {
const { languageService, project } = info;
const logger = (msg: string) =>
project.projectService.logger.info(`[typescript-jest-service] ${msg}`);
const getProgram = () => {
const program = languageService.getProgram();
if (!program) throw new Error();
return program;
};
const adapter = new Adapter({
logger,
getSourceFile(fileName: string) {
return getProgram().getSourceFile(fileName);
},
});
const proxy = new LanguageServiceProxyBuilder(info)
.wrap("getSemanticDiagnostics", (delegate) =>
adapter.getSemanticDiagnostics.bind(adapter, delegate)
)
.wrap("getQuickInfoAtPosition", (delegate) =>
adapter.getQuickInfoAtPosition.bind(adapter, delegate)
)
.wrap("getCompletionEntryDetails", (delegate) =>
adapter.getCompletionEntryDetails.bind(adapter, delegate)
)
.wrap("getSignatureHelpItems", (delegate) =>
adapter.getSignatureHelpItems.bind(adapter, delegate)
)
.wrap("getCompletionsAtPosition", (delegate) =>
adapter.getCompletionsAtPosition.bind(adapter, delegate)
)
.build();
return proxy;
};
type FactoryProps = {
typescript: typeof typescript;
};
export const pluginModuleFactory: typescript.server.PluginModuleFactory = ({
typescript,
}: FactoryProps) => ({
create: create(typescript),
});
================================================
FILE: src/visit.ts
================================================
import ts from "typescript";
import expect from "expect";
import parseComment from "./parse-comment";
import getTemporaryFile from "./get-temporary-file";
import { getJSDocExpectTags } from "./get-expect-tag";
import { MessageBag } from "./message-bag";
export default (node: ts.Node, messageBag: MessageBag) => {
let defaultExport: ts.Identifier;
let namedExports: ts.Identifier[] = [];
try {
// Get default export
node.forEachChild((node) => {
if (ts.isExportAssignment(node) && ts.isIdentifier(node.expression)) {
defaultExport = node.expression;
}
});
// Get named exports
node.forEachChild((node) => {
if (ts.isExportDeclaration(node)) {
node.exportClause.forEachChild((item) => {
if (ts.isExportSpecifier(item)) {
namedExports = [...namedExports, item.name];
}
});
}
});
node.forEachChild((node) => {
const isTopLevel = ts.isSourceFile(node.parent);
if (ts.isFunctionDeclaration(node) && isTopLevel) {
if (hasExportModifier(node) || hasNamedExport(namedExports, node)) {
executeTest(node, messageBag);
}
if (isDefaultExport(defaultExport, node)) {
executeTest(node, messageBag, {
defaultExport: true,
});
}
}
});
} catch (err) {}
};
function hasNamedExport(
namedExports: ts.Identifier[],
node: ts.FunctionDeclaration
) {
return namedExports.find(
(item) => item.escapedText === node.name.escapedText
);
}
function isDefaultExport(
defaultExport: ts.Identifier,
node: ts.FunctionDeclaration
) {
return defaultExport && node.name.escapedText === defaultExport.escapedText;
}
function hasExportModifier(node: ts.FunctionDeclaration) {
return node.modifiers?.some(
(item) => item.kind === ts.SyntaxKind.ExportKeyword
);
}
function executeTest(
node: ts.FunctionDeclaration,
messageBag: MessageBag,
options: {
defaultExport?: boolean;
} = {}
) {
const expectTags = getJSDocExpectTags(node);
try {
var fileModule = getTemporaryFile(node);
} catch (err) {
if (err.diagnosticText) {
const [, pos, end] = err.diagnosticText.match(/\((\d+),(\d+)\)/);
messageBag.add({
pos,
end,
content: err.diagnosticText,
});
}
}
if (!fileModule) {
return;
}
for (const tag of expectTags) {
try {
const comment = parseComment(tag.comment);
if (!comment) continue;
const { matcher, params, result } = comment;
const functionName = options.defaultExport ? "default" : node.name.text;
const call = matcher
.split(".")
.reduce(
(prev, current) => prev[current],
expect(fileModule[functionName](...params))
);
if (typeof call === "function") {
call(...result);
}
} catch (err) {
messageBag.add({
pos: tag.pos,
end: tag.end,
content: err.message.replace(/\n\s*\n/g, "\n"),
});
}
}
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2015",
"outDir": "lib",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true
}
}