diff --git a/README.md b/README.md index b7afec1..d5301a6 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,6 @@ To build the project: npm run build ``` -In dev mode, you can use: -```bash -npm run watch -``` - ### Start the server In dev mode: ```bash diff --git a/package.json b/package.json index c962b48..f70144e 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,12 @@ "test": "test" }, "scripts": { - "build": "rm -rf dist && tsc", - "watch": "npm run build -- --watch", + "start": "npm run build && fastify start -l info dist/app.js", + "build": "tsc", + "watch": "tsc -w", + "dev": "npm run build && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch\" \"npm:dev:start\"", + "dev:start": "fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js", "test": "npm run db:seed && tap --jobs=1 test/**/*", - "start": "fastify start -l info dist/app.js", - "dev": "fastify start -w -l info -P dist/app.js", "standalone": "node --env-file=.env dist/server.js", "lint": "eslint --ignore-pattern=dist", "lint:fix": "npm run lint -- --fix", @@ -35,7 +36,9 @@ "@fastify/swagger-ui": "^4.0.1", "@fastify/type-provider-typebox": "^4.0.0", "@fastify/under-pressure": "^8.3.0", + "@jean-michelet/workflow": "^1.0.7", "@sinclair/typebox": "^0.33.7", + "concurrently": "^8.2.2", "fastify": "^4.26.1", "fastify-cli": "^6.1.1", "fastify-plugin": "^4.0.0", @@ -47,7 +50,7 @@ "fastify-tsconfig": "^2.0.0", "mysql2": "^3.10.1", "neostandard": "^0.7.0", - "tap": "^19.2.2", + "tap": "^21.0.1", "typescript": "^5.4.5" } } diff --git a/src/app.ts b/src/app.ts index 8cf458a..23734d0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,7 @@ export default async function serviceApp( fastify: FastifyInstance, opts: FastifyPluginOptions ) { + delete opts.skipOverride // This option only serves testing purpose // This loads all external plugins defined in plugins/external // those should be registered first as your custom plugins might depend on them await fastify.register(fastifyAutoload, { diff --git a/src/plugins/custom/workflow.ts b/src/plugins/custom/workflow.ts new file mode 100644 index 0000000..7a268b4 --- /dev/null +++ b/src/plugins/custom/workflow.ts @@ -0,0 +1,33 @@ +import fp from 'fastify-plugin'; +import { MultiOriginTransition, Transition, Workflow } from '@jean-michelet/workflow'; +import { Task, TaskStatus, TaskTransitions } from '../../schemas/tasks.js'; + +declare module "fastify" { + export interface FastifyInstance { + taskWorkflow: ReturnType; + } + } + +function createTaskWorkflow() { + const wf = new Workflow({ + stateProperty: 'status' + }) + + wf.addTransition(TaskTransitions.Start, new Transition(TaskStatus.New, TaskStatus.InProgress)); + wf.addTransition(TaskTransitions.Complete, new Transition(TaskStatus.InProgress, TaskStatus.Completed)); + wf.addTransition(TaskTransitions.Hold, new Transition(TaskStatus.InProgress, TaskStatus.OnHold)); + wf.addTransition(TaskTransitions.Resume, new Transition(TaskStatus.OnHold, TaskStatus.InProgress)); + wf.addTransition(TaskTransitions.Cancel, new MultiOriginTransition( + [TaskStatus.New, TaskStatus.InProgress, TaskStatus.OnHold], + TaskStatus.Canceled + )); + wf.addTransition(TaskTransitions.Archive, new Transition(TaskStatus.Completed, TaskStatus.Archived)); + + return wf +} + +export default fp(async (fastify) => { + fastify.decorate('taskWorkflow', createTaskWorkflow()); +}, { + name: 'workflow' +}); \ No newline at end of file diff --git a/src/plugins/external/1-env.ts b/src/plugins/external/env.ts similarity index 100% rename from src/plugins/external/1-env.ts rename to src/plugins/external/env.ts diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 2d8f29b..78e78a3 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -2,7 +2,15 @@ import { FastifyPluginAsyncTypebox, Type } from "@fastify/type-provider-typebox"; -import { TaskSchema } from "../../../schemas/tasks.js"; +import { + TaskSchema, + Task, + CreateTaskSchema, + UpdateTaskSchema, + TaskStatus, + PatchTaskTransitionSchema +} from "../../../schemas/tasks.js"; +import { FastifyReply } from "fastify"; const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get( @@ -16,9 +24,166 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } }, async function () { - return [{ id: 1, name: "Do something..." }]; + const tasks = await fastify.repository.findMany("tasks"); + + return tasks; + } + ); + + fastify.get( + "/:id", + { + schema: { + params: Type.Object({ + id: Type.Number() + }), + response: { + 200: TaskSchema, + 404: Type.Object({ message: Type.String() }) + }, + tags: ["Tasks"] + } + }, + async function (request, reply) { + const { id } = request.params; + const task = await fastify.repository.find("tasks", { where: { id } }); + + if (!task) { + return notFound(reply); + } + + return task; + } + ); + + fastify.post( + "/", + { + schema: { + body: CreateTaskSchema, + response: { + 201: { + id: Type.Number() + } + }, + tags: ["Tasks"] + } + }, + async function (request, reply) { + const id = await fastify.repository.create("tasks", { data: {...request.body, status: TaskStatus.New} }); + reply.code(201); + + return { + id + }; + } + ); + + fastify.patch( + "/:id", + { + schema: { + params: Type.Object({ + id: Type.Number() + }), + body: UpdateTaskSchema, + response: { + 200: TaskSchema, + 404: Type.Object({ message: Type.String() }) + }, + tags: ["Tasks"] + } + }, + async function (request, reply) { + const { id } = request.params; + const affectedRows = await fastify.repository.update("tasks", { + data: request.body, + where: { id } + }); + + if (affectedRows === 0) { + return notFound(reply) + } + + const task = await fastify.repository.find("tasks", { where: { id } }); + + return task as Task; + } + ); + + fastify.patch( + "/:id/transition", + { + schema: { + params: Type.Object({ + id: Type.Number() + }), + body: PatchTaskTransitionSchema, + response: { + 200: Type.Object({ message: Type.String() }), + 404: Type.Object({ message: Type.String() }) + }, + tags: ["Tasks"] + } + }, + async function (request, reply) { + const { id } = request.params; + const { transition } = request.body; + + const task = await fastify.repository.find("tasks", { select: 'status', where: { id } }); + + if (!task) { + return notFound(reply); + } + + if (!fastify.taskWorkflow.can(transition, task)) { + reply.status(400) + return { message: `Transition "${transition}" can not be applied to task with status "${task.status}"` } + } + + fastify.taskWorkflow.apply(transition, task) + + await fastify.repository.update("tasks", { + data: { status: task.status }, + where: { id } + }); + + return { + message: "Status changed" + }; + } + ); + + fastify.delete( + "/:id", + { + schema: { + params: Type.Object({ + id: Type.Number() + }), + response: { + 204: Type.Null(), + 404: Type.Object({ message: Type.String() }) + }, + tags: ["Tasks"] + } + }, + async function (request, reply) { + const { id } = request.params; + const affectedRows = await fastify.repository.delete("tasks", { id }); + + if (affectedRows === 0) { + return notFound(reply) + } + + reply.code(204).send(null); } ); }; -export default plugin; +function notFound(reply: FastifyReply) { + reply.code(404) + return { message: "Task not found" } +} + +export default plugin; \ No newline at end of file diff --git a/src/schemas/tasks.ts b/src/schemas/tasks.ts index 6f506c5..c987264 100644 --- a/src/schemas/tasks.ts +++ b/src/schemas/tasks.ts @@ -1,6 +1,55 @@ -import { Type } from "@sinclair/typebox"; +import { Static, Type } from "@sinclair/typebox"; + +export const TaskTransitions = { + Start: "start", + Complete: "complete", + Hold: "hold", + Resume: "resume", + Cancel: "cancel", + Archive: "archive" +} as const; + +export const TaskStatus = { + New: 'new', + InProgress: 'in-progress', + OnHold: 'on-hold', + Completed: 'completed', + Canceled: 'canceled', + Archived: 'archived' +} as const; + +export type TaskStatusType = typeof TaskStatus[keyof typeof TaskStatus]; export const TaskSchema = Type.Object({ id: Type.Number(), - name: Type.String() + name: Type.String(), + author_id: Type.Number(), + assigned_user_id: Type.Optional(Type.Number()), + status: Type.String(), + created_at: Type.String({ format: "date-time" }), + updated_at: Type.String({ format: "date-time" }) }); + +export interface Task extends Static {} + +export const CreateTaskSchema = Type.Object({ + name: Type.String(), + author_id: Type.Number(), + assigned_user_id: Type.Optional(Type.Number()) +}); + +export const UpdateTaskSchema = Type.Object({ + name: Type.Optional(Type.String()), + assigned_user_id: Type.Optional(Type.Number()) +}); + +export const PatchTaskTransitionSchema = Type.Object({ + transition: Type.Union([ + Type.Literal(TaskTransitions.Start), + Type.Literal(TaskTransitions.Complete), + Type.Literal(TaskTransitions.Hold), + Type.Literal(TaskTransitions.Resume), + Type.Literal(TaskTransitions.Cancel), + Type.Literal(TaskTransitions.Archive) + ]) +}); \ No newline at end of file diff --git a/test/helper.ts b/test/helper.ts index ce16fe9..14c00f3 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,21 +1,36 @@ // This file contains code that we reuse // between our tests. -import { InjectOptions } from "fastify"; +import { FastifyInstance, InjectOptions } from "fastify"; import { build as buildApplication } from "fastify-cli/helper.js"; import path from "node:path"; import { TestContext } from "node:test"; +declare module "fastify" { + interface FastifyInstance { + login: typeof login; + injectWithLogin: typeof injectWithLogin + } +} + + const AppPath = path.join(import.meta.dirname, "../src/app.ts"); // Fill in this config with all the configurations // needed for testing the application export function config() { - return {}; + return { + skipOverride: "true" // Register our application with fastify-plugin + }; } +const tokens: Record = {} // We will create different users with different roles -async function login(username: string) { +async function login(this: FastifyInstance, username: string) { + if (tokens[username]) { + return tokens[username] + } + const res = await this.inject({ method: "POST", url: "/api/auth/login", @@ -25,9 +40,20 @@ async function login(username: string) { } }); - return JSON.parse(res.payload).token; + tokens[username] = JSON.parse(res.payload).token; + + return tokens[username] } +async function injectWithLogin(this: FastifyInstance, username: string, opts: InjectOptions) { + opts.headers = { + ...opts.headers, + Authorization: `Bearer ${await this.login(username)}` + }; + + return this.inject(opts); +}; + // automatically build and tear down our instance export async function build(t: TestContext) { // you can set all the options supported by the fastify CLI command @@ -36,18 +62,11 @@ export async function build(t: TestContext) { // fastify-plugin ensures that all decorators // are exposed for testing purposes, this is // different from the production setup - const app = await buildApplication(argv, config()); + const app = await buildApplication(argv, config()) as FastifyInstance; app.login = login; - app.injectWithLogin = async (username: string, opts: InjectOptions) => { - opts.headers = { - ...opts.headers, - Authorization: `Bearer ${await app.login(username)}` - }; - - return app.inject(opts); - }; + app.injectWithLogin = injectWithLogin // close the app after we are done t.after(() => app.close()); diff --git a/test/plugins/repository.test.ts b/test/plugins/repository.test.ts index 2f143a7..4244f63 100644 --- a/test/plugins/repository.test.ts +++ b/test/plugins/repository.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { execSync } from "child_process"; import Fastify from "fastify"; import repository from "../../src/plugins/custom/repository.js"; -import * as envPlugin from "../../src/plugins/external/1-env.js"; +import * as envPlugin from "../../src/plugins/external/env.js"; import * as mysqlPlugin from "../../src/plugins/external/mysql.js"; import { Auth } from '../../src/schemas/auth.js'; diff --git a/test/plugins/scrypt.test.ts b/test/plugins/scrypt.test.ts index 5b8e6ac..2d2f6d6 100644 --- a/test/plugins/scrypt.test.ts +++ b/test/plugins/scrypt.test.ts @@ -1,6 +1,6 @@ import { test } from "tap"; import Fastify from "fastify"; -import scryptPlugin from "../../src/plugins/custom/scrypt.ts"; +import scryptPlugin from "../../src/plugins/custom/scrypt.js"; test("scrypt works standalone", async t => { const app = Fastify(); diff --git a/test/plugins/workflow.test.ts b/test/plugins/workflow.test.ts new file mode 100644 index 0000000..d6d9c81 --- /dev/null +++ b/test/plugins/workflow.test.ts @@ -0,0 +1,86 @@ +import Fastify, { FastifyInstance } from "fastify"; +import { Task, TaskStatus, TaskTransitions } from "../../src/schemas/tasks.js"; +import workflowPlugin from "../../src/plugins/custom/workflow.js"; +import assert from "assert"; +import { after, before, describe, it } from "node:test"; + +describe("workflow", () => { + let app: FastifyInstance; + + before(async () => { + app = Fastify(); + app.register(workflowPlugin); + + await app.ready() + }); + + after(() => app.close()); + + it("Start transition", async () => { + const task = { status: TaskStatus.New } as Task; + assert.ok(app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(app.taskWorkflow.can(TaskTransitions.Cancel, task)); + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); + }); + + it("In Progress transitions", async () => { + const task = { status: TaskStatus.InProgress } as Task; + assert.ok(app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(app.taskWorkflow.can(TaskTransitions.Cancel, task)); + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); + }); + + it("On Hold transitions", async () => { + + const task = { status: TaskStatus.OnHold } as Task; + assert.ok(app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(app.taskWorkflow.can(TaskTransitions.Cancel, task)); + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); + }); + + it("Completed transitions", async () => { + + const task = { status: TaskStatus.Completed } as Task; + assert.ok(app.taskWorkflow.can(TaskTransitions.Archive, task)); + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Cancel, task)); + }); + + it("Canceled transitions", async () => { + + const task = { status: TaskStatus.Canceled } as Task; + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); + }); + + it("Archived transitions", async () => { + + const task = { status: TaskStatus.Archived } as Task; + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Cancel, task)); + }); +}); \ No newline at end of file diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index 8dc2d33..ab5a4dc 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -1,17 +1,242 @@ -import { test } from "node:test"; +import { describe, it } from "node:test"; import assert from "node:assert"; import { build } from "../../../helper.js"; +import { Task, TaskStatus, TaskTransitions } from "../../../../src/schemas/tasks.js"; +import { FastifyInstance } from "fastify"; -test("GET /api/tasks with valid JWT Token should return 200", async (t) => { - const app = await build(t); +async function createTask(app: FastifyInstance, taskData: Partial) { + return await app.repository.create("tasks", { data: taskData }); +} - const res = await app.injectWithLogin("basic", { - method: "GET", - url: "/api/tasks" +describe('Tasks api (logged user only)', () => { + describe('GET /api/tasks', () => { + it("should return a list of tasks", async (t) => { + const app = await build(t); + + const taskData = { + name: "New Task", + author_id: 1, + status: TaskStatus.New + }; + + const newTaskId = await app.repository.create("tasks", { data: taskData }); + + const res = await app.injectWithLogin("basic", { + method: "GET", + url: "/api/tasks" + }); + + assert.strictEqual(res.statusCode, 200); + const tasks = JSON.parse(res.payload) as Task[]; + const createdTask = tasks.find((task) => task.id === newTaskId); + assert.ok(createdTask, "Created task should be in the response"); + + assert.deepStrictEqual(taskData.name, createdTask.name); + assert.strictEqual(taskData.author_id, createdTask.author_id); + assert.strictEqual(taskData.status, createdTask.status); + }); + }) + + describe('GET /api/tasks/:id', () => { + it("should return a task", async (t) => { + const app = await build(t); + + const taskData = { + name: "Single Task", + author_id: 1, + status: TaskStatus.New + }; + + const newTaskId = await createTask(app, taskData); + + const res = await app.injectWithLogin("basic", { + method: "GET", + url: `/api/tasks/${newTaskId}` + }); + + assert.strictEqual(res.statusCode, 200); + const task = JSON.parse(res.payload) as Task; + assert.equal(task.id, newTaskId); + }); + + it("should return 404 if task is not found", async (t) => { + const app = await build(t); + + const res = await app.injectWithLogin("basic", { + method: "GET", + url: "/api/tasks/9999" + }); + + assert.strictEqual(res.statusCode, 404); + const payload = JSON.parse(res.payload); + assert.strictEqual(payload.message, "Task not found"); + }); + }); + + describe('POST /api/tasks', () => { + it("should create a new task", async (t) => { + const app = await build(t); + + const taskData = { + name: "New Task", + author_id: 1 + }; + + const res = await app.injectWithLogin("basic", { + method: "POST", + url: "/api/tasks", + payload: taskData + }); + + assert.strictEqual(res.statusCode, 201); + const { id } = JSON.parse(res.payload); + + const createdTask = await app.repository.find("tasks", { select: 'name', where: { id } }) as Task; + assert.equal(createdTask.name, taskData.name); + }); + }); + + describe('PATCH /api/tasks/:id', () => { + it("should update an existing task", async (t) => { + const app = await build(t); + + const taskData = { + name: "Task to Update", + author_id: 1, + status: TaskStatus.New + }; + const newTaskId = await createTask(app, taskData); + + const updatedData = { + name: "Updated Task" + }; + + const res = await app.injectWithLogin("basic", { + method: "PATCH", + url: `/api/tasks/${newTaskId}`, + payload: updatedData + }); + + assert.strictEqual(res.statusCode, 200); + const updatedTask = await app.repository.find("tasks", { where: { id: newTaskId } }) as Task; + assert.equal(updatedTask.name, updatedData.name); + }); + + it("should return 404 if task is not found for update", async (t) => { + const app = await build(t); + + const updatedData = { + name: "Updated Task" + }; + + const res = await app.injectWithLogin("basic", { + method: "PATCH", + url: "/api/tasks/9999", + payload: updatedData + }); + + assert.strictEqual(res.statusCode, 404); + const payload = JSON.parse(res.payload); + assert.strictEqual(payload.message, "Task not found"); + }); + }); + + describe('PATCH /api/tasks/:id/transition', () => { + it("should apply transition if valid", async (t) => { + const app = await build(t); + + const taskData = { + name: "Task to Patch", + author_id: 1, + status: TaskStatus.New + }; + const newTaskId = await createTask(app, taskData); + + const patchData = { transition: TaskTransitions.Start}; + + const res = await app.injectWithLogin("basic", { + method: "PATCH", + url: `/api/tasks/${newTaskId}/transition`, + payload: patchData + }); + + assert.strictEqual(res.statusCode, 200); + const message = JSON.parse(res.payload).message; + assert.strictEqual(message, "Status changed"); + + const patchedTask = await app.repository.find("tasks", { where: { id: newTaskId } }) as Task; + assert.strictEqual(patchedTask.status, TaskStatus.InProgress); + + const res2 = await app.injectWithLogin("basic", { + method: "PATCH", + url: `/api/tasks/${newTaskId}/transition`, + payload: { ...patchData, transition: 'invalid' } + }); + + assert.strictEqual(res2.statusCode, 400); + + const res3 = await app.injectWithLogin("basic", { + method: "PATCH", + url: `/api/tasks/${newTaskId}/transition`, + payload: { ...patchData, transition: TaskTransitions.Start } + }); + + assert.strictEqual(res3.statusCode, 400); + assert.deepStrictEqual(JSON.parse(res3.body), { + message: `Transition "${TaskTransitions.Start}" can not be applied to task with status "in-progress"` + }) + }); + + it("should return 404 if task is not found", async (t) => { + const app = await build(t); + + const patchData = { transition: TaskTransitions.Start }; + + const res = await app.injectWithLogin("basic", { + method: "PATCH", + url: "/api/tasks/9999/transition", + payload: patchData + }); + + assert.strictEqual(res.statusCode, 404); + const payload = JSON.parse(res.payload); + assert.strictEqual(payload.message, "Task not found"); + }); }); - assert.strictEqual(res.statusCode, 200); - assert.deepStrictEqual(JSON.parse(res.payload), [ - { id: 1, name: "Do something..." } - ]); -}); + describe('DELETE /api/tasks/:id', () => { + it("should delete an existing task", async (t) => { + const app = await build(t); + + const taskData = { + name: "Task to Delete", + author_id: 1, + status: TaskStatus.New + }; + const newTaskId = await createTask(app, taskData); + + const res = await app.injectWithLogin("basic", { + method: "DELETE", + url: `/api/tasks/${newTaskId}` + }); + + assert.strictEqual(res.statusCode, 204); + + const deletedTask = await app.repository.find("tasks", { where: { id: newTaskId } }); + assert.strictEqual(deletedTask, null); + }); + + it("should return 404 if task is not found for deletion", async (t) => { + const app = await build(t); + + const res = await app.injectWithLogin("basic", { + method: "DELETE", + url: "/api/tasks/9999" + }); + + assert.strictEqual(res.statusCode, 404); + const payload = JSON.parse(res.payload); + assert.strictEqual(payload.message, "Task not found"); + }); + }); +}) diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..f69976d --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "noEmit": false + }, + "include": ["@types", "../src/**/*.ts", "**/*.ts"] +} \ No newline at end of file