hostfn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1136 -0
- package/_conduct/specs/1.v0.spec.md +1041 -0
- package/examples/express-api/package.json +22 -0
- package/examples/express-api/src/index.ts +16 -0
- package/examples/express-api/tsconfig.json +11 -0
- package/examples/github-actions-deploy.yml +40 -0
- package/examples/monorepo-config.json +76 -0
- package/examples/monorepo-multi-server-config.json +74 -0
- package/package.json +39 -0
- package/packages/cli/package.json +40 -0
- package/packages/cli/src/__tests__/core/backup.test.ts +137 -0
- package/packages/cli/src/__tests__/core/health.test.ts +125 -0
- package/packages/cli/src/__tests__/core/lock.test.ts +173 -0
- package/packages/cli/src/__tests__/core/nginx-multi-domain.test.ts +176 -0
- package/packages/cli/src/__tests__/runtimes/pm2.test.ts +130 -0
- package/packages/cli/src/__tests__/utils/validation.test.ts +164 -0
- package/packages/cli/src/commands/deploy.ts +817 -0
- package/packages/cli/src/commands/env.ts +391 -0
- package/packages/cli/src/commands/expose.ts +438 -0
- package/packages/cli/src/commands/init.ts +192 -0
- package/packages/cli/src/commands/logs.ts +106 -0
- package/packages/cli/src/commands/rollback.ts +142 -0
- package/packages/cli/src/commands/server/info.ts +131 -0
- package/packages/cli/src/commands/server/setup.ts +200 -0
- package/packages/cli/src/commands/status.ts +149 -0
- package/packages/cli/src/config/loader.ts +66 -0
- package/packages/cli/src/config/schema.ts +140 -0
- package/packages/cli/src/core/backup.ts +128 -0
- package/packages/cli/src/core/health.ts +116 -0
- package/packages/cli/src/core/local.ts +67 -0
- package/packages/cli/src/core/lock.ts +108 -0
- package/packages/cli/src/core/nginx.ts +170 -0
- package/packages/cli/src/core/ssh.ts +335 -0
- package/packages/cli/src/core/sync.ts +138 -0
- package/packages/cli/src/core/workspace.ts +180 -0
- package/packages/cli/src/index.ts +240 -0
- package/packages/cli/src/runtimes/base.ts +144 -0
- package/packages/cli/src/runtimes/nodejs/detector.ts +157 -0
- package/packages/cli/src/runtimes/nodejs/index.ts +228 -0
- package/packages/cli/src/runtimes/nodejs/pm2.ts +71 -0
- package/packages/cli/src/runtimes/registry.ts +76 -0
- package/packages/cli/src/utils/logger.ts +86 -0
- package/packages/cli/src/utils/validation.ts +147 -0
- package/packages/cli/tsconfig.json +25 -0
- package/packages/cli/vitest.config.ts +19 -0
- package/turbo.json +24 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "example-express-api",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "tsx watch src/index.ts",
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"start": "node dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"express": "^4.18.0"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/express": "^4.17.0",
|
|
15
|
+
"@types/node": "^20.0.0",
|
|
16
|
+
"tsx": "^4.0.0",
|
|
17
|
+
"typescript": "^5.0.0"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
const app = express();
|
|
4
|
+
const port = process.env.PORT || 3000;
|
|
5
|
+
|
|
6
|
+
app.get('/health', (req, res) => {
|
|
7
|
+
res.json({ status: 'ok' });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
app.get('/', (req, res) => {
|
|
11
|
+
res.json({ message: 'Hello from Express API!' });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
app.listen(port, () => {
|
|
15
|
+
console.log(`Server running on port ${port}`);
|
|
16
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
name: Deploy with hostfn
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
- production
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
deploy:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- name: Checkout code
|
|
15
|
+
uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Setup Node.js
|
|
18
|
+
uses: actions/setup-node@v4
|
|
19
|
+
with:
|
|
20
|
+
node-version: '20'
|
|
21
|
+
|
|
22
|
+
- name: Install hostfn
|
|
23
|
+
run: npm install -g hostfn
|
|
24
|
+
|
|
25
|
+
- name: Deploy to production
|
|
26
|
+
env:
|
|
27
|
+
HOSTFN_SSH_KEY: ${{ secrets.HOSTFN_SSH_KEY }}
|
|
28
|
+
HOSTFN_SSH_PASSPHRASE: ${{ secrets.HOSTFN_SSH_PASSPHRASE }}
|
|
29
|
+
run: hostfn deploy production --ci
|
|
30
|
+
|
|
31
|
+
deploy-selfhosted:
|
|
32
|
+
# This job runs on a self-hosted runner on the deployment server
|
|
33
|
+
runs-on: self-hosted
|
|
34
|
+
|
|
35
|
+
steps:
|
|
36
|
+
- name: Checkout code
|
|
37
|
+
uses: actions/checkout@v4
|
|
38
|
+
|
|
39
|
+
- name: Deploy locally (no SSH needed)
|
|
40
|
+
run: hostfn deploy production --local --ci
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "21n",
|
|
3
|
+
"runtime": "nodejs",
|
|
4
|
+
"version": "18",
|
|
5
|
+
"environments": {
|
|
6
|
+
"production": {
|
|
7
|
+
"server": "ubuntu@prod.example.com",
|
|
8
|
+
"port": 3000,
|
|
9
|
+
"instances": "max"
|
|
10
|
+
},
|
|
11
|
+
"staging": {
|
|
12
|
+
"server": "ubuntu@staging.example.com",
|
|
13
|
+
"port": 3000,
|
|
14
|
+
"instances": 2
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"build": {
|
|
18
|
+
"command": "npm run build",
|
|
19
|
+
"directory": "dist",
|
|
20
|
+
"nodeModules": "production"
|
|
21
|
+
},
|
|
22
|
+
"start": {
|
|
23
|
+
"command": "npm start",
|
|
24
|
+
"entry": "dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"services": {
|
|
27
|
+
"account": {
|
|
28
|
+
"port": 3001,
|
|
29
|
+
"path": "services/account",
|
|
30
|
+
"domain": "account.example.com",
|
|
31
|
+
"instances": "max"
|
|
32
|
+
},
|
|
33
|
+
"auth": {
|
|
34
|
+
"port": 3002,
|
|
35
|
+
"path": "services/auth",
|
|
36
|
+
"domain": "auth.example.com",
|
|
37
|
+
"instances": 2
|
|
38
|
+
},
|
|
39
|
+
"notification": {
|
|
40
|
+
"port": 3003,
|
|
41
|
+
"path": "services/notification",
|
|
42
|
+
"domain": "notification.example.com"
|
|
43
|
+
},
|
|
44
|
+
"analytics": {
|
|
45
|
+
"port": 3004,
|
|
46
|
+
"path": "services/analytics"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"health": {
|
|
50
|
+
"path": "/health",
|
|
51
|
+
"timeout": 60,
|
|
52
|
+
"retries": 10,
|
|
53
|
+
"interval": 3
|
|
54
|
+
},
|
|
55
|
+
"env": {
|
|
56
|
+
"required": ["NODE_ENV", "DATABASE_URL"],
|
|
57
|
+
"optional": ["REDIS_URL", "LOG_LEVEL"]
|
|
58
|
+
},
|
|
59
|
+
"sync": {
|
|
60
|
+
"exclude": [
|
|
61
|
+
"node_modules",
|
|
62
|
+
".git",
|
|
63
|
+
".github",
|
|
64
|
+
"dist",
|
|
65
|
+
"build",
|
|
66
|
+
".env",
|
|
67
|
+
".env.*",
|
|
68
|
+
"*.log",
|
|
69
|
+
".turbo",
|
|
70
|
+
".wrangler"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"backup": {
|
|
74
|
+
"keep": 5
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "21n",
|
|
3
|
+
"runtime": "nodejs",
|
|
4
|
+
"version": "18",
|
|
5
|
+
"environments": {
|
|
6
|
+
"production": {
|
|
7
|
+
"server": "ubuntu@shared.example.com",
|
|
8
|
+
"port": 3000,
|
|
9
|
+
"instances": "max"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"build": {
|
|
13
|
+
"command": "npm run build",
|
|
14
|
+
"directory": "dist",
|
|
15
|
+
"nodeModules": "production"
|
|
16
|
+
},
|
|
17
|
+
"start": {
|
|
18
|
+
"command": "npm start",
|
|
19
|
+
"entry": "dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"services": {
|
|
22
|
+
"account": {
|
|
23
|
+
"port": 3001,
|
|
24
|
+
"path": "services/account",
|
|
25
|
+
"domain": "account.example.com",
|
|
26
|
+
"server": "ubuntu@account-server.example.com",
|
|
27
|
+
"instances": "max"
|
|
28
|
+
},
|
|
29
|
+
"auth": {
|
|
30
|
+
"port": 3002,
|
|
31
|
+
"path": "services/auth",
|
|
32
|
+
"domain": "auth.example.com",
|
|
33
|
+
"server": "ubuntu@auth-server.example.com",
|
|
34
|
+
"instances": 4
|
|
35
|
+
},
|
|
36
|
+
"notification": {
|
|
37
|
+
"port": 3003,
|
|
38
|
+
"path": "services/notification",
|
|
39
|
+
"domain": "notification.example.com"
|
|
40
|
+
},
|
|
41
|
+
"analytics": {
|
|
42
|
+
"port": 3004,
|
|
43
|
+
"path": "services/analytics",
|
|
44
|
+
"server": "ubuntu@analytics-server.example.com"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"health": {
|
|
48
|
+
"path": "/health",
|
|
49
|
+
"timeout": 60,
|
|
50
|
+
"retries": 10,
|
|
51
|
+
"interval": 3
|
|
52
|
+
},
|
|
53
|
+
"env": {
|
|
54
|
+
"required": ["NODE_ENV", "DATABASE_URL"],
|
|
55
|
+
"optional": ["REDIS_URL", "LOG_LEVEL"]
|
|
56
|
+
},
|
|
57
|
+
"sync": {
|
|
58
|
+
"exclude": [
|
|
59
|
+
"node_modules",
|
|
60
|
+
".git",
|
|
61
|
+
".github",
|
|
62
|
+
"dist",
|
|
63
|
+
"build",
|
|
64
|
+
".env",
|
|
65
|
+
".env.*",
|
|
66
|
+
"*.log",
|
|
67
|
+
".turbo",
|
|
68
|
+
".wrangler"
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
"backup": {
|
|
72
|
+
"keep": 5
|
|
73
|
+
}
|
|
74
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hostfn",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Universal application deployment CLI",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"workspaces": [
|
|
7
|
+
"packages/*"
|
|
8
|
+
],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "turbo run build",
|
|
11
|
+
"dev": "turbo run dev",
|
|
12
|
+
"lint": "turbo run lint",
|
|
13
|
+
"test": "turbo run test",
|
|
14
|
+
"clean": "turbo run clean && rm -rf node_modules"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"turbo": "^1.11.0",
|
|
18
|
+
"typescript": "^5.3.0"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"deployment",
|
|
22
|
+
"vps"
|
|
23
|
+
],
|
|
24
|
+
"author": "21n",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/21nCo/hostfn.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/21nCo/hostfn/issues"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.0.0"
|
|
34
|
+
},
|
|
35
|
+
"packageManager": "npm@10.2.0",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"fast-glob": "^3.3.3"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hostfn",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Universal application deployment CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hostfn": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "tsx watch src/index.ts",
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"clean": "rm -rf dist",
|
|
13
|
+
"lint": "tsc --noEmit",
|
|
14
|
+
"test": "vitest",
|
|
15
|
+
"test:watch": "vitest --watch",
|
|
16
|
+
"test:coverage": "vitest --coverage"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"commander": "^11.1.0",
|
|
20
|
+
"chalk": "^5.3.0",
|
|
21
|
+
"ora": "^7.0.1",
|
|
22
|
+
"inquirer": "^9.2.12",
|
|
23
|
+
"zod": "^3.22.4",
|
|
24
|
+
"ssh2": "^1.15.0",
|
|
25
|
+
"dotenv": "^16.3.1",
|
|
26
|
+
"execa": "^8.0.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^20.10.0",
|
|
30
|
+
"@types/ssh2": "^1.15.0",
|
|
31
|
+
"@types/inquirer": "^9.0.7",
|
|
32
|
+
"@vitest/coverage-v8": "^1.0.0",
|
|
33
|
+
"typescript": "^5.3.0",
|
|
34
|
+
"tsx": "^4.7.0",
|
|
35
|
+
"vitest": "^1.0.0"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { BackupManager } from '../../core/backup.js';
|
|
3
|
+
|
|
4
|
+
// Mock SSH connection
|
|
5
|
+
const mockSSH = {
|
|
6
|
+
exec: vi.fn(),
|
|
7
|
+
exists: vi.fn(),
|
|
8
|
+
mkdir: vi.fn(),
|
|
9
|
+
upload: vi.fn(),
|
|
10
|
+
download: vi.fn(),
|
|
11
|
+
disconnect: vi.fn(),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('BackupManager', () => {
|
|
15
|
+
let backupManager: BackupManager;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
backupManager = new BackupManager(mockSSH as any, '/var/www/myapp');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('create', () => {
|
|
23
|
+
it('should create backup with timestamp', async () => {
|
|
24
|
+
mockSSH.mkdir.mockResolvedValue(undefined);
|
|
25
|
+
mockSSH.exists.mockResolvedValue(true); // dist exists
|
|
26
|
+
mockSSH.exec.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
27
|
+
|
|
28
|
+
const backupPath = await backupManager.create();
|
|
29
|
+
|
|
30
|
+
expect(backupPath).toMatch(/\/var\/www\/myapp\/backups\/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
|
|
31
|
+
expect(mockSSH.mkdir).toHaveBeenCalled();
|
|
32
|
+
expect(mockSSH.exec).toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should return path even if source directory does not exist', async () => {
|
|
36
|
+
mockSSH.mkdir.mockResolvedValue(undefined);
|
|
37
|
+
mockSSH.exists.mockResolvedValue(false); // No dist directory
|
|
38
|
+
|
|
39
|
+
const backupPath = await backupManager.create();
|
|
40
|
+
|
|
41
|
+
// Returns backup path but doesn't fail
|
|
42
|
+
expect(backupPath).toMatch(/\/var\/www\/myapp\/backups\/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
|
|
43
|
+
expect(mockSSH.mkdir).toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('list', () => {
|
|
48
|
+
it('should list all backups with timestamps', async () => {
|
|
49
|
+
mockSSH.exists.mockResolvedValue(true);
|
|
50
|
+
mockSSH.exec.mockResolvedValue({
|
|
51
|
+
stdout: 'myapp.backup.20251114_130000\nmyapp.backup.20251114_120000\n', // sorted reverse
|
|
52
|
+
stderr: '',
|
|
53
|
+
exitCode: 0
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const backups = await backupManager.list();
|
|
57
|
+
|
|
58
|
+
expect(backups).toHaveLength(2);
|
|
59
|
+
expect(backups[0]).toContain('20251114_130000'); // Most recent first
|
|
60
|
+
expect(backups[1]).toContain('20251114_120000');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should return empty array when no backups exist', async () => {
|
|
64
|
+
mockSSH.exec.mockResolvedValue({
|
|
65
|
+
stdout: '',
|
|
66
|
+
stderr: '',
|
|
67
|
+
exitCode: 0
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const backups = await backupManager.list();
|
|
71
|
+
|
|
72
|
+
expect(backups).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('restore', () => {
|
|
77
|
+
it('should restore from backup', async () => {
|
|
78
|
+
mockSSH.exists.mockResolvedValue(true);
|
|
79
|
+
mockSSH.exec.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
80
|
+
|
|
81
|
+
await backupManager.restore('myapp.backup.20251114_120000');
|
|
82
|
+
|
|
83
|
+
expect(mockSSH.exec).toHaveBeenCalledWith(
|
|
84
|
+
expect.stringContaining('cp -r')
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('cleanup', () => {
|
|
90
|
+
it('should keep only specified number of backups', async () => {
|
|
91
|
+
const backupNames = [
|
|
92
|
+
'myapp.backup.20251114_150000', // Most recent (sorted)
|
|
93
|
+
'myapp.backup.20251114_140000',
|
|
94
|
+
'myapp.backup.20251114_130000',
|
|
95
|
+
'myapp.backup.20251114_120000',
|
|
96
|
+
'myapp.backup.20251114_110000',
|
|
97
|
+
'myapp.backup.20251114_100000', // Oldest
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
mockSSH.exists.mockResolvedValue(true);
|
|
101
|
+
mockSSH.exec
|
|
102
|
+
.mockResolvedValueOnce({
|
|
103
|
+
stdout: backupNames.join('\n'),
|
|
104
|
+
stderr: '',
|
|
105
|
+
exitCode: 0
|
|
106
|
+
})
|
|
107
|
+
.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
108
|
+
|
|
109
|
+
await backupManager.cleanup(3);
|
|
110
|
+
|
|
111
|
+
// Should remove oldest 3 backups
|
|
112
|
+
expect(mockSSH.exec).toHaveBeenCalledWith(
|
|
113
|
+
expect.stringContaining('rm -rf')
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should not remove backups if under limit', async () => {
|
|
118
|
+
const backupNames = [
|
|
119
|
+
'myapp.backup.20251114_100000',
|
|
120
|
+
'myapp.backup.20251114_110000',
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
mockSSH.exists.mockResolvedValue(true);
|
|
124
|
+
mockSSH.exec.mockResolvedValueOnce({
|
|
125
|
+
stdout: backupNames.join('\n'),
|
|
126
|
+
stderr: '',
|
|
127
|
+
exitCode: 0
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await backupManager.cleanup(5);
|
|
131
|
+
|
|
132
|
+
// Should only call list, not remove (called twice: exists + ls)
|
|
133
|
+
expect(mockSSH.exec).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(mockSSH.exec).toHaveBeenCalledWith(expect.stringContaining('ls -1'));
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { HealthCheck } from '../../core/health.js';
|
|
3
|
+
|
|
4
|
+
// Mock fetch globally
|
|
5
|
+
global.fetch = vi.fn();
|
|
6
|
+
|
|
7
|
+
describe('HealthCheck', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.clearAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('check', () => {
|
|
13
|
+
it('should return healthy for successful response', async () => {
|
|
14
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
15
|
+
ok: true,
|
|
16
|
+
status: 200,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const result = await HealthCheck.check({
|
|
20
|
+
url: 'http://localhost:3000/health',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(result.healthy).toBe(true);
|
|
24
|
+
expect(result.statusCode).toBe(200);
|
|
25
|
+
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return unhealthy for failed response', async () => {
|
|
29
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
30
|
+
ok: false,
|
|
31
|
+
status: 500,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const result = await HealthCheck.check({
|
|
35
|
+
url: 'http://localhost:3000/health',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(result.healthy).toBe(false);
|
|
39
|
+
expect(result.statusCode).toBe(500);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return unhealthy for network error', async () => {
|
|
43
|
+
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
|
44
|
+
|
|
45
|
+
const result = await HealthCheck.check({
|
|
46
|
+
url: 'http://localhost:3000/health',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result.healthy).toBe(false);
|
|
50
|
+
expect(result.error).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should respect timeout', async () => {
|
|
54
|
+
(global.fetch as any).mockImplementationOnce(() =>
|
|
55
|
+
new Promise((resolve) => setTimeout(resolve, 2000))
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const result = await HealthCheck.check({
|
|
59
|
+
url: 'http://localhost:3000/health',
|
|
60
|
+
timeout: 100,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.healthy).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('waitForReady', () => {
|
|
68
|
+
it('should return true when service becomes healthy', async () => {
|
|
69
|
+
let callCount = 0;
|
|
70
|
+
(global.fetch as any).mockImplementation(() => {
|
|
71
|
+
callCount++;
|
|
72
|
+
return Promise.resolve({
|
|
73
|
+
ok: callCount >= 2, // Becomes healthy on second call
|
|
74
|
+
status: callCount >= 2 ? 200 : 503,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const result = await HealthCheck.waitForReady({
|
|
79
|
+
url: 'http://localhost:3000/health',
|
|
80
|
+
retries: 5,
|
|
81
|
+
interval: 10,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(result).toBe(true);
|
|
85
|
+
expect(callCount).toBeGreaterThanOrEqual(2);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should return false when max retries exceeded', async () => {
|
|
89
|
+
(global.fetch as any).mockResolvedValue({
|
|
90
|
+
ok: false,
|
|
91
|
+
status: 503,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await HealthCheck.waitForReady({
|
|
95
|
+
url: 'http://localhost:3000/health',
|
|
96
|
+
retries: 2,
|
|
97
|
+
interval: 10,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(result).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should call progress callback on each attempt', async () => {
|
|
104
|
+
(global.fetch as any).mockResolvedValue({
|
|
105
|
+
ok: false,
|
|
106
|
+
status: 503,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const progressCalls: number[] = [];
|
|
110
|
+
|
|
111
|
+
await HealthCheck.waitForReady(
|
|
112
|
+
{
|
|
113
|
+
url: 'http://localhost:3000/health',
|
|
114
|
+
retries: 3,
|
|
115
|
+
interval: 10,
|
|
116
|
+
},
|
|
117
|
+
(attempt) => {
|
|
118
|
+
progressCalls.push(attempt);
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(progressCalls).toEqual([1, 2, 3]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|