Skip to content

Commit

Permalink
feat: something that works
Browse files Browse the repository at this point in the history
  • Loading branch information
skarab42 committed Jul 8, 2022
1 parent 2a79ba6 commit 2bb95cf
Show file tree
Hide file tree
Showing 12 changed files with 331 additions and 81 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"eslint": "^8.18.0",
"eslint-config-prettier": "^8.5.0",
"lint-staged": "^13.0.3",
"magic-string": "^0.26.2",
"nodemon": "^2.0.19",
"prettier": "^2.7.1",
"semantic-release": "^19.0.3",
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions src/assert.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export function expectType<T = undefined>(value: unknown): T {
return value as T;
}
export function expectType<SourceType>(source?: unknown) {
const dumbFunction = <TargetType>(target?: unknown) => {
return [source, target] as [SourceType, TargetType];
};

export function expectTypeEqual<T = undefined>(value: unknown): T {
return value as T;
return {
assignableTo: dumbFunction,
};
}
32 changes: 32 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export function createErrorString({
message,
file,
line,
column,
}: {
message: string;
file: string;
line: number;
column: number;
}) {
return `
// error generated by vite-plugin-vitest-typescript-assert
(() => {
const err = new TypeError("${message}");
err.name = "TypeError";
err.stack = "";
err.stackStr = "";
err.stacks = [{
file: "${file}",
line: ${line + 1},
column: ${column},
sourcePos: {
source: "${file}",
line: ${line + 1},
column: ${column}
}
}];
throw err;
})()
`;
}
34 changes: 20 additions & 14 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,43 @@
import type { Plugin } from 'vite';
import { typeCheck } from './program';
import MagicString from 'magic-string';
import { createErrorString } from './error';

export function vitestTypescriptAssertPlugin(): Plugin {
return {
name: 'vitest:typescript-assert',
apply: 'serve',
enforce: 'pre',
// configureServer(server) {
// const prout = (path: string) => {
// setTimeout(() => {
// console.log('>>>>>>>>>>', path);
// }, 2000);
// };

// server.watcher.on('add', prout);
// server.watcher.on('change', prout);
// server.watcher.on('unlink', prout);
// },
transform(code, fileName) {
if (!fileName.endsWith('.test.ts')) {
// TODO get from config, with glob pattern
return;
}

// WTF is appening here ????
code = code.replace(/\t/g, ' ');

const result = typeCheck({
configName: 'tsconfig.check.json',
input: { fileName, code },
});

// eslint-disable-next-line no-console
console.log(result);
const newCode = new MagicString(code);

result.assertionDiagnostics?.forEach((diagnostic) => {
const column = diagnostic.position.character;
const line = diagnostic.position.line;
const message = diagnostic.message.replace(/"/g, '\\"');

const lastBlockNode = diagnostic.path[diagnostic.path.length - 1]?.node;
const lastBlockPosition = lastBlockNode?.getEnd() ?? newCode.length();
newCode.appendLeft(lastBlockPosition - 2, createErrorString({ message, file: fileName, line, column }));
});

return code;
return {
code: newCode.toString(),
map: newCode.generateMap({ hires: true }),
};
},
};
}
34 changes: 34 additions & 0 deletions src/program/assertions/checkers/assignableTo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type ts from 'typescript';
import { AssertionNode, getTypes } from '../index';
import { createAssertionDiagnostic } from '../diagnostic';

export function assignableTo(path: AssertionNode[], target: ts.CallExpression, typeChecker: ts.TypeChecker) {
const types = getTypes(target, typeChecker);

const sourceString = typeChecker.typeToString(types.source.type);
const targetString = typeChecker.typeToString(types.target.type);

// @ts-expect-error access to internal API
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const pass = typeChecker.isTypeAssignableTo(types.source.type, types.target.type) as boolean;

const position = types.source.node.getSourceFile().getLineAndCharacterOfPosition(types.source.position);

let diagnostic = undefined;

if (!pass) {
diagnostic = createAssertionDiagnostic({
message: `Type \`${sourceString}\` is not assignable to type \`${targetString}\`.`,
position,
path,
});
}

return {
pass,
fail: !pass,
source: sourceString,
target: targetString,
diagnostic,
};
}
1 change: 1 addition & 0 deletions src/program/assertions/checkers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './assignableTo';
12 changes: 12 additions & 0 deletions src/program/assertions/diagnostic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type ts from 'typescript';
import type { AssertionNode } from '.';

export interface AssertionDiagnostic {
message: string;
position: ts.LineAndCharacter;
path: AssertionNode[];
}

export function createAssertionDiagnostic(diagnostic: AssertionDiagnostic): AssertionDiagnostic {
return { ...diagnostic };
}
153 changes: 139 additions & 14 deletions src/program/assertions/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import ts from 'typescript';
import * as assertions from '../../assert';

export const assertionNames = Object.keys(assertions);

// TODO: should be set by user config?!
export const vitestBlockIdentifiers = ['describe', 'test', 'it'];
import * as checkers from './checkers';
import type { AssertionDiagnostic } from './diagnostic';

export interface AssertionNode {
name: string;
Expand All @@ -13,33 +9,162 @@ export interface AssertionNode {

export interface Assertion {
identifier: string;
assertionName: string;
path: AssertionNode[];
node: ts.CallExpression;
}

export interface AssertionResult {
pass: boolean;
fail: boolean;
source: string;
target: string;
diagnostic: AssertionDiagnostic | undefined;
}

export type AssertionChecker = (
path: AssertionNode[],
target: ts.CallExpression,
typeChecker: ts.TypeChecker,
) => AssertionResult;

export const assertionCheckers = new Map<string, AssertionChecker>(Object.entries(checkers));

// TODO: should be set by user config?!
export const vitestBlockIdentifiers = [
'it',
'it.skip',
'it.skipIf',
'it.runIf',
'it.only',
'it.concurrent',
'it.todo',
'it.fails',
'it.each',
'test',
'test.skip',
'test.skipIf',
'test.runIf',
'test.only',
'test.concurrent',
'test.todo',
'test.fails',
'test.each',
'describe',
'describe.skip',
'describe.only',
'describe.concurrent',
'describe.shuffle',
'describe.todo',
'describe.each',
];

export function getAssertions(node: ts.Node) {
const assertions: Assertion[] = [];
const sourceFile = node.getSourceFile();
const rootPath = { name: 'root', node, endPosition: { line: 0, character: 0 } };

function walkNodes(node: ts.Node, path = [rootPath]) {
const currentPath = [...path];

function walkNodes(node: ts.Node, path = [{ name: 'root', node }]) {
if (ts.isCallExpression(node)) {
const identifier = node.expression.getText();

// TODO: handle skip, only, runif, etc...
if (vitestBlockIdentifiers.includes(identifier)) {
const firstArgument = node.arguments[0];
const vitestBlockName = node.arguments[0];
const vitestTestFunction = node.arguments[1];

if (firstArgument) {
path.push({ name: firstArgument.getText().slice(1, -1), node });
if (vitestBlockName && vitestTestFunction) {
const endPosition = sourceFile.getLineAndCharacterOfPosition(vitestTestFunction.getEnd());
currentPath.push({ name: vitestBlockName.getText().slice(1, -1), node, endPosition });
} // TODO: handle the case when is not a string? (this should never append...)
} else if (assertionNames.includes(identifier)) {
// console.log('> assert:', path, ':', identifier);
assertions.push({ identifier, path, node });
} else if (identifier.startsWith('expectType')) {
const assertionName = identifier.split('.').pop();

if (assertionName && assertionCheckers.has(assertionName)) {
assertions.push({ identifier, path: currentPath, assertionName, node });
}
}
}

ts.forEachChild(node, (child) => walkNodes(child, path));
ts.forEachChild(node, (child) => walkNodes(child, currentPath));
}

walkNodes(node);

return assertions;
}

function getMiddle(node: ts.Node) {
const diff = node.getEnd() - node.getStart();
return node.getStart() + Math.round(diff / 2);
}

export function getTypes(target: ts.CallExpression, typeChecker: ts.TypeChecker) {
const source = target.expression.getChildAt(0) as ts.CallExpression;

let sourceType = undefined;
let targetType = undefined;
let sourceValue = undefined;
let targetValue = undefined;

let sourceMiddle = -1;
let targetMiddle = -1;

if (source.typeArguments?.[0]) {
sourceType = typeChecker.getTypeFromTypeNode(source.typeArguments[0]);
sourceMiddle = getMiddle(source.typeArguments[0]);
}

if (target.typeArguments?.[0]) {
targetType = typeChecker.getTypeFromTypeNode(target.typeArguments[0]);
targetMiddle = getMiddle(target.typeArguments[0]);
}

if (source.arguments[0]) {
sourceValue = typeChecker.getTypeAtLocation(source.arguments[0]);
sourceMiddle = getMiddle(source.arguments[0]);
}

if (target.arguments[0]) {
targetValue = typeChecker.getTypeAtLocation(target.arguments[0]);
targetMiddle = getMiddle(target.arguments[0]);
}

if ((sourceType && sourceValue) || (targetType && targetValue)) {
// TODO: create and add to diagnostics output and do not throw
throw new Error('Do not define a type and value at the same time, please review the arguments of your test.');
}

sourceType = sourceType ?? sourceValue;
targetType = targetType ?? targetValue;

if (!sourceType || !targetType) {
// TODO: create and add to diagnostics output and do not throw
throw new Error('A type or value is missing, please review the arguments of your test.');
}

return {
source: { node: source, position: sourceMiddle, type: sourceType },
target: { node: target, position: targetMiddle, type: targetType },
};
}

export function processAssertions(assertions: Assertion[], typeChecker: ts.TypeChecker) {
const diagnostics: AssertionDiagnostic[] = [];

assertions.forEach(({ assertionName, node, path }) => {
const assertion = assertionCheckers.get(assertionName);

if (assertion) {
const result = assertion(path, node, typeChecker);

if (result.diagnostic) {
diagnostics.push(result.diagnostic);
}
}
});

return diagnostics;
}
Loading

0 comments on commit 2bb95cf

Please sign in to comment.