feat: add vitest, vite, move to tdd

This commit is contained in:
2026-07-13 07:07:44 +05:00
parent 4704a1d5a0
commit 62862beb06
18 changed files with 2043 additions and 346 deletions
+60
View File
@@ -0,0 +1,60 @@
# Vitest + Clean Architecture Refactoring
## Цель
Установить Vitest, переделать архитектуру ближе к чистой (порты/адаптеры),
написать нехрупкие тесты на реальные юзкейсы.
## Изменения
### Новые файлы
```txt
src/
├── ports.ts # Интерфейсы: IFileSystem, ITaskRepository, IConfigProvider
├── storage/
│ ├── FsTaskRepository.ts # ITaskRepository → зависит только от IFileSystem, markdown, uuid
│ ├── VscodeConfigProvider.ts # IConfigProvider → зависит от vscode
│ └── NodeFileSystem.ts # IFileSystem → обёртка над fs.promises
tests/
├── utils/
│ ├── markdown.test.ts
│ └── uuid.test.ts
├── storage/
│ ├── FsTaskRepository.test.ts
│ └── helpers/
│ └── InMemoryFileSystem.ts
```
### Удалить
- `src/storage/taskStore.ts`
- `src/storage/configStore.ts`
### Изменить
- `src/views/taskTreeProvider.ts` — конструктор принимает `ITaskRepository` +
`IConfigProvider`
- `src/extension.ts` — сборка зависимостей (DI)
### Инфраструктура
- `npm i -D vitest`
- `vitest.config.ts`
- `package.json` — скрипты `test`, `test:run`
## Тесты
| Тест | I/O | Что проверяет |
| ------------------ | ------------------------ | -------------------------------------------- |
| FsTaskRepository | InMemoryFileSystem (Map) | CRUD, фильтрация по extension, контент файла |
| markdown | — | parse, serialize, round-trip, default-ы |
| uuid | — | валидный формат, уникальность |
| InMemoryFileSystem | — | read/write/readdir/mkdir |
## Что НЕ тестируем (пока)
- NodeFileSystem — тривиальная обёртка
- VscodeConfigProvider — требует Extension Host
- TaskTreeProvider — UI, требует Extension Host
+1487 -206
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -88,17 +88,21 @@
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"build": "vite build",
"watch": "vite build --watch",
"vscode:prepublish": "npm run build",
"lint": "tsc --noEmit",
"format": "prettier --write ."
"format": "prettier --write .",
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"@types/vscode": "^1.96.0",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vite": "^8.1.4",
"vitest": "^4.1.10"
},
"dependencies": {
"gray-matter": "^4.0.3",
+14 -8
View File
@@ -1,13 +1,18 @@
import * as path from "path";
import * as vscode from "vscode";
import { ConfigStore } from "./storage/configStore";
import { TaskStore } from "./storage/taskStore";
import { TaskTreeProvider } from "./views/taskTreeProvider";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { NodeFileSystem } from "@/storage/NodeFileSystem";
import { VscodeConfigProvider } from "@/storage/VscodeConfigProvider";
import { TaskTreeProvider } from "@/views/taskTreeProvider";
export function activate(context: vscode.ExtensionContext) {
const taskStore = new TaskStore();
const configStore = new ConfigStore();
const taskTreeProvider = new TaskTreeProvider();
const fileSystem = new NodeFileSystem();
const configStore = new VscodeConfigProvider();
const taskStore = new FsTaskRepository(
fileSystem,
configStore.getFileExtension(),
);
const taskTreeProvider = new TaskTreeProvider(taskStore, configStore);
vscode.window.registerTreeDataProvider(
"projectTasks.tasks",
@@ -16,7 +21,8 @@ export function activate(context: vscode.ExtensionContext) {
const projectPath = configStore.getProjectTaskPath();
if (projectPath) {
const taskPattern = path.join(projectPath, "*.task.md").replace(/\\/g, "/");
const ext = configStore.getFileExtension();
const taskPattern = path.join(projectPath, `*${ext}`).replace(/\\/g, "/");
const watcher = vscode.workspace.createFileSystemWatcher(taskPattern);
watcher.onDidCreate(() => taskTreeProvider.refresh());
watcher.onDidChange(() => taskTreeProvider.refresh());
@@ -48,7 +54,7 @@ export function activate(context: vscode.ExtensionContext) {
return;
}
await taskStore.createTask(projectPath, {
await taskStore.create(projectPath, {
title,
description: "",
status: "todo",
+26
View File
@@ -0,0 +1,26 @@
import { Task } from "@/model/task";
export interface IFileSystem {
readFile(path: string): Promise<string>;
writeFile(path: string, content: string): Promise<void>;
deleteFile(path: string): Promise<void>;
readdir(path: string): Promise<string[]>;
mkdir(path: string): Promise<void>;
}
export interface ITaskRepository {
list(folderPath: string): Promise<Task[]>;
getById(folderPath: string, id: string): Promise<Task | undefined>;
create(
folderPath: string,
task: Omit<Task, "id" | "created" | "updated">,
): Promise<Task>;
update(folderPath: string, task: Task): Promise<void>;
delete(folderPath: string, id: string): Promise<void>;
}
export interface IConfigProvider {
getProjectTaskPath(): string | undefined;
getGlobalTaskPath(): string | undefined;
getFileExtension(): string;
}
+104
View File
@@ -0,0 +1,104 @@
import { Task } from "@/model/task";
import { IFileSystem, ITaskRepository } from "@/ports";
import { parseTaskFile, serializeTask } from "@/utils/markdown";
import { generateId } from "@/utils/uuid";
export class FsTaskRepository implements ITaskRepository {
constructor(
private fs: IFileSystem,
private fileExtension: string,
) {}
async list(folderPath: string): Promise<Task[]> {
let entries: string[];
try {
entries = await this.fs.readdir(folderPath);
} catch {
return [];
}
const files = entries.filter((name) => name.endsWith(this.fileExtension));
const tasks: Task[] = [];
for (const file of files) {
try {
const fullPath = this.joinPath(folderPath, file);
const content = await this.fs.readFile(fullPath);
const task = parseTaskFile(content);
if (!task.id) continue;
tasks.push(task);
} catch {
// skip unparseable files
}
}
return tasks.sort(
(a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime(),
);
}
async getById(folderPath: string, id: string): Promise<Task | undefined> {
const tasks = await this.list(folderPath);
return tasks.find((t) => t.id === id);
}
async create(
folderPath: string,
task: Omit<Task, "id" | "created" | "updated">,
): Promise<Task> {
const now = new Date().toISOString();
const newTask: Task = {
...task,
id: generateId(),
created: now,
updated: now,
};
await this.fs.mkdir(folderPath);
const filePath = this.taskFilePath(folderPath, newTask);
await this.fs.writeFile(filePath, serializeTask(newTask));
return newTask;
}
async update(folderPath: string, task: Task): Promise<void> {
const existing = await this.getById(folderPath, task.id);
const updatedTask = { ...task, updated: new Date().toISOString() };
const newPath = this.taskFilePath(folderPath, updatedTask);
if (existing) {
const oldPath = this.taskFilePath(folderPath, existing);
if (oldPath !== newPath) {
await this.fs.deleteFile(oldPath).catch(() => {});
}
}
await this.fs.writeFile(newPath, serializeTask(updatedTask));
}
async delete(folderPath: string, id: string): Promise<void> {
const task = await this.getById(folderPath, id);
if (!task) return;
const filePath = this.taskFilePath(folderPath, task);
try {
await this.fs.deleteFile(filePath);
} catch {
// already gone
}
}
private taskFilePath(folderPath: string, task: Task): string {
const safeName = task.title
.toLowerCase()
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80);
return this.joinPath(folderPath, `${safeName}${this.fileExtension}`);
}
private joinPath(...segments: string[]): string {
return segments.join("/").replace(/\\/g, "/");
}
}
+25
View File
@@ -0,0 +1,25 @@
import * as fs from "fs";
import { IFileSystem } from "@/ports";
export class NodeFileSystem implements IFileSystem {
async readFile(path: string): Promise<string> {
return fs.promises.readFile(path, "utf-8");
}
async writeFile(path: string, content: string): Promise<void> {
await fs.promises.writeFile(path, content, "utf-8");
}
async deleteFile(path: string): Promise<void> {
await fs.promises.unlink(path);
}
async readdir(path: string): Promise<string[]> {
const entries = await fs.promises.readdir(path, { withFileTypes: true });
return entries.filter((e) => e.isFile()).map((e) => e.name);
}
async mkdir(path: string): Promise<void> {
await fs.promises.mkdir(path, { recursive: true });
}
}
@@ -1,7 +1,8 @@
import * as path from "path";
import * as vscode from "vscode";
import { IConfigProvider } from "@/ports";
export class ConfigStore {
export class VscodeConfigProvider implements IConfigProvider {
getProjectTaskPath(): string | undefined {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) return undefined;
@@ -13,8 +14,7 @@ export class ConfigStore {
getGlobalTaskPath(): string | undefined {
const config = vscode.workspace.getConfiguration("projectTasks");
const globalPath = config.get<string>("globalPath");
return globalPath || undefined;
return config.get<string>("globalPath") || undefined;
}
getFileExtension(): string {
-114
View File
@@ -1,114 +0,0 @@
import * as vscode from "vscode";
import * as path from "path";
import * as fs from "fs";
import { Task } from "../model/task";
import { parseTaskFile, serializeTask } from "../utils/markdown";
import { generateId } from "../utils/uuid";
export class TaskStore {
private fileExtension: string;
constructor() {
const config = vscode.workspace.getConfiguration("projectTasks");
this.fileExtension = config.get<string>("fileExtension") ?? ".task.md";
}
getProjectTaskPath(): string | undefined {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) return undefined;
const config = vscode.workspace.getConfiguration("projectTasks");
const relativePath = config.get<string>("projectPath") ?? ".vscode/tasks";
return path.join(workspaceRoot, relativePath);
}
getGlobalTaskPath(): string | undefined {
const config = vscode.workspace.getConfiguration("projectTasks");
const globalPath = config.get<string>("globalPath");
return globalPath || undefined;
}
async listTasks(folderPath: string): Promise<Task[]> {
try {
await fs.promises.mkdir(folderPath, { recursive: true });
} catch {
return [];
}
const entries = await fs.promises.readdir(folderPath, {
withFileTypes: true,
});
const files = entries.filter(
(e) => e.isFile() && e.name.endsWith(this.fileExtension),
);
const tasks: Task[] = [];
for (const file of files) {
try {
const content = await fs.promises.readFile(
path.join(folderPath, file.name),
"utf-8",
);
tasks.push(parseTaskFile(content));
} catch (err) {
console.error(`Failed to parse task file ${file.name}:`, err);
}
}
return tasks.sort(
(a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime(),
);
}
async getTask(folderPath: string, taskId: string): Promise<Task | undefined> {
const tasks = await this.listTasks(folderPath);
return tasks.find((t) => t.id === taskId);
}
async createTask(
folderPath: string,
task: Omit<Task, "id" | "created" | "updated">,
): Promise<Task> {
const now = new Date().toISOString();
const newTask: Task = {
...task,
id: generateId(),
created: now,
updated: now,
};
await fs.promises.mkdir(folderPath, { recursive: true });
const filePath = this.taskFilePath(folderPath, newTask);
await fs.promises.writeFile(filePath, serializeTask(newTask), "utf-8");
return newTask;
}
async updateTask(folderPath: string, task: Task): Promise<void> {
const updatedTask = { ...task, updated: new Date().toISOString() };
const filePath = this.taskFilePath(folderPath, updatedTask);
await fs.promises.writeFile(filePath, serializeTask(updatedTask), "utf-8");
}
async deleteTask(folderPath: string, taskId: string): Promise<void> {
const task = await this.getTask(folderPath, taskId);
if (!task) return;
const filePath = this.taskFilePath(folderPath, task);
try {
await fs.promises.unlink(filePath);
} catch (err) {
console.error(`Failed to delete task file:`, err);
}
}
private taskFilePath(folderPath: string, task: Task): string {
const safeName = task.title
.toLowerCase()
.replace(/[^a-z0-9\u0400-\u04FF]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80);
return path.join(folderPath, `${safeName}${this.fileExtension}`);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { parseTaskFile, serializeTask } from "./markdown";
import { Task } from "@/model/task";
const fullTask: Task = {
id: "550e8400-e29b-41d4-a716-446655440000",
title: "Пофиксить баг логина",
description: "Ошибка валидации токена",
status: "in-progress",
priority: "high",
tags: ["bug", "frontend"],
assignee: "ivanov",
created: "2026-07-12T10:00:00.000Z",
updated: "2026-07-12T10:00:00.000Z",
};
describe("markdown", () => {
it("parses a .task.md file with full frontmatter", () => {
const content = [
"---",
'id: "550e8400-e29b-41d4-a716-446655440000"',
'title: "Пофиксить баг логина"',
"status: in-progress",
"priority: high",
"tags: [bug, frontend]",
'assignee: "ivanov"',
"created: 2026-07-12T10:00:00.000Z",
"updated: 2026-07-12T10:00:00.000Z",
"---",
"Ошибка валидации токена",
].join("\n");
const task = parseTaskFile(content);
expect(task).toEqual(fullTask);
});
it("fills defaults for missing fields", () => {
const content = [
"---",
'id: "abc"',
'title: "Minimal"',
"---",
"",
].join("\n");
const task = parseTaskFile(content);
expect(task.id).toBe("abc");
expect(task.title).toBe("Minimal");
expect(task.description).toBe("");
expect(task.status).toBe("todo");
expect(task.priority).toBe("medium");
expect(task.tags).toEqual([]);
expect(task.assignee).toBe("");
expect(task.created).toBeTruthy();
expect(task.updated).toBeTruthy();
});
it("serializeTask produces valid frontmatter", () => {
const result = serializeTask(fullTask);
expect(result).toContain("---");
expect(result).toContain("title: Пофиксить баг логина");
expect(result).toContain("status: in-progress");
expect(result).toContain("Ошибка валидации токена");
});
it("round-trip: serialize → parse produces the same task", () => {
const serialized = serializeTask(fullTask);
const parsed = parseTaskFile(serialized);
expect(parsed).toEqual(fullTask);
});
it("description with utf-8 survives round-trip", () => {
const task: Task = {
...fullTask,
description: "Тестовая задача с кириллицей\nи переносом строки",
};
const serialized = serializeTask(task);
const parsed = parseTaskFile(serialized);
expect(parsed.description).toBe(task.description);
});
});
+9 -3
View File
@@ -1,5 +1,11 @@
import matter from "gray-matter";
import { Task } from "../model/task";
import { Task } from "@/model/task";
function toISO(value: unknown): string {
if (value instanceof Date) return value.toISOString();
if (typeof value === "string") return value;
return new Date().toISOString();
}
export function parseTaskFile(content: string): Task {
const parsed = matter(content);
@@ -11,8 +17,8 @@ export function parseTaskFile(content: string): Task {
priority: parsed.data.priority ?? "medium",
tags: parsed.data.tags ?? [],
assignee: parsed.data.assignee ?? "",
created: parsed.data.created ?? new Date().toISOString(),
updated: parsed.data.updated ?? new Date().toISOString(),
created: toISO(parsed.data.created),
updated: toISO(parsed.data.updated),
};
}
+7 -6
View File
@@ -1,7 +1,6 @@
import * as vscode from "vscode";
import { Task, TaskStatus } from "../model/task";
import { TaskStore } from "../storage/taskStore";
import { ConfigStore } from "../storage/configStore";
import { Task, TaskStatus } from "@/model/task";
import { IConfigProvider, ITaskRepository } from "@/ports";
const STATUS_ORDER: TaskStatus[] = ["todo", "in-progress", "done", "cancelled"];
@@ -30,8 +29,10 @@ export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem
>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private taskStore = new TaskStore();
private configStore = new ConfigStore();
constructor(
private taskStore: ITaskRepository,
private configStore: IConfigProvider,
) {}
refresh(): void {
this._onDidChangeTreeData.fire();
@@ -54,7 +55,7 @@ export class TaskTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem
return [new vscode.TreeItem("Open a workspace folder to start")];
}
const tasks = await this.taskStore.listTasks(projectPath);
const tasks = await this.taskStore.list(projectPath);
return STATUS_ORDER.map((status) => {
const groupTasks = tasks.filter((t) => t.status === status);
+42
View File
@@ -0,0 +1,42 @@
import { IFileSystem } from "@/ports";
export class InMemoryFileSystem implements IFileSystem {
private files = new Map<string, string>();
reset(): void {
this.files.clear();
}
async readFile(path: string): Promise<string> {
const content = this.files.get(path);
if (content === undefined) {
throw new Error(`ENOENT: no such file: ${path}`);
}
return content;
}
async writeFile(path: string, content: string): Promise<void> {
this.files.set(path, content);
}
async deleteFile(path: string): Promise<void> {
this.files.delete(path);
}
async readdir(path: string): Promise<string[]> {
const prefix = path.endsWith("/") || path.endsWith("\\") ? path : path + "/";
const seen = new Set<string>();
for (const key of this.files.keys()) {
if (key.startsWith(prefix)) {
const relative = key.slice(prefix.length);
const top = relative.split(/[/\\]/)[0];
if (top) seen.add(top);
}
}
return [...seen];
}
async mkdir(_path: string): Promise<void> {
// no-op for in-memory
}
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach } from "vitest";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { InMemoryFileSystem } from "../helpers/InMemoryFileSystem";
import { ITaskRepository } from "@/ports";
import { Task } from "@/model/task";
describe("FsTaskRepository", () => {
let fs: InMemoryFileSystem;
let repo: ITaskRepository;
const folder = "/tasks";
const sampleTask = {
title: "Fix login bug",
description: "Token validation error",
status: "todo" as const,
priority: "high" as const,
tags: ["bug", "frontend"],
assignee: "ivanov",
};
beforeEach(() => {
fs = new InMemoryFileSystem();
repo = new FsTaskRepository(fs, ".task.md");
});
it("creates a task and writes it as a .task.md file", async () => {
const task = await repo.create(folder, sampleTask);
expect(task.id).toBeTruthy();
expect(task.title).toBe("Fix login bug");
expect(task.created).toBeTruthy();
expect(task.updated).toBe(task.created);
const files = await fs.readdir(folder);
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/\.task\.md$/);
const content = await fs.readFile(`${folder}/${files[0]}`);
expect(content).toContain("Fix login bug");
expect(content).toContain("status: todo");
});
it("list returns created tasks sorted by updated desc", async () => {
const a = await repo.create(folder, { ...sampleTask, title: "A" });
await delay(10);
const b = await repo.create(folder, { ...sampleTask, title: "B" });
const tasks = await repo.list(folder);
expect(tasks).toHaveLength(2);
expect(tasks[0].title).toBe("B");
expect(tasks[1].title).toBe("A");
});
it("getById returns the correct task", async () => {
const created = await repo.create(folder, sampleTask);
const found = await repo.getById(folder, created.id);
expect(found).toEqual(created);
});
it("getById returns undefined for unknown id", async () => {
const found = await repo.getById(folder, "nonexistent");
expect(found).toBeUndefined();
});
it("update modifies the file and updates timestamp", async () => {
const task = await repo.create(folder, sampleTask);
await delay(5);
const updated: Task = { ...task, title: "Fixed!", status: "done" };
await repo.update(folder, updated);
const found = await repo.getById(folder, task.id);
expect(found!.title).toBe("Fixed!");
expect(found!.status).toBe("done");
expect(new Date(found!.updated).getTime()).toBeGreaterThan(
new Date(task.updated).getTime(),
);
});
it("delete removes the task file", async () => {
const task = await repo.create(folder, sampleTask);
await repo.delete(folder, task.id);
const tasks = await repo.list(folder);
expect(tasks).toHaveLength(0);
});
it("delete is a no-op for unknown id", async () => {
await expect(repo.delete(folder, "nope")).resolves.toBeUndefined();
});
it("list returns empty array for non-existent folder", async () => {
const tasks = await repo.list("/nonexistent");
expect(tasks).toEqual([]);
});
it("filters files by extension", async () => {
fs.writeFile(`${folder}/notes.txt`, "hello");
fs.writeFile(`${folder}/chore.md`, "---\nid: x\n---\n");
await repo.create(folder, sampleTask);
const tasks = await repo.list(folder);
expect(tasks).toHaveLength(1);
});
it("skips unparseable .task.md files silently", async () => {
fs.writeFile(`${folder}/corrupt.task.md`, "not valid frontmatter");
await repo.create(folder, sampleTask);
const tasks = await repo.list(folder);
expect(tasks).toHaveLength(1);
});
});
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"include": ["**/*", "../src/**/*"],
"compilerOptions": {
"noEmit": true,
"rootDir": ".."
}
}
+4 -1
View File
@@ -12,7 +12,10 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"types": ["node"]
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "out", ".vscode-test"]
+30
View File
@@ -0,0 +1,30 @@
import path from "path";
import { builtinModules } from "node:module";
import { defineConfig } from "vite";
export default defineConfig({
build: {
target: "node20",
ssr: path.resolve(__dirname, "src/extension.ts"),
outDir: "out",
sourcemap: true,
rollupOptions: {
output: {
entryFileNames: "extension.js",
},
external: [
"vscode",
...builtinModules,
...builtinModules.map((m) => `node:${m}`),
],
},
},
ssr: {
noExternal: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["node_modules", "out"],
environment: "node",
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});