diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..d0f62cc --- /dev/null +++ b/.eslintignore @@ -0,0 +1,9 @@ +dist/ +build/ +out/ +coverage/ +node_modules/ +docs/ +.vite/ +.cache/ + diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3ee60ee --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,58 @@ +{ + "root": true, + "env": { + "browser": true, + "node": true, + "es2022": true + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": ["@typescript-eslint", "react", "react-hooks"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "prettier" + ], + "settings": { + "react": { "version": "detect" } + }, + "overrides": [ + { + "files": ["tests/**/*", "**/*.test.ts", "**/*.test.tsx"], + "env": { "node": true }, + "globals": { + "vi": "readonly", + "describe": "readonly", + "it": "readonly", + "expect": "readonly", + "beforeAll": "readonly", + "afterAll": "readonly" + }, + "rules": { + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }] + } + }, + { + "files": ["server/**/*.ts"], + "env": { "node": true, "browser": false } + } + ], + "ignorePatterns": [ + "dist/", + "build/", + "out/", + "node_modules/", + "coverage/", + "docs/" + ], + "rules": { + "react/react-in-jsx-scope": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/explicit-module-boundary-types": "off" + } +} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2ba0953 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,65 @@ +name: Tests + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + test: + name: Node ${{ matrix.node }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [18, 20] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test + + typecheck: + name: Type Check (Node 20) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Run TypeScript typecheck + run: npm run typecheck + + lint: + name: Lint (ESLint) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Run ESLint + run: npm run lint diff --git a/package.json b/package.json index a95d349..9862b17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "markdown-web", - "version": "2.15.0", + "version": "2.15.1", "description": "A modern, browser-based markdown editor that you can run in any directory. Edit .md files with live preview, auto-save, and a VS Code-inspired interface.", "main": "dist/server/index.js", "bin": { @@ -33,7 +33,10 @@ "start:https": "sudo node dist/server/https-server.js", "setup-ssl": "sudo ./scripts/setup-ssl.sh", "typecheck": "tsc --noEmit", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "test": "vitest run", + "lint": "eslint . --ext .ts,.tsx,.js,.jsx", + "lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix" }, "keywords": [ "markdown", @@ -82,6 +85,15 @@ "concurrently": "^8.2.2", "tsx": "^4.6.2", "typescript": "^5.3.3", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vitest": "^2.1.1", + "supertest": "^6.3.4", + "@types/supertest": "^2.0.16", + "eslint": "^8.57.0", + "@typescript-eslint/parser": "^7.18.0", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "eslint-plugin-react": "^7.34.3", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-config-prettier": "^9.1.0" } } diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..74109b4 --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import path from 'path'; +import os from 'os'; +import fs from 'fs/promises'; +import http from 'http'; +import supertest from 'supertest'; + +let server: http.Server; +let tmpRoot: string; +let originalPort = process.env.PORT; +let originalHome = process.env.HOME; + +async function makeDir(p: string) { + await fs.mkdir(p, { recursive: true }); +} + +async function write(p: string, content = '') { + await makeDir(path.dirname(p)); + await fs.writeFile(p, content, 'utf-8'); +} + +beforeAll(async () => { + // Isolate HOME so settings/logs don't touch real user env + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mdweb-tests-')); + process.env.HOME = path.join(tmpRoot, 'home'); + await fs.mkdir(process.env.HOME, { recursive: true }); + + // Create a test workspace with nested paths and spaces + const workspace = path.join(tmpRoot, 'workspace'); + await makeDir(workspace); + await write(path.join(workspace, 'readme.md'), '# Readme'); + await write(path.join(workspace, 'Work', 'afx Markets', 'System Overview.md'), '# System'); + + // Start server on an ephemeral port (set before import) + process.env.PORT = '0'; + const mod = await import('../server/index'); + server = await mod.startServer(workspace); +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + if (originalPort === undefined) delete process.env.PORT; else process.env.PORT = originalPort; + if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; +}); + +describe('Server API basics', () => { + it('lists .md files including paths with spaces', async () => { + const res = await supertest(server).get('/api/files').expect(200); + const list = res.body as any[]; + expect(Array.isArray(list)).toBe(true); + // find the nested folder and file + const work = list.find((x) => x.type === 'directory' && x.name === 'Work'); + expect(work).toBeTruthy(); + const afx = (work?.children || []).find((x: any) => x.type === 'directory' && x.name === 'afx Markets'); + expect(afx).toBeTruthy(); + const sys = (afx?.children || []).find((x: any) => x.type === 'file' && x.name === 'System Overview.md'); + expect(sys).toBeTruthy(); + // ensure POSIX-style path returned + expect(sys.path).toContain('Work/afx Markets/System Overview.md'); + }); + + it('can create a new markdown file', async () => { + const fileRel = 'Notes/New File.md'; + await supertest(server).post('/api/create-file').send({ fileName: fileRel }).expect(200); + const res = await supertest(server).get('/api/files').expect(200); + const notes = res.body.find((x: any) => x.type === 'directory' && x.name === 'Notes'); + const nf = (notes?.children || []).find((x: any) => x.type === 'file' && x.name === 'New File.md'); + expect(nf).toBeTruthy(); + }); + + it('can rename a file', async () => { + const oldRel = 'Old.md'; + const newRel = 'Renamed.md'; + await supertest(server).post('/api/create-file').send({ fileName: oldRel }).expect(200); + await supertest(server).post('/api/rename').send({ oldPath: oldRel, newPath: newRel }).expect(200); + const res = await supertest(server).get('/api/files').expect(200); + const renamed = res.body.find((x: any) => x.type === 'file' && x.name === 'Renamed.md'); + expect(renamed).toBeTruthy(); + }); + + it('can delete a file', async () => { + const delRel = 'ToDelete.md'; + await supertest(server).post('/api/create-file').send({ fileName: delRel }).expect(200); + await supertest(server).delete(`/api/files/${encodeURIComponent(delRel)}`).expect(200); + const res = await supertest(server).get('/api/files').expect(200); + const gone = res.body.find((x: any) => x.type === 'file' && x.name === 'ToDelete.md'); + expect(gone).toBeUndefined(); + }); + + it('AI status toggles when key is set via settings', async () => { + // Initially disabled (no keys) + const s0 = await supertest(server).get('/api/ai/status').expect(200); + expect(s0.body.enabled).toBe(false); + // Set key through settings + await supertest(server).post('/api/settings').send({ openAiKey: 'test-key' }).expect(200); + const s1 = await supertest(server).get('/api/ai/status').expect(200); + expect(s1.body.enabled).toBe(true); + }); +}); +