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

npm mit license CI Gitter twitter

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: ![](https://i.imgur.com/kK9BlMi.gif) ## 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 ![](https://i.imgur.com/ZplL1PV.gif) > ### Test arrays ![](https://i.imgur.com/epox4Pu.gif) ## 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. 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 } }