Scaffold project and implement config, DB schema/queries
- Set up package.json (ESM, scripts), tsconfig.json, vitest.config.ts - Install runtime and dev dependencies - Add CLAUDE.md with architecture notes and code quality rules - Config module with env var parsing and JWT_SECRET validation - DB schema: users + files tables with FK cascade - DB queries: createUser, getUserBy*, createFile, getFileById, getFilesByUserId, deleteFile - Tests for config, db/users, db/files (15 tests passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -137,3 +137,7 @@ dist
|
|||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Project
|
||||||
|
data/
|
||||||
|
dist/
|
||||||
|
|||||||
28
CLAUDE.md
Normal file
28
CLAUDE.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Nanodrop
|
||||||
|
|
||||||
|
Simple file-sharing platform. TypeScript + Fastify + SQLite.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm test` — Run all tests (vitest)
|
||||||
|
- `npm run dev` — Start dev server (tsx)
|
||||||
|
- `npm run build` — Compile TypeScript
|
||||||
|
- `npm run register-user -- --username <user> --password <pass>` — Create user
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- ESM throughout (`"type": "module"`)
|
||||||
|
- Fastify with server-rendered HTML pages
|
||||||
|
- SQLite via better-sqlite3 for persistence
|
||||||
|
- JWT in httpOnly cookies for auth
|
||||||
|
- PRG pattern for HTML forms (no client-side JS except copy-to-clipboard)
|
||||||
|
- Dependency injection — DB/service functions take deps as params for testability
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- Review code after every change. Refactor for readability.
|
||||||
|
- Use TDD: write tests first, then implement.
|
||||||
|
- Build and test after every change.
|
||||||
|
- Break large functions into smaller ones, extract duplicate code.
|
||||||
|
- Search for duplicated code in tests and extract into reusable helpers.
|
||||||
|
- Commit after every logical set of changes. Keep commits small and focused.
|
||||||
3152
package-lock.json
generated
3152
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -2,10 +2,34 @@
|
|||||||
"name": "nanodrop",
|
"name": "nanodrop",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Simple file-sharing utility",
|
"description": "Simple file-sharing utility",
|
||||||
"main": "index.js",
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"build": "tsc",
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"register-user": "tsx src/cli/register-user.ts"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC"
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/formbody": "^8.0.2",
|
||||||
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/multipart": "^9.4.0",
|
||||||
|
"@fastify/static": "^9.0.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"fastify": "^5.7.4",
|
||||||
|
"nanoid": "^5.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^25.3.3",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/config.ts
Normal file
34
src/config.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export interface Config {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
jwtSecret: string;
|
||||||
|
jwtExpiry: string;
|
||||||
|
dbPath: string;
|
||||||
|
uploadDir: string;
|
||||||
|
logFile: string;
|
||||||
|
maxFileSize: number;
|
||||||
|
baseUrl: string;
|
||||||
|
cookieSecure: boolean;
|
||||||
|
trustProxy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
|
if (!jwtSecret) {
|
||||||
|
throw new Error('JWT_SECRET environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||||
|
host: process.env.HOST ?? '0.0.0.0',
|
||||||
|
jwtSecret,
|
||||||
|
jwtExpiry: process.env.JWT_EXPIRY ?? '7d',
|
||||||
|
dbPath: process.env.DB_PATH ?? './data/nanodrop.db',
|
||||||
|
uploadDir: process.env.UPLOAD_DIR ?? './data/uploads',
|
||||||
|
logFile: process.env.LOG_FILE ?? './data/nanodrop.log',
|
||||||
|
maxFileSize: parseInt(process.env.MAX_FILE_SIZE ?? '104857600', 10),
|
||||||
|
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
|
||||||
|
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
||||||
|
trustProxy: process.env.TRUST_PROXY === 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
51
src/db/files.ts
Normal file
51
src/db/files.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
export interface FileRow {
|
||||||
|
id: string;
|
||||||
|
user_id: number;
|
||||||
|
original_name: string;
|
||||||
|
mime_type: string;
|
||||||
|
size: number;
|
||||||
|
stored_name: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFileParams {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
storedName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFile(db: Database.Database, params: CreateFileParams): FileRow {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
`INSERT INTO files (id, user_id, original_name, mime_type, size, stored_name)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
|
||||||
|
);
|
||||||
|
return stmt.get(
|
||||||
|
params.id,
|
||||||
|
params.userId,
|
||||||
|
params.originalName,
|
||||||
|
params.mimeType,
|
||||||
|
params.size,
|
||||||
|
params.storedName,
|
||||||
|
) as FileRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileById(db: Database.Database, id: string): FileRow | undefined {
|
||||||
|
const stmt = db.prepare('SELECT * FROM files WHERE id = ?');
|
||||||
|
return stmt.get(id) as FileRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFilesByUserId(db: Database.Database, userId: number): FileRow[] {
|
||||||
|
const stmt = db.prepare('SELECT * FROM files WHERE user_id = ? ORDER BY created_at DESC');
|
||||||
|
return stmt.all(userId) as FileRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFile(db: Database.Database, id: string, userId: number): boolean {
|
||||||
|
const stmt = db.prepare('DELETE FROM files WHERE id = ? AND user_id = ?');
|
||||||
|
const result = stmt.run(id, userId);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
28
src/db/schema.ts
Normal file
28
src/db/schema.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
export function initDb(dbPath: string): Database.Database {
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
stored_name TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
30
src/db/users.ts
Normal file
30
src/db/users.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
export interface UserRow {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserParams {
|
||||||
|
username: string;
|
||||||
|
passwordHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(db: Database.Database, params: CreateUserParams): UserRow {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT INTO users (username, password_hash) VALUES (?, ?) RETURNING *'
|
||||||
|
);
|
||||||
|
return stmt.get(params.username, params.passwordHash) as UserRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserByUsername(db: Database.Database, username: string): UserRow | undefined {
|
||||||
|
const stmt = db.prepare('SELECT * FROM users WHERE username = ?');
|
||||||
|
return stmt.get(username) as UserRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserById(db: Database.Database, id: number): UserRow | undefined {
|
||||||
|
const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||||
|
return stmt.get(id) as UserRow | undefined;
|
||||||
|
}
|
||||||
21
tests/unit/auth-service.test.ts
Normal file
21
tests/unit/auth-service.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { hashPassword, verifyPassword } from '../../src/services/auth.ts';
|
||||||
|
|
||||||
|
describe('services/auth - password hashing', () => {
|
||||||
|
it('hashes a password and verifies it', async () => {
|
||||||
|
const hash = await hashPassword('mysecret');
|
||||||
|
expect(hash).not.toBe('mysecret');
|
||||||
|
await expect(verifyPassword('mysecret', hash)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong password', async () => {
|
||||||
|
const hash = await hashPassword('mysecret');
|
||||||
|
await expect(verifyPassword('wrong', hash)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different hashes for same password', async () => {
|
||||||
|
const h1 = await hashPassword('same');
|
||||||
|
const h2 = await hashPassword('same');
|
||||||
|
expect(h1).not.toBe(h2);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
tests/unit/config.test.ts
Normal file
68
tests/unit/config.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
describe('config', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns defaults when env vars are not set', async () => {
|
||||||
|
process.env.JWT_SECRET = 'test-secret';
|
||||||
|
delete process.env.PORT;
|
||||||
|
delete process.env.HOST;
|
||||||
|
delete process.env.JWT_EXPIRY;
|
||||||
|
delete process.env.DB_PATH;
|
||||||
|
delete process.env.UPLOAD_DIR;
|
||||||
|
delete process.env.LOG_FILE;
|
||||||
|
delete process.env.MAX_FILE_SIZE;
|
||||||
|
delete process.env.BASE_URL;
|
||||||
|
delete process.env.COOKIE_SECURE;
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
|
||||||
|
const { loadConfig } = await import('../../src/config.ts');
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
expect(config.port).toBe(3000);
|
||||||
|
expect(config.host).toBe('0.0.0.0');
|
||||||
|
expect(config.jwtExpiry).toBe('7d');
|
||||||
|
expect(config.dbPath).toBe('./data/nanodrop.db');
|
||||||
|
expect(config.uploadDir).toBe('./data/uploads');
|
||||||
|
expect(config.logFile).toBe('./data/nanodrop.log');
|
||||||
|
expect(config.maxFileSize).toBe(104857600);
|
||||||
|
expect(config.baseUrl).toBe('http://localhost:3000');
|
||||||
|
expect(config.cookieSecure).toBe(false);
|
||||||
|
expect(config.trustProxy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads values from env vars', async () => {
|
||||||
|
process.env.JWT_SECRET = 'my-secret';
|
||||||
|
process.env.PORT = '4000';
|
||||||
|
process.env.HOST = '127.0.0.1';
|
||||||
|
process.env.JWT_EXPIRY = '1d';
|
||||||
|
process.env.COOKIE_SECURE = 'true';
|
||||||
|
process.env.TRUST_PROXY = 'true';
|
||||||
|
process.env.MAX_FILE_SIZE = '52428800';
|
||||||
|
|
||||||
|
const { loadConfig } = await import('../../src/config.ts');
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
expect(config.port).toBe(4000);
|
||||||
|
expect(config.host).toBe('127.0.0.1');
|
||||||
|
expect(config.jwtSecret).toBe('my-secret');
|
||||||
|
expect(config.jwtExpiry).toBe('1d');
|
||||||
|
expect(config.cookieSecure).toBe(true);
|
||||||
|
expect(config.trustProxy).toBe(true);
|
||||||
|
expect(config.maxFileSize).toBe(52428800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when JWT_SECRET is missing', async () => {
|
||||||
|
delete process.env.JWT_SECRET;
|
||||||
|
const { loadConfig } = await import('../../src/config.ts');
|
||||||
|
expect(() => loadConfig()).toThrow('JWT_SECRET');
|
||||||
|
});
|
||||||
|
});
|
||||||
77
tests/unit/db-files.test.ts
Normal file
77
tests/unit/db-files.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { initDb } from '../../src/db/schema.ts';
|
||||||
|
import { createUser } from '../../src/db/users.ts';
|
||||||
|
import { createFile, getFileById, getFilesByUserId, deleteFile } from '../../src/db/files.ts';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
describe('db/files', () => {
|
||||||
|
let db: Database.Database;
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = initDb(':memory:');
|
||||||
|
const user = createUser(db, { username: 'alice', passwordHash: 'hash' });
|
||||||
|
userId = user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeFile(overrides: Partial<Parameters<typeof createFile>[1]> = {}) {
|
||||||
|
return createFile(db, {
|
||||||
|
id: 'file1',
|
||||||
|
userId,
|
||||||
|
originalName: 'photo.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
size: 1024,
|
||||||
|
storedName: 'file1.jpg',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates a file and retrieves by id', () => {
|
||||||
|
const file = makeFile();
|
||||||
|
expect(file.id).toBe('file1');
|
||||||
|
expect(file.user_id).toBe(userId);
|
||||||
|
expect(file.original_name).toBe('photo.jpg');
|
||||||
|
expect(file.mime_type).toBe('image/jpeg');
|
||||||
|
expect(file.size).toBe(1024);
|
||||||
|
expect(file.stored_name).toBe('file1.jpg');
|
||||||
|
|
||||||
|
const found = getFileById(db, 'file1');
|
||||||
|
expect(found).toEqual(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for unknown file id', () => {
|
||||||
|
expect(getFileById(db, 'nope')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists files by user ordered newest first', () => {
|
||||||
|
makeFile({ id: 'a', storedName: 'a.jpg' });
|
||||||
|
makeFile({ id: 'b', storedName: 'b.jpg' });
|
||||||
|
const files = getFilesByUserId(db, userId);
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
expect(files.map((f) => f.id)).toContain('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for user with no files', () => {
|
||||||
|
expect(getFilesByUserId(db, userId)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a file by id and userId', () => {
|
||||||
|
makeFile();
|
||||||
|
const deleted = deleteFile(db, 'file1', userId);
|
||||||
|
expect(deleted).toBe(true);
|
||||||
|
expect(getFileById(db, 'file1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when deleting a file belonging to another user', () => {
|
||||||
|
makeFile();
|
||||||
|
const deleted = deleteFile(db, 'file1', 999);
|
||||||
|
expect(deleted).toBe(false);
|
||||||
|
expect(getFileById(db, 'file1')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cascades delete when user is deleted', () => {
|
||||||
|
makeFile();
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||||
|
expect(getFileById(db, 'file1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
43
tests/unit/db-users.test.ts
Normal file
43
tests/unit/db-users.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { initDb } from '../../src/db/schema.ts';
|
||||||
|
import { createUser, getUserByUsername, getUserById } from '../../src/db/users.ts';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
describe('db/users', () => {
|
||||||
|
let db: Database.Database;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = initDb(':memory:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a user and retrieves by username', () => {
|
||||||
|
const user = createUser(db, { username: 'alice', passwordHash: 'hash123' });
|
||||||
|
|
||||||
|
expect(user.id).toBeTypeOf('number');
|
||||||
|
expect(user.username).toBe('alice');
|
||||||
|
expect(user.password_hash).toBe('hash123');
|
||||||
|
expect(user.created_at).toBeTruthy();
|
||||||
|
|
||||||
|
const found = getUserByUsername(db, 'alice');
|
||||||
|
expect(found).toEqual(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for unknown username', () => {
|
||||||
|
expect(getUserByUsername(db, 'ghost')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retrieves user by id', () => {
|
||||||
|
const user = createUser(db, { username: 'bob', passwordHash: 'hash456' });
|
||||||
|
const found = getUserById(db, user.id);
|
||||||
|
expect(found).toEqual(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for unknown id', () => {
|
||||||
|
expect(getUserById(db, 999)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces unique username constraint', () => {
|
||||||
|
createUser(db, { username: 'carol', passwordHash: 'hash' });
|
||||||
|
expect(() => createUser(db, { username: 'carol', passwordHash: 'other' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
}
|
||||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
root: '.',
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user