odac 0.9.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/.editorconfig +21 -0
- package/.github/workflows/auto-pr-description.yml +49 -0
- package/.github/workflows/release.yml +32 -0
- package/.github/workflows/test-coverage.yml +58 -0
- package/.husky/pre-commit +2 -0
- package/.kiro/steering/code-style.md +56 -0
- package/.kiro/steering/product.md +20 -0
- package/.kiro/steering/structure.md +77 -0
- package/.kiro/steering/tech.md +87 -0
- package/.prettierrc +10 -0
- package/.releaserc.js +134 -0
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +181 -0
- package/CODE_OF_CONDUCT.md +83 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +661 -0
- package/README.md +57 -0
- package/SECURITY.md +26 -0
- package/bin/candy +10 -0
- package/bin/candypack +10 -0
- package/cli/index.js +3 -0
- package/cli/src/Cli.js +348 -0
- package/cli/src/Connector.js +93 -0
- package/cli/src/Monitor.js +416 -0
- package/core/Candy.js +87 -0
- package/core/Commands.js +239 -0
- package/core/Config.js +1094 -0
- package/core/Lang.js +52 -0
- package/core/Log.js +43 -0
- package/core/Process.js +26 -0
- package/docs/backend/01-overview/01-whats-in-the-candy-box.md +9 -0
- package/docs/backend/01-overview/02-super-handy-helper-functions.md +9 -0
- package/docs/backend/01-overview/03-development-server.md +79 -0
- package/docs/backend/02-structure/01-typical-project-layout.md +39 -0
- package/docs/backend/03-config/00-configuration-overview.md +214 -0
- package/docs/backend/03-config/01-database-connection.md +60 -0
- package/docs/backend/03-config/02-static-route-mapping-optional.md +20 -0
- package/docs/backend/03-config/03-request-timeout.md +11 -0
- package/docs/backend/03-config/04-environment-variables.md +227 -0
- package/docs/backend/03-config/05-early-hints.md +352 -0
- package/docs/backend/04-routing/01-basic-page-routes.md +28 -0
- package/docs/backend/04-routing/02-controller-less-view-routes.md +43 -0
- package/docs/backend/04-routing/03-api-and-data-routes.md +20 -0
- package/docs/backend/04-routing/04-authentication-aware-routes.md +48 -0
- package/docs/backend/04-routing/05-advanced-routing.md +14 -0
- package/docs/backend/04-routing/06-error-pages.md +101 -0
- package/docs/backend/04-routing/07-cron-jobs.md +149 -0
- package/docs/backend/05-controllers/01-how-to-build-a-controller.md +17 -0
- package/docs/backend/05-controllers/02-your-trusty-candy-assistant.md +20 -0
- package/docs/backend/05-controllers/03-controller-classes.md +93 -0
- package/docs/backend/05-forms/01-custom-forms.md +395 -0
- package/docs/backend/05-forms/02-automatic-database-insert.md +297 -0
- package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +96 -0
- package/docs/backend/06-request-and-response/02-sending-a-response-replying-to-the-user.md +40 -0
- package/docs/backend/07-views/01-the-view-directory.md +73 -0
- package/docs/backend/07-views/02-rendering-a-view.md +179 -0
- package/docs/backend/07-views/03-template-syntax.md +181 -0
- package/docs/backend/07-views/03-variables.md +328 -0
- package/docs/backend/07-views/04-request-data.md +231 -0
- package/docs/backend/07-views/05-conditionals.md +290 -0
- package/docs/backend/07-views/06-loops.md +353 -0
- package/docs/backend/07-views/07-translations.md +358 -0
- package/docs/backend/07-views/08-backend-javascript.md +398 -0
- package/docs/backend/07-views/09-comments.md +297 -0
- package/docs/backend/08-database/01-database-connection.md +99 -0
- package/docs/backend/08-database/02-using-mysql.md +322 -0
- package/docs/backend/09-validation/01-the-validator-service.md +424 -0
- package/docs/backend/10-authentication/01-user-logins-with-authjs.md +53 -0
- package/docs/backend/10-authentication/02-foiling-villains-with-csrf-protection.md +55 -0
- package/docs/backend/10-authentication/03-register.md +134 -0
- package/docs/backend/10-authentication/04-candy-register-forms.md +676 -0
- package/docs/backend/10-authentication/05-session-management.md +159 -0
- package/docs/backend/10-authentication/06-candy-login-forms.md +596 -0
- package/docs/backend/11-mail/01-the-mail-service.md +42 -0
- package/docs/backend/12-streaming/01-streaming-overview.md +300 -0
- package/docs/backend/13-utilities/01-candy-var.md +504 -0
- package/docs/frontend/01-overview/01-introduction.md +146 -0
- package/docs/frontend/02-ajax-navigation/01-quick-start.md +608 -0
- package/docs/frontend/02-ajax-navigation/02-configuration.md +370 -0
- package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +519 -0
- package/docs/frontend/03-forms/01-form-handling.md +420 -0
- package/docs/frontend/04-api-requests/01-get-post.md +443 -0
- package/docs/frontend/05-streaming/01-client-streaming.md +163 -0
- package/docs/index.json +452 -0
- package/docs/server/01-installation/01-quick-install.md +19 -0
- package/docs/server/01-installation/02-manual-installation-via-npm.md +9 -0
- package/docs/server/02-get-started/01-core-concepts.md +7 -0
- package/docs/server/02-get-started/02-basic-commands.md +57 -0
- package/docs/server/02-get-started/03-cli-reference.md +276 -0
- package/docs/server/02-get-started/04-cli-quick-reference.md +102 -0
- package/docs/server/03-service/01-start-a-new-service.md +57 -0
- package/docs/server/03-service/02-delete-a-service.md +48 -0
- package/docs/server/04-web/01-create-a-website.md +36 -0
- package/docs/server/04-web/02-list-websites.md +9 -0
- package/docs/server/04-web/03-delete-a-website.md +29 -0
- package/docs/server/05-subdomain/01-create-a-subdomain.md +32 -0
- package/docs/server/05-subdomain/02-list-subdomains.md +33 -0
- package/docs/server/05-subdomain/03-delete-a-subdomain.md +41 -0
- package/docs/server/06-ssl/01-renew-an-ssl-certificate.md +34 -0
- package/docs/server/07-mail/01-create-a-mail-account.md +23 -0
- package/docs/server/07-mail/02-delete-a-mail-account.md +20 -0
- package/docs/server/07-mail/03-list-mail-accounts.md +20 -0
- package/docs/server/07-mail/04-change-account-password.md +23 -0
- package/eslint.config.mjs +120 -0
- package/framework/index.js +4 -0
- package/framework/src/Auth.js +309 -0
- package/framework/src/Candy.js +81 -0
- package/framework/src/Config.js +79 -0
- package/framework/src/Env.js +60 -0
- package/framework/src/Lang.js +57 -0
- package/framework/src/Mail.js +83 -0
- package/framework/src/Mysql.js +575 -0
- package/framework/src/Request.js +301 -0
- package/framework/src/Route/Cron.js +128 -0
- package/framework/src/Route/Internal.js +439 -0
- package/framework/src/Route.js +455 -0
- package/framework/src/Server.js +15 -0
- package/framework/src/Stream.js +163 -0
- package/framework/src/Token.js +37 -0
- package/framework/src/Validator.js +271 -0
- package/framework/src/Var.js +211 -0
- package/framework/src/View/EarlyHints.js +190 -0
- package/framework/src/View/Form.js +600 -0
- package/framework/src/View.js +513 -0
- package/framework/web/candy.js +838 -0
- package/jest.config.js +22 -0
- package/locale/de-DE.json +80 -0
- package/locale/en-US.json +79 -0
- package/locale/es-ES.json +80 -0
- package/locale/fr-FR.json +80 -0
- package/locale/pt-BR.json +80 -0
- package/locale/ru-RU.json +80 -0
- package/locale/tr-TR.json +85 -0
- package/locale/zh-CN.json +80 -0
- package/package.json +86 -0
- package/server/index.js +5 -0
- package/server/src/Api.js +88 -0
- package/server/src/DNS.js +940 -0
- package/server/src/Hub.js +535 -0
- package/server/src/Mail.js +571 -0
- package/server/src/SSL.js +180 -0
- package/server/src/Server.js +27 -0
- package/server/src/Service.js +248 -0
- package/server/src/Subdomain.js +64 -0
- package/server/src/Web/Firewall.js +170 -0
- package/server/src/Web/Proxy.js +134 -0
- package/server/src/Web.js +451 -0
- package/server/src/mail/imap.js +1091 -0
- package/server/src/mail/server.js +32 -0
- package/server/src/mail/smtp.js +786 -0
- package/test/cli/Cli.test.js +36 -0
- package/test/core/Candy.test.js +234 -0
- package/test/core/Commands.test.js +538 -0
- package/test/core/Config.test.js +1435 -0
- package/test/core/Lang.test.js +250 -0
- package/test/core/Process.test.js +156 -0
- package/test/framework/Route.test.js +239 -0
- package/test/framework/View/EarlyHints.test.js +282 -0
- package/test/scripts/check-coverage.js +132 -0
- package/test/server/Api.test.js +647 -0
- package/test/server/Client.test.js +338 -0
- package/test/server/DNS.test.js +2050 -0
- package/test/server/DNS.test.js.bak +2084 -0
- package/test/server/Log.test.js +73 -0
- package/test/server/Mail.account.test_.js +460 -0
- package/test/server/Mail.init.test_.js +411 -0
- package/test/server/Mail.test_.js +1340 -0
- package/test/server/SSL.test_.js +1491 -0
- package/test/server/Server.test.js +765 -0
- package/test/server/Service.test_.js +1127 -0
- package/test/server/Subdomain.test.js +440 -0
- package/test/server/Web/Firewall.test.js +175 -0
- package/test/server/Web.test_.js +1562 -0
- package/test/server/__mocks__/acme-client.js +17 -0
- package/test/server/__mocks__/bcrypt.js +50 -0
- package/test/server/__mocks__/child_process.js +389 -0
- package/test/server/__mocks__/crypto.js +432 -0
- package/test/server/__mocks__/fs.js +450 -0
- package/test/server/__mocks__/globalCandy.js +227 -0
- package/test/server/__mocks__/http-proxy.js +105 -0
- package/test/server/__mocks__/http.js +575 -0
- package/test/server/__mocks__/https.js +272 -0
- package/test/server/__mocks__/index.js +249 -0
- package/test/server/__mocks__/mail/server.js +100 -0
- package/test/server/__mocks__/mail/smtp.js +31 -0
- package/test/server/__mocks__/mailparser.js +81 -0
- package/test/server/__mocks__/net.js +369 -0
- package/test/server/__mocks__/node-forge.js +328 -0
- package/test/server/__mocks__/os.js +320 -0
- package/test/server/__mocks__/path.js +291 -0
- package/test/server/__mocks__/selfsigned.js +8 -0
- package/test/server/__mocks__/server/src/mail/server.js +100 -0
- package/test/server/__mocks__/server/src/mail/smtp.js +31 -0
- package/test/server/__mocks__/smtp-server.js +106 -0
- package/test/server/__mocks__/sqlite3.js +394 -0
- package/test/server/__mocks__/testFactories.js +299 -0
- package/test/server/__mocks__/testHelpers.js +363 -0
- package/test/server/__mocks__/tls.js +229 -0
- package/watchdog/index.js +3 -0
- package/watchdog/src/Watchdog.js +156 -0
- package/web/config.json +5 -0
- package/web/controller/page/about.js +27 -0
- package/web/controller/page/index.js +34 -0
- package/web/package.json +18 -0
- package/web/public/assets/css/style.css +1835 -0
- package/web/public/assets/js/app.js +96 -0
- package/web/route/www.js +19 -0
- package/web/skeleton/main.html +22 -0
- package/web/view/content/about.html +65 -0
- package/web/view/content/home.html +205 -0
- package/web/view/footer/main.html +11 -0
- package/web/view/head/main.html +5 -0
- package/web/view/header/main.html +14 -0
|
@@ -0,0 +1,1127 @@
|
|
|
1
|
+
// Import test utilities
|
|
2
|
+
const {setupGlobalMocks, createMockEventEmitter} = require('./__mocks__/testHelpers')
|
|
3
|
+
const {createMockServiceConfig} = require('./__mocks__/testFactories')
|
|
4
|
+
|
|
5
|
+
// Create enhanced mock child process with EventEmitter capabilities
|
|
6
|
+
const createMockChildProcess = (pid = 12345) => {
|
|
7
|
+
const emitter = createMockEventEmitter()
|
|
8
|
+
const stdout = createMockEventEmitter()
|
|
9
|
+
const stderr = createMockEventEmitter()
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
pid,
|
|
13
|
+
killed: false,
|
|
14
|
+
exitCode: null,
|
|
15
|
+
signalCode: null,
|
|
16
|
+
connected: true,
|
|
17
|
+
stdout,
|
|
18
|
+
stderr,
|
|
19
|
+
stdin: {
|
|
20
|
+
write: jest.fn(),
|
|
21
|
+
end: jest.fn(),
|
|
22
|
+
writable: true
|
|
23
|
+
},
|
|
24
|
+
kill: jest.fn((signal = 'SIGTERM') => {
|
|
25
|
+
emitter.killed = true
|
|
26
|
+
emitter.signalCode = signal
|
|
27
|
+
return true
|
|
28
|
+
}),
|
|
29
|
+
send: jest.fn(),
|
|
30
|
+
disconnect: jest.fn(),
|
|
31
|
+
...emitter
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Service will be required inside tests after mocks are set up
|
|
36
|
+
let Service
|
|
37
|
+
|
|
38
|
+
describe('Service', () => {
|
|
39
|
+
let mockCandy
|
|
40
|
+
let mockChildProcess
|
|
41
|
+
let mockSpawn
|
|
42
|
+
let childProcess
|
|
43
|
+
let fs
|
|
44
|
+
let os
|
|
45
|
+
let path
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
jest.clearAllMocks()
|
|
49
|
+
|
|
50
|
+
// Setup global Candy mock
|
|
51
|
+
setupGlobalMocks()
|
|
52
|
+
mockCandy = global.Candy
|
|
53
|
+
|
|
54
|
+
// Mock child_process module
|
|
55
|
+
mockChildProcess = createMockChildProcess()
|
|
56
|
+
mockSpawn = jest.fn().mockReturnValue(mockChildProcess)
|
|
57
|
+
|
|
58
|
+
jest.doMock('child_process', () => ({
|
|
59
|
+
spawn: mockSpawn
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
// Mock fs module
|
|
63
|
+
jest.doMock('fs', () => ({
|
|
64
|
+
existsSync: jest.fn().mockReturnValue(true),
|
|
65
|
+
readFile: jest.fn().mockImplementation((filePath, encoding, callback) => {
|
|
66
|
+
callback(null, 'mock log data')
|
|
67
|
+
}),
|
|
68
|
+
writeFile: jest.fn().mockImplementation((filePath, data, encoding, callback) => {
|
|
69
|
+
callback(null)
|
|
70
|
+
})
|
|
71
|
+
}))
|
|
72
|
+
|
|
73
|
+
// Mock os module
|
|
74
|
+
jest.doMock('os', () => ({
|
|
75
|
+
homedir: jest.fn().mockReturnValue('/home/user')
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
// Mock path module
|
|
79
|
+
jest.doMock('path', () => ({
|
|
80
|
+
basename: jest.fn().mockImplementation(filePath => {
|
|
81
|
+
const parts = filePath.split('/')
|
|
82
|
+
return parts[parts.length - 1]
|
|
83
|
+
}),
|
|
84
|
+
dirname: jest.fn().mockImplementation(filePath => {
|
|
85
|
+
const parts = filePath.split('/')
|
|
86
|
+
return parts.slice(0, -1).join('/')
|
|
87
|
+
}),
|
|
88
|
+
resolve: jest.fn().mockImplementation(filePath => `/resolved${filePath}`)
|
|
89
|
+
}))
|
|
90
|
+
|
|
91
|
+
// Reset and re-require Service to get fresh instance
|
|
92
|
+
jest.resetModules()
|
|
93
|
+
|
|
94
|
+
// Get the mocked modules
|
|
95
|
+
childProcess = require('child_process')
|
|
96
|
+
fs = require('fs')
|
|
97
|
+
os = require('os')
|
|
98
|
+
path = require('path')
|
|
99
|
+
|
|
100
|
+
// Now require Service with our mocks in place
|
|
101
|
+
Service = require('../../server/src/Service')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
jest.resetModules()
|
|
106
|
+
jest.dontMock('child_process')
|
|
107
|
+
jest.dontMock('fs')
|
|
108
|
+
jest.dontMock('os')
|
|
109
|
+
jest.dontMock('path')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('Service registration and process spawning', () => {
|
|
113
|
+
test('should validate service file existence checking', async () => {
|
|
114
|
+
const testFile = '/path/to/nonexistent-service.js'
|
|
115
|
+
|
|
116
|
+
// Mock fs.existsSync to return false for non-existent file
|
|
117
|
+
fs.existsSync.mockReturnValue(false)
|
|
118
|
+
|
|
119
|
+
// Mock config
|
|
120
|
+
mockCandy.setMock('core', 'Config', {
|
|
121
|
+
config: {services: []}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const result = await Service.start(testFile)
|
|
125
|
+
|
|
126
|
+
expect(result.success).toBe(false)
|
|
127
|
+
expect(result.data).toContain('not found')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('should reject empty service file parameter', async () => {
|
|
131
|
+
mockCandy.setMock('core', 'Config', {
|
|
132
|
+
config: {services: []}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const result = await Service.start('')
|
|
136
|
+
|
|
137
|
+
expect(result.success).toBe(false)
|
|
138
|
+
expect(result.data).toContain('not specified')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('should prevent duplicate service registration', async () => {
|
|
142
|
+
const testFile = '/path/to/test-service.js'
|
|
143
|
+
const resolvedFile = `/resolved${testFile}`
|
|
144
|
+
|
|
145
|
+
// Create existing service that matches the resolved file path
|
|
146
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
147
|
+
id: 0,
|
|
148
|
+
name: 'test-service.js',
|
|
149
|
+
file: resolvedFile,
|
|
150
|
+
active: true
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
mockCandy.setMock('core', 'Config', {
|
|
154
|
+
config: {services: [existingService]}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// Mock fs to say file exists
|
|
158
|
+
fs.existsSync.mockReturnValue(true)
|
|
159
|
+
|
|
160
|
+
const result = await Service.start(testFile)
|
|
161
|
+
|
|
162
|
+
expect(result.success).toBe(true)
|
|
163
|
+
expect(result.data).toContain('already exists')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('should generate correct service configuration structure', () => {
|
|
167
|
+
// Test the service configuration structure by examining the factory
|
|
168
|
+
const mockService = createMockServiceConfig('my-awesome-service.js', {
|
|
169
|
+
file: '/resolved/path/to/my-awesome-service.js'
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Verify the service has all required properties for registration
|
|
173
|
+
expect(mockService).toHaveProperty('id')
|
|
174
|
+
expect(mockService).toHaveProperty('name', 'my-awesome-service.js')
|
|
175
|
+
expect(mockService).toHaveProperty('file', '/resolved/path/to/my-awesome-service.js')
|
|
176
|
+
expect(mockService).toHaveProperty('active', true)
|
|
177
|
+
expect(typeof mockService.id).toBe('number')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('should handle service file path resolution', () => {
|
|
181
|
+
const testFile = '/path/to/test-service.js'
|
|
182
|
+
|
|
183
|
+
// Test that path.resolve is called correctly
|
|
184
|
+
const resolved = path.resolve(testFile)
|
|
185
|
+
expect(resolved).toBe(`/resolved${testFile}`)
|
|
186
|
+
expect(path.resolve).toHaveBeenCalledWith(testFile)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('should handle service name extraction from file path', () => {
|
|
190
|
+
const testCases = [
|
|
191
|
+
{input: '/path/to/my-service.js', expected: 'my-service.js'},
|
|
192
|
+
{input: '/another/path/awesome-service.js', expected: 'awesome-service.js'},
|
|
193
|
+
{input: 'simple-service.js', expected: 'simple-service.js'}
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
testCases.forEach(({input, expected}) => {
|
|
197
|
+
const result = path.basename(input)
|
|
198
|
+
expect(result).toBe(expected)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('should handle working directory extraction from file path', () => {
|
|
203
|
+
const testCases = [
|
|
204
|
+
{input: '/path/to/services/my-service.js', expected: '/path/to/services'},
|
|
205
|
+
{input: '/another/location/service.js', expected: '/another/location'},
|
|
206
|
+
{input: '/root/service.js', expected: '/root'}
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
testCases.forEach(({input, expected}) => {
|
|
210
|
+
const result = path.dirname(input)
|
|
211
|
+
expect(result).toBe(expected)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('should verify child process spawning interface', () => {
|
|
216
|
+
// Test that childProcess.spawn is available and can be called with correct parameters
|
|
217
|
+
const testFile = '/resolved/path/to/test-service.js'
|
|
218
|
+
const expectedCwd = '/resolved/path/to'
|
|
219
|
+
|
|
220
|
+
// Call spawn directly to verify the interface
|
|
221
|
+
const mockProcess = mockSpawn('node', [testFile], {cwd: expectedCwd})
|
|
222
|
+
|
|
223
|
+
expect(mockSpawn).toHaveBeenCalledWith('node', [testFile], {cwd: expectedCwd})
|
|
224
|
+
expect(mockProcess).toBeDefined()
|
|
225
|
+
expect(mockProcess.pid).toBeDefined()
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('should verify process ID tracking capabilities', () => {
|
|
229
|
+
// Test that we can track process IDs and status
|
|
230
|
+
const mockPid = 12345
|
|
231
|
+
const serviceConfig = createMockServiceConfig('test-service.js', {
|
|
232
|
+
id: 0,
|
|
233
|
+
active: true,
|
|
234
|
+
pid: mockPid,
|
|
235
|
+
status: 'running',
|
|
236
|
+
started: Date.now()
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// Verify the service configuration has all required fields for process tracking
|
|
240
|
+
expect(serviceConfig).toMatchObject({
|
|
241
|
+
pid: mockPid,
|
|
242
|
+
status: 'running',
|
|
243
|
+
active: true
|
|
244
|
+
})
|
|
245
|
+
expect(serviceConfig.started).toBeDefined()
|
|
246
|
+
expect(typeof serviceConfig.started).toBe('number')
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
describe('Service monitoring and restart logic', () => {
|
|
251
|
+
test('should detect services with missing PIDs and restart them', async () => {
|
|
252
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
253
|
+
id: 0,
|
|
254
|
+
file: '/path/to/test-service.js',
|
|
255
|
+
active: true,
|
|
256
|
+
pid: null // No PID means not running
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
let servicesConfig = [existingService]
|
|
260
|
+
mockCandy.setMock('core', 'Config', {
|
|
261
|
+
config: {
|
|
262
|
+
get services() {
|
|
263
|
+
return servicesConfig
|
|
264
|
+
},
|
|
265
|
+
set services(value) {
|
|
266
|
+
servicesConfig = value
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
await Service.check()
|
|
272
|
+
|
|
273
|
+
expect(mockSpawn).toHaveBeenCalledWith('node', [existingService.file], {
|
|
274
|
+
cwd: path.dirname(existingService.file)
|
|
275
|
+
})
|
|
276
|
+
expect(servicesConfig[0].pid).toBe(12345)
|
|
277
|
+
expect(servicesConfig[0].status).toBe('running')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('should restart services when watcher indicates process is not running', async () => {
|
|
281
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
282
|
+
id: 0,
|
|
283
|
+
file: '/path/to/test-service.js',
|
|
284
|
+
active: true,
|
|
285
|
+
pid: 12345
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
let servicesConfig = [existingService]
|
|
289
|
+
|
|
290
|
+
// Mock Process.stop
|
|
291
|
+
mockCandy.setMock('core', 'Process', {
|
|
292
|
+
stop: jest.fn()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
mockCandy.setMock('core', 'Config', {
|
|
296
|
+
config: {
|
|
297
|
+
get services() {
|
|
298
|
+
return servicesConfig
|
|
299
|
+
},
|
|
300
|
+
set services(value) {
|
|
301
|
+
servicesConfig = value
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
await Service.check()
|
|
307
|
+
|
|
308
|
+
expect(mockCandy.getMock('core', 'Process').stop).toHaveBeenCalledWith(12345)
|
|
309
|
+
expect(mockSpawn).toHaveBeenCalled()
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('should implement error counting mechanism by tracking crashes', async () => {
|
|
313
|
+
// Test that the Service module tracks error counts internally
|
|
314
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
315
|
+
id: 0,
|
|
316
|
+
file: '/path/to/test-service.js',
|
|
317
|
+
active: true,
|
|
318
|
+
pid: null // Start without PID to trigger restart
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
let servicesConfig = [existingService]
|
|
322
|
+
mockCandy.setMock('core', 'Config', {
|
|
323
|
+
config: {
|
|
324
|
+
get services() {
|
|
325
|
+
return servicesConfig
|
|
326
|
+
},
|
|
327
|
+
set services(value) {
|
|
328
|
+
servicesConfig = value
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
// First check should start the service
|
|
334
|
+
await Service.check()
|
|
335
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1)
|
|
336
|
+
|
|
337
|
+
// Simulate process exit to increment error count and set #active to false
|
|
338
|
+
mockChildProcess.emit('exit', 1)
|
|
339
|
+
|
|
340
|
+
// Service should now be stopped and have no PID
|
|
341
|
+
expect(servicesConfig[0].status).toBe('stopped')
|
|
342
|
+
expect(servicesConfig[0].pid).toBeNull()
|
|
343
|
+
|
|
344
|
+
// Set updated time to past to avoid cooldown (error_count * 1000ms)
|
|
345
|
+
servicesConfig[0].updated = Date.now() - 2000
|
|
346
|
+
|
|
347
|
+
// Second check should restart since pid is null and cooldown has passed
|
|
348
|
+
await Service.check()
|
|
349
|
+
expect(mockSpawn).toHaveBeenCalledTimes(2)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('should implement cooldown period after errors', async () => {
|
|
353
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
354
|
+
id: 0,
|
|
355
|
+
file: '/path/to/test-service.js',
|
|
356
|
+
active: true,
|
|
357
|
+
pid: null,
|
|
358
|
+
status: 'errored',
|
|
359
|
+
updated: Date.now() // Recent error timestamp - should trigger cooldown
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
let servicesConfig = [existingService]
|
|
363
|
+
mockCandy.setMock('core', 'Config', {
|
|
364
|
+
config: {
|
|
365
|
+
get services() {
|
|
366
|
+
return servicesConfig
|
|
367
|
+
},
|
|
368
|
+
set services(value) {
|
|
369
|
+
servicesConfig = value
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// First start the service to get an error count
|
|
375
|
+
await Service.check()
|
|
376
|
+
const initialSpawnCount = mockSpawn.mock.calls.length
|
|
377
|
+
|
|
378
|
+
// Simulate exit to increment error count
|
|
379
|
+
mockChildProcess.emit('exit', 1)
|
|
380
|
+
|
|
381
|
+
// Set service to errored with very recent timestamp to trigger cooldown
|
|
382
|
+
servicesConfig[0].status = 'errored'
|
|
383
|
+
servicesConfig[0].updated = Date.now()
|
|
384
|
+
|
|
385
|
+
// Should not restart due to cooldown period
|
|
386
|
+
await Service.check()
|
|
387
|
+
|
|
388
|
+
// Should not have spawned additional processes due to cooldown
|
|
389
|
+
expect(mockSpawn).toHaveBeenCalledTimes(initialSpawnCount)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test('should stop service after exceeding maximum error limit', async () => {
|
|
393
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
394
|
+
id: 0,
|
|
395
|
+
file: '/path/to/test-service.js',
|
|
396
|
+
active: true,
|
|
397
|
+
pid: null,
|
|
398
|
+
status: 'stopped'
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
let servicesConfig = [existingService]
|
|
402
|
+
mockCandy.setMock('core', 'Config', {
|
|
403
|
+
config: {
|
|
404
|
+
get services() {
|
|
405
|
+
return servicesConfig
|
|
406
|
+
},
|
|
407
|
+
set services(value) {
|
|
408
|
+
servicesConfig = value
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
// Simulate many check cycles to exceed error limit
|
|
414
|
+
// The Service module should stop trying after 10 errors
|
|
415
|
+
for (let i = 0; i < 15; i++) {
|
|
416
|
+
await Service.check()
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Should have stopped trying to restart after error limit
|
|
420
|
+
// The exact number depends on internal error counting logic
|
|
421
|
+
expect(mockSpawn.mock.calls.length).toBeLessThan(15)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test('should update service status during lifecycle events', async () => {
|
|
425
|
+
// Test that service status is properly updated during different events
|
|
426
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
427
|
+
id: 0,
|
|
428
|
+
file: '/path/to/test-service.js',
|
|
429
|
+
active: true,
|
|
430
|
+
pid: null
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
let servicesConfig = [existingService]
|
|
434
|
+
mockCandy.setMock('core', 'Config', {
|
|
435
|
+
config: {
|
|
436
|
+
get services() {
|
|
437
|
+
return servicesConfig
|
|
438
|
+
},
|
|
439
|
+
set services(value) {
|
|
440
|
+
servicesConfig = value
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
await Service.check()
|
|
446
|
+
|
|
447
|
+
// Should be running after successful start
|
|
448
|
+
expect(servicesConfig[0].status).toBe('running')
|
|
449
|
+
expect(servicesConfig[0].pid).toBe(12345)
|
|
450
|
+
expect(servicesConfig[0].started).toBeDefined()
|
|
451
|
+
|
|
452
|
+
// Simulate stderr output (error)
|
|
453
|
+
mockChildProcess.stderr.emit('data', 'Error occurred')
|
|
454
|
+
|
|
455
|
+
expect(servicesConfig[0].status).toBe('errored')
|
|
456
|
+
expect(servicesConfig[0].updated).toBeDefined()
|
|
457
|
+
|
|
458
|
+
// Simulate process exit - should only change status if currently running
|
|
459
|
+
// Since we're already errored, the exit handler checks if status is 'running'
|
|
460
|
+
servicesConfig[0].status = 'running' // Reset to running to test exit behavior
|
|
461
|
+
mockChildProcess.emit('exit', 1)
|
|
462
|
+
|
|
463
|
+
expect(servicesConfig[0].status).toBe('stopped')
|
|
464
|
+
expect(servicesConfig[0].pid).toBeNull()
|
|
465
|
+
expect(servicesConfig[0].started).toBeNull()
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test('should handle process monitoring state correctly', async () => {
|
|
469
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
470
|
+
id: 0,
|
|
471
|
+
file: '/path/to/test-service.js',
|
|
472
|
+
active: true,
|
|
473
|
+
pid: null
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
let servicesConfig = [existingService]
|
|
477
|
+
mockCandy.setMock('core', 'Config', {
|
|
478
|
+
config: {
|
|
479
|
+
get services() {
|
|
480
|
+
return servicesConfig
|
|
481
|
+
},
|
|
482
|
+
set services(value) {
|
|
483
|
+
servicesConfig = value
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
// Start first process
|
|
489
|
+
mockChildProcess.pid = 12345
|
|
490
|
+
await Service.check()
|
|
491
|
+
|
|
492
|
+
expect(servicesConfig[0].pid).toBe(12345)
|
|
493
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1)
|
|
494
|
+
|
|
495
|
+
// Simulate process exit - this sets #active[id] = false and pid = null
|
|
496
|
+
mockChildProcess.emit('exit', 1)
|
|
497
|
+
|
|
498
|
+
expect(servicesConfig[0].pid).toBeNull()
|
|
499
|
+
expect(servicesConfig[0].status).toBe('stopped')
|
|
500
|
+
|
|
501
|
+
// Set updated time to past to avoid cooldown
|
|
502
|
+
servicesConfig[0].updated = Date.now() - 2000
|
|
503
|
+
|
|
504
|
+
// Create new mock child process for restart
|
|
505
|
+
const newMockChildProcess = createMockChildProcess(12346)
|
|
506
|
+
mockSpawn.mockReturnValue(newMockChildProcess)
|
|
507
|
+
|
|
508
|
+
// Check should restart the service since pid is null and cooldown has passed
|
|
509
|
+
await Service.check()
|
|
510
|
+
|
|
511
|
+
expect(mockSpawn).toHaveBeenCalledTimes(2)
|
|
512
|
+
expect(servicesConfig[0].pid).toBe(12346)
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
test('should prevent concurrent restarts of the same service', async () => {
|
|
516
|
+
// Test the #active flag prevents concurrent restarts
|
|
517
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
518
|
+
id: 0,
|
|
519
|
+
file: '/path/to/test-service.js',
|
|
520
|
+
active: true,
|
|
521
|
+
pid: null
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
let servicesConfig = [existingService]
|
|
525
|
+
mockCandy.setMock('core', 'Config', {
|
|
526
|
+
config: {
|
|
527
|
+
get services() {
|
|
528
|
+
return servicesConfig
|
|
529
|
+
},
|
|
530
|
+
set services(value) {
|
|
531
|
+
servicesConfig = value
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
// Try to check multiple times rapidly
|
|
537
|
+
const checkPromises = [Service.check(), Service.check(), Service.check()]
|
|
538
|
+
|
|
539
|
+
await Promise.all(checkPromises)
|
|
540
|
+
|
|
541
|
+
// Should only start once due to #active flag
|
|
542
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test('should handle service status transitions correctly', async () => {
|
|
546
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
547
|
+
id: 0,
|
|
548
|
+
file: '/path/to/test-service.js',
|
|
549
|
+
active: true,
|
|
550
|
+
pid: null
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
let servicesConfig = [existingService]
|
|
554
|
+
mockCandy.setMock('core', 'Config', {
|
|
555
|
+
config: {
|
|
556
|
+
get services() {
|
|
557
|
+
return servicesConfig
|
|
558
|
+
},
|
|
559
|
+
set services(value) {
|
|
560
|
+
servicesConfig = value
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
await Service.check()
|
|
566
|
+
|
|
567
|
+
// Should start as running
|
|
568
|
+
expect(servicesConfig[0].status).toBe('running')
|
|
569
|
+
|
|
570
|
+
// Error should change status to errored
|
|
571
|
+
mockChildProcess.stderr.emit('data', 'Test error')
|
|
572
|
+
expect(servicesConfig[0].status).toBe('errored')
|
|
573
|
+
|
|
574
|
+
// Exit should only change status to stopped if currently running
|
|
575
|
+
// Reset to running to test the exit behavior properly
|
|
576
|
+
servicesConfig[0].status = 'running'
|
|
577
|
+
mockChildProcess.emit('exit', 1)
|
|
578
|
+
expect(servicesConfig[0].status).toBe('stopped')
|
|
579
|
+
|
|
580
|
+
// After exit, the service should have no PID and be ready for restart
|
|
581
|
+
expect(servicesConfig[0].pid).toBeNull()
|
|
582
|
+
|
|
583
|
+
// Restart should change back to running, but we need to account for cooldown
|
|
584
|
+
// Set updated time to past to avoid cooldown
|
|
585
|
+
servicesConfig[0].updated = Date.now() - 10000
|
|
586
|
+
|
|
587
|
+
await Service.check()
|
|
588
|
+
expect(servicesConfig[0].status).toBe('running')
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
test('should only monitor active services', async () => {
|
|
592
|
+
const activeService = createMockServiceConfig('active-service.js', {
|
|
593
|
+
id: 0,
|
|
594
|
+
file: '/path/to/active-service.js',
|
|
595
|
+
active: true,
|
|
596
|
+
pid: null
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
const inactiveService = createMockServiceConfig('inactive-service.js', {
|
|
600
|
+
id: 1,
|
|
601
|
+
file: '/path/to/inactive-service.js',
|
|
602
|
+
active: false,
|
|
603
|
+
pid: null
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
let servicesConfig = [activeService, inactiveService]
|
|
607
|
+
mockCandy.setMock('core', 'Config', {
|
|
608
|
+
config: {
|
|
609
|
+
get services() {
|
|
610
|
+
return servicesConfig
|
|
611
|
+
},
|
|
612
|
+
set services(value) {
|
|
613
|
+
servicesConfig = value
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
await Service.check()
|
|
619
|
+
|
|
620
|
+
// Should only start the active service
|
|
621
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1)
|
|
622
|
+
expect(mockSpawn).toHaveBeenCalledWith('node', [activeService.file], {
|
|
623
|
+
cwd: path.dirname(activeService.file)
|
|
624
|
+
})
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
test('should handle watcher state management correctly', async () => {
|
|
628
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
629
|
+
id: 0,
|
|
630
|
+
file: '/path/to/test-service.js',
|
|
631
|
+
active: true,
|
|
632
|
+
pid: 12345,
|
|
633
|
+
status: 'running', // Set initial status
|
|
634
|
+
updated: Date.now() - 5000 // Set to past to avoid any cooldown issues
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
let servicesConfig = [existingService]
|
|
638
|
+
|
|
639
|
+
// Mock Process.stop to simulate process not found
|
|
640
|
+
mockCandy.setMock('core', 'Process', {
|
|
641
|
+
stop: jest.fn()
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
mockCandy.setMock('core', 'Config', {
|
|
645
|
+
config: {
|
|
646
|
+
get services() {
|
|
647
|
+
return servicesConfig
|
|
648
|
+
},
|
|
649
|
+
set services(value) {
|
|
650
|
+
servicesConfig = value
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
await Service.check()
|
|
656
|
+
|
|
657
|
+
// Should detect missing process and restart
|
|
658
|
+
expect(mockCandy.getMock('core', 'Process').stop).toHaveBeenCalledWith(12345)
|
|
659
|
+
expect(mockSpawn).toHaveBeenCalled()
|
|
660
|
+
|
|
661
|
+
// The check method sets PID to null after calling #run, so PID should be null
|
|
662
|
+
// This is the actual behavior: #run sets PID, then check sets it to null
|
|
663
|
+
expect(servicesConfig[0].pid).toBeNull()
|
|
664
|
+
})
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
describe('Service log management and status reporting', () => {
|
|
668
|
+
test('should capture stdout and stderr logs', async () => {
|
|
669
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
670
|
+
id: 0,
|
|
671
|
+
file: '/path/to/test-service.js',
|
|
672
|
+
active: true,
|
|
673
|
+
pid: null
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
let servicesConfig = [existingService]
|
|
677
|
+
mockCandy.setMock('core', 'Config', {
|
|
678
|
+
config: {
|
|
679
|
+
get services() {
|
|
680
|
+
return servicesConfig
|
|
681
|
+
},
|
|
682
|
+
set services(value) {
|
|
683
|
+
servicesConfig = value
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
// Start the service
|
|
689
|
+
await Service.check()
|
|
690
|
+
|
|
691
|
+
// Simulate stdout data
|
|
692
|
+
const stdoutData = 'Service started successfully'
|
|
693
|
+
mockChildProcess.stdout.emit('data', stdoutData)
|
|
694
|
+
|
|
695
|
+
// Simulate stderr data
|
|
696
|
+
const stderrData = 'Warning: deprecated function'
|
|
697
|
+
mockChildProcess.stderr.emit('data', stderrData)
|
|
698
|
+
|
|
699
|
+
// Trigger log writing by calling check again
|
|
700
|
+
await Service.check()
|
|
701
|
+
|
|
702
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
703
|
+
'/home/user/.candypack/logs/test-service.js.log',
|
|
704
|
+
expect.stringContaining(stdoutData),
|
|
705
|
+
'utf8',
|
|
706
|
+
expect.any(Function)
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
710
|
+
'/home/user/.candypack/logs/test-service.js.err.log',
|
|
711
|
+
expect.stringContaining(stderrData),
|
|
712
|
+
'utf8',
|
|
713
|
+
expect.any(Function)
|
|
714
|
+
)
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
test('should format log entries with timestamps', async () => {
|
|
718
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
719
|
+
id: 0,
|
|
720
|
+
file: '/path/to/test-service.js',
|
|
721
|
+
active: true,
|
|
722
|
+
pid: null
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
let servicesConfig = [existingService]
|
|
726
|
+
mockCandy.setMock('core', 'Config', {
|
|
727
|
+
config: {
|
|
728
|
+
get services() {
|
|
729
|
+
return servicesConfig
|
|
730
|
+
},
|
|
731
|
+
set services(value) {
|
|
732
|
+
servicesConfig = value
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
await Service.check()
|
|
738
|
+
|
|
739
|
+
const testMessage = 'Test log message'
|
|
740
|
+
mockChildProcess.stdout.emit('data', testMessage)
|
|
741
|
+
|
|
742
|
+
await Service.check()
|
|
743
|
+
|
|
744
|
+
const logCall = fs.writeFile.mock.calls.find(call => call[0].includes('.log') && !call[0].includes('.err.log'))
|
|
745
|
+
|
|
746
|
+
expect(logCall[1]).toMatch(/\[LOG\]\[\d+\] Test log message/)
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
test('should handle multiline log messages correctly', async () => {
|
|
750
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
751
|
+
id: 0,
|
|
752
|
+
file: '/path/to/test-service.js',
|
|
753
|
+
active: true,
|
|
754
|
+
pid: null
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
let servicesConfig = [existingService]
|
|
758
|
+
mockCandy.setMock('core', 'Config', {
|
|
759
|
+
config: {
|
|
760
|
+
get services() {
|
|
761
|
+
return servicesConfig
|
|
762
|
+
},
|
|
763
|
+
set services(value) {
|
|
764
|
+
servicesConfig = value
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
await Service.check()
|
|
770
|
+
|
|
771
|
+
const multilineMessage = 'Line 1\nLine 2\nLine 3'
|
|
772
|
+
mockChildProcess.stdout.emit('data', multilineMessage)
|
|
773
|
+
|
|
774
|
+
await Service.check()
|
|
775
|
+
|
|
776
|
+
const logCall = fs.writeFile.mock.calls.find(call => call[0].includes('.log') && !call[0].includes('.err.log'))
|
|
777
|
+
|
|
778
|
+
expect(logCall[1]).toMatch(/\[LOG\]\[\d+\] Line 1\n\[LOG\]\[\d+\] Line 2\n\[LOG\]\[\d+\] Line 3/)
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
test('should implement log rotation when logs exceed size limit', async () => {
|
|
782
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
783
|
+
id: 0,
|
|
784
|
+
file: '/path/to/test-service.js',
|
|
785
|
+
active: true,
|
|
786
|
+
pid: null
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
let servicesConfig = [existingService]
|
|
790
|
+
mockCandy.setMock('core', 'Config', {
|
|
791
|
+
config: {
|
|
792
|
+
get services() {
|
|
793
|
+
return servicesConfig
|
|
794
|
+
},
|
|
795
|
+
set services(value) {
|
|
796
|
+
servicesConfig = value
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
await Service.check()
|
|
802
|
+
|
|
803
|
+
// Generate large log data (over 1MB)
|
|
804
|
+
const largeMessage = 'x'.repeat(500000)
|
|
805
|
+
mockChildProcess.stdout.emit('data', largeMessage)
|
|
806
|
+
mockChildProcess.stdout.emit('data', largeMessage)
|
|
807
|
+
mockChildProcess.stdout.emit('data', largeMessage)
|
|
808
|
+
|
|
809
|
+
await Service.check()
|
|
810
|
+
|
|
811
|
+
const logCall = fs.writeFile.mock.calls.find(call => call[0].includes('.log') && !call[0].includes('.err.log'))
|
|
812
|
+
|
|
813
|
+
// Log should be truncated to 1MB
|
|
814
|
+
expect(logCall[1].length).toBeLessThanOrEqual(1000000)
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
test('should load existing logs on initialization', async () => {
|
|
818
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
819
|
+
id: 0,
|
|
820
|
+
file: '/path/to/test-service.js',
|
|
821
|
+
active: true
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
const existingLogData = 'Previous log entries'
|
|
825
|
+
fs.readFile.mockImplementation((path, encoding, callback) => {
|
|
826
|
+
if (path.includes('test-service.js.log')) {
|
|
827
|
+
callback(null, existingLogData)
|
|
828
|
+
} else {
|
|
829
|
+
callback(new Error('File not found'))
|
|
830
|
+
}
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
mockCandy.setMock('core', 'Config', {
|
|
834
|
+
config: {services: [existingService]}
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
await Service.init()
|
|
838
|
+
|
|
839
|
+
expect(fs.readFile).toHaveBeenCalledWith('/home/user/.candypack/logs/test-service.js.log', 'utf8', expect.any(Function))
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
test('should calculate and report service uptime correctly', async () => {
|
|
843
|
+
const startTime = Date.now() - 90061000 // 1 day, 1 hour, 1 minute, 1 second ago
|
|
844
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
845
|
+
id: 0,
|
|
846
|
+
file: '/path/to/test-service.js',
|
|
847
|
+
active: true,
|
|
848
|
+
status: 'running',
|
|
849
|
+
started: startTime
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
mockCandy.setMock('core', 'Config', {
|
|
853
|
+
config: {services: [existingService]}
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
const services = await Service.status()
|
|
857
|
+
|
|
858
|
+
expect(services[0].uptime).toMatch(/1d 1h 1m 1s/)
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
test('should handle uptime calculation for various durations', async () => {
|
|
862
|
+
const testCases = [
|
|
863
|
+
{duration: 3661000, expected: /1h 1m 1s/}, // 1 hour, 1 minute, 1 second
|
|
864
|
+
{duration: 61000, expected: /1m 1s/}, // 1 minute, 1 second
|
|
865
|
+
{duration: 1000, expected: /1s/}, // 1 second
|
|
866
|
+
{duration: 500, expected: /^$/} // Less than 1 second - empty string
|
|
867
|
+
]
|
|
868
|
+
|
|
869
|
+
for (const testCase of testCases) {
|
|
870
|
+
const startTime = Date.now() - testCase.duration
|
|
871
|
+
const service = createMockServiceConfig('test-service.js', {
|
|
872
|
+
id: 0,
|
|
873
|
+
status: 'running',
|
|
874
|
+
started: startTime
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
mockCandy.setMock('core', 'Config', {
|
|
878
|
+
config: {services: [service]}
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
const services = await Service.status()
|
|
882
|
+
if (testCase.duration < 1000) {
|
|
883
|
+
// For very short durations, uptime might be empty string
|
|
884
|
+
expect(services[0].uptime || '').toMatch(testCase.expected)
|
|
885
|
+
} else {
|
|
886
|
+
expect(services[0].uptime).toMatch(testCase.expected)
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
test('should stop service and clean up resources', async () => {
|
|
892
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
893
|
+
id: 0,
|
|
894
|
+
file: '/path/to/test-service.js',
|
|
895
|
+
active: true,
|
|
896
|
+
pid: 12345
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
let servicesConfig = [existingService]
|
|
900
|
+
const mockProcessStop = jest.fn()
|
|
901
|
+
|
|
902
|
+
mockCandy.setMock('core', 'Process', {
|
|
903
|
+
stop: mockProcessStop
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
mockCandy.setMock('core', 'Config', {
|
|
907
|
+
config: {
|
|
908
|
+
get services() {
|
|
909
|
+
return servicesConfig
|
|
910
|
+
},
|
|
911
|
+
set services(value) {
|
|
912
|
+
servicesConfig = value
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
Service.stop(0)
|
|
918
|
+
|
|
919
|
+
expect(mockProcessStop).toHaveBeenCalledWith(12345)
|
|
920
|
+
expect(servicesConfig[0].pid).toBeNull()
|
|
921
|
+
expect(servicesConfig[0].started).toBeNull()
|
|
922
|
+
expect(servicesConfig[0].active).toBe(false)
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
test('should handle stopping non-existent service gracefully', async () => {
|
|
926
|
+
mockCandy.setMock('core', 'Config', {
|
|
927
|
+
config: {services: []}
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
// Should not throw error
|
|
931
|
+
expect(() => Service.stop('nonexistent')).not.toThrow()
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
test('should handle stopping already stopped service', async () => {
|
|
935
|
+
const stoppedService = createMockServiceConfig('test-service.js', {
|
|
936
|
+
id: 0,
|
|
937
|
+
active: false,
|
|
938
|
+
pid: null
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
mockCandy.setMock('core', 'Config', {
|
|
942
|
+
config: {services: [stoppedService]}
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
// Should not throw error
|
|
946
|
+
expect(() => Service.stop(0)).not.toThrow()
|
|
947
|
+
})
|
|
948
|
+
|
|
949
|
+
test('should handle file write errors gracefully', async () => {
|
|
950
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
951
|
+
id: 0,
|
|
952
|
+
file: '/path/to/test-service.js',
|
|
953
|
+
active: true,
|
|
954
|
+
pid: null
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
// Mock fs.writeFile to call callback with error
|
|
958
|
+
fs.writeFile.mockImplementation((path, data, encoding, callback) => {
|
|
959
|
+
callback(new Error('Disk full'))
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
let servicesConfig = [existingService]
|
|
963
|
+
mockCandy.setMock('core', 'Config', {
|
|
964
|
+
config: {
|
|
965
|
+
get services() {
|
|
966
|
+
return servicesConfig
|
|
967
|
+
},
|
|
968
|
+
set services(value) {
|
|
969
|
+
servicesConfig = value
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
await Service.check()
|
|
975
|
+
|
|
976
|
+
mockChildProcess.stdout.emit('data', 'Test message')
|
|
977
|
+
|
|
978
|
+
// Should not throw error even when file write fails
|
|
979
|
+
await expect(Service.check()).resolves.not.toThrow()
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
test('should handle stderr logs and add them to both logs and errs', async () => {
|
|
983
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
984
|
+
id: 0,
|
|
985
|
+
file: '/path/to/test-service.js',
|
|
986
|
+
active: true,
|
|
987
|
+
pid: null
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
let servicesConfig = [existingService]
|
|
991
|
+
mockCandy.setMock('core', 'Config', {
|
|
992
|
+
config: {
|
|
993
|
+
get services() {
|
|
994
|
+
return servicesConfig
|
|
995
|
+
},
|
|
996
|
+
set services(value) {
|
|
997
|
+
servicesConfig = value
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
await Service.check()
|
|
1003
|
+
|
|
1004
|
+
const errorMessage = 'Critical error occurred'
|
|
1005
|
+
mockChildProcess.stderr.emit('data', errorMessage)
|
|
1006
|
+
|
|
1007
|
+
await Service.check()
|
|
1008
|
+
|
|
1009
|
+
// Should write to both regular log and error log
|
|
1010
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
1011
|
+
'/home/user/.candypack/logs/test-service.js.log',
|
|
1012
|
+
expect.stringContaining('[ERR]'),
|
|
1013
|
+
'utf8',
|
|
1014
|
+
expect.any(Function)
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
1018
|
+
'/home/user/.candypack/logs/test-service.js.err.log',
|
|
1019
|
+
expect.stringContaining(errorMessage),
|
|
1020
|
+
'utf8',
|
|
1021
|
+
expect.any(Function)
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
// Should also update service status to errored
|
|
1025
|
+
expect(servicesConfig[0].status).toBe('errored')
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
test('should handle log rotation for error logs when they exceed size limit', async () => {
|
|
1029
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
1030
|
+
id: 0,
|
|
1031
|
+
file: '/path/to/test-service.js',
|
|
1032
|
+
active: true,
|
|
1033
|
+
pid: null
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
let servicesConfig = [existingService]
|
|
1037
|
+
mockCandy.setMock('core', 'Config', {
|
|
1038
|
+
config: {
|
|
1039
|
+
get services() {
|
|
1040
|
+
return servicesConfig
|
|
1041
|
+
},
|
|
1042
|
+
set services(value) {
|
|
1043
|
+
servicesConfig = value
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
await Service.check()
|
|
1049
|
+
|
|
1050
|
+
// Generate large error log data (over 1MB)
|
|
1051
|
+
const largeErrorMessage = 'x'.repeat(500000)
|
|
1052
|
+
mockChildProcess.stderr.emit('data', largeErrorMessage)
|
|
1053
|
+
mockChildProcess.stderr.emit('data', largeErrorMessage)
|
|
1054
|
+
mockChildProcess.stderr.emit('data', largeErrorMessage)
|
|
1055
|
+
|
|
1056
|
+
await Service.check()
|
|
1057
|
+
|
|
1058
|
+
const errorLogCall = fs.writeFile.mock.calls.find(call => call[0].includes('.err.log'))
|
|
1059
|
+
|
|
1060
|
+
// Error log should be truncated to 1MB
|
|
1061
|
+
expect(errorLogCall[1].length).toBeLessThanOrEqual(1000000)
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
test('should not write logs for services without log data', async () => {
|
|
1065
|
+
const existingService = createMockServiceConfig('test-service.js', {
|
|
1066
|
+
id: 0,
|
|
1067
|
+
file: '/path/to/test-service.js',
|
|
1068
|
+
active: true,
|
|
1069
|
+
pid: 12345 // Already running, won't restart
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
let servicesConfig = [existingService]
|
|
1073
|
+
mockCandy.setMock('core', 'Config', {
|
|
1074
|
+
config: {
|
|
1075
|
+
get services() {
|
|
1076
|
+
return servicesConfig
|
|
1077
|
+
},
|
|
1078
|
+
set services(value) {
|
|
1079
|
+
servicesConfig = value
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
// Mock watcher to indicate process is running
|
|
1085
|
+
Service.check.__proto__.constructor.prototype['#watcher'] = {12345: true}
|
|
1086
|
+
|
|
1087
|
+
await Service.check()
|
|
1088
|
+
|
|
1089
|
+
// Should not call fs.writeFile since there are no logs
|
|
1090
|
+
expect(fs.writeFile).not.toHaveBeenCalled()
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
test('should handle service status reporting for stopped services', async () => {
|
|
1094
|
+
const stoppedService = createMockServiceConfig('test-service.js', {
|
|
1095
|
+
id: 0,
|
|
1096
|
+
status: 'stopped',
|
|
1097
|
+
started: null
|
|
1098
|
+
})
|
|
1099
|
+
|
|
1100
|
+
mockCandy.setMock('core', 'Config', {
|
|
1101
|
+
config: {services: [stoppedService]}
|
|
1102
|
+
})
|
|
1103
|
+
|
|
1104
|
+
const services = await Service.status()
|
|
1105
|
+
|
|
1106
|
+
// Stopped services should not have uptime calculated
|
|
1107
|
+
expect(services[0].uptime).toBeUndefined()
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
test('should handle service status reporting for errored services', async () => {
|
|
1111
|
+
const erroredService = createMockServiceConfig('test-service.js', {
|
|
1112
|
+
id: 0,
|
|
1113
|
+
status: 'errored',
|
|
1114
|
+
started: Date.now() - 60000 // 1 minute ago
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
mockCandy.setMock('core', 'Config', {
|
|
1118
|
+
config: {services: [erroredService]}
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
const services = await Service.status()
|
|
1122
|
+
|
|
1123
|
+
// Errored services should not have uptime calculated (only running services do)
|
|
1124
|
+
expect(services[0].uptime).toBeUndefined()
|
|
1125
|
+
})
|
|
1126
|
+
})
|
|
1127
|
+
})
|