Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
dist/
build/
out/
coverage/
node_modules/
docs/
.vite/
.cache/

58 changes: 58 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
65 changes: 65 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 15 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
100 changes: 100 additions & 0 deletions tests/server.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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);
});
});

Loading