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.
Files changed (213) hide show
  1. package/.editorconfig +21 -0
  2. package/.github/workflows/auto-pr-description.yml +49 -0
  3. package/.github/workflows/release.yml +32 -0
  4. package/.github/workflows/test-coverage.yml +58 -0
  5. package/.husky/pre-commit +2 -0
  6. package/.kiro/steering/code-style.md +56 -0
  7. package/.kiro/steering/product.md +20 -0
  8. package/.kiro/steering/structure.md +77 -0
  9. package/.kiro/steering/tech.md +87 -0
  10. package/.prettierrc +10 -0
  11. package/.releaserc.js +134 -0
  12. package/AGENTS.md +84 -0
  13. package/CHANGELOG.md +181 -0
  14. package/CODE_OF_CONDUCT.md +83 -0
  15. package/CONTRIBUTING.md +63 -0
  16. package/LICENSE +661 -0
  17. package/README.md +57 -0
  18. package/SECURITY.md +26 -0
  19. package/bin/candy +10 -0
  20. package/bin/candypack +10 -0
  21. package/cli/index.js +3 -0
  22. package/cli/src/Cli.js +348 -0
  23. package/cli/src/Connector.js +93 -0
  24. package/cli/src/Monitor.js +416 -0
  25. package/core/Candy.js +87 -0
  26. package/core/Commands.js +239 -0
  27. package/core/Config.js +1094 -0
  28. package/core/Lang.js +52 -0
  29. package/core/Log.js +43 -0
  30. package/core/Process.js +26 -0
  31. package/docs/backend/01-overview/01-whats-in-the-candy-box.md +9 -0
  32. package/docs/backend/01-overview/02-super-handy-helper-functions.md +9 -0
  33. package/docs/backend/01-overview/03-development-server.md +79 -0
  34. package/docs/backend/02-structure/01-typical-project-layout.md +39 -0
  35. package/docs/backend/03-config/00-configuration-overview.md +214 -0
  36. package/docs/backend/03-config/01-database-connection.md +60 -0
  37. package/docs/backend/03-config/02-static-route-mapping-optional.md +20 -0
  38. package/docs/backend/03-config/03-request-timeout.md +11 -0
  39. package/docs/backend/03-config/04-environment-variables.md +227 -0
  40. package/docs/backend/03-config/05-early-hints.md +352 -0
  41. package/docs/backend/04-routing/01-basic-page-routes.md +28 -0
  42. package/docs/backend/04-routing/02-controller-less-view-routes.md +43 -0
  43. package/docs/backend/04-routing/03-api-and-data-routes.md +20 -0
  44. package/docs/backend/04-routing/04-authentication-aware-routes.md +48 -0
  45. package/docs/backend/04-routing/05-advanced-routing.md +14 -0
  46. package/docs/backend/04-routing/06-error-pages.md +101 -0
  47. package/docs/backend/04-routing/07-cron-jobs.md +149 -0
  48. package/docs/backend/05-controllers/01-how-to-build-a-controller.md +17 -0
  49. package/docs/backend/05-controllers/02-your-trusty-candy-assistant.md +20 -0
  50. package/docs/backend/05-controllers/03-controller-classes.md +93 -0
  51. package/docs/backend/05-forms/01-custom-forms.md +395 -0
  52. package/docs/backend/05-forms/02-automatic-database-insert.md +297 -0
  53. package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +96 -0
  54. package/docs/backend/06-request-and-response/02-sending-a-response-replying-to-the-user.md +40 -0
  55. package/docs/backend/07-views/01-the-view-directory.md +73 -0
  56. package/docs/backend/07-views/02-rendering-a-view.md +179 -0
  57. package/docs/backend/07-views/03-template-syntax.md +181 -0
  58. package/docs/backend/07-views/03-variables.md +328 -0
  59. package/docs/backend/07-views/04-request-data.md +231 -0
  60. package/docs/backend/07-views/05-conditionals.md +290 -0
  61. package/docs/backend/07-views/06-loops.md +353 -0
  62. package/docs/backend/07-views/07-translations.md +358 -0
  63. package/docs/backend/07-views/08-backend-javascript.md +398 -0
  64. package/docs/backend/07-views/09-comments.md +297 -0
  65. package/docs/backend/08-database/01-database-connection.md +99 -0
  66. package/docs/backend/08-database/02-using-mysql.md +322 -0
  67. package/docs/backend/09-validation/01-the-validator-service.md +424 -0
  68. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +53 -0
  69. package/docs/backend/10-authentication/02-foiling-villains-with-csrf-protection.md +55 -0
  70. package/docs/backend/10-authentication/03-register.md +134 -0
  71. package/docs/backend/10-authentication/04-candy-register-forms.md +676 -0
  72. package/docs/backend/10-authentication/05-session-management.md +159 -0
  73. package/docs/backend/10-authentication/06-candy-login-forms.md +596 -0
  74. package/docs/backend/11-mail/01-the-mail-service.md +42 -0
  75. package/docs/backend/12-streaming/01-streaming-overview.md +300 -0
  76. package/docs/backend/13-utilities/01-candy-var.md +504 -0
  77. package/docs/frontend/01-overview/01-introduction.md +146 -0
  78. package/docs/frontend/02-ajax-navigation/01-quick-start.md +608 -0
  79. package/docs/frontend/02-ajax-navigation/02-configuration.md +370 -0
  80. package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +519 -0
  81. package/docs/frontend/03-forms/01-form-handling.md +420 -0
  82. package/docs/frontend/04-api-requests/01-get-post.md +443 -0
  83. package/docs/frontend/05-streaming/01-client-streaming.md +163 -0
  84. package/docs/index.json +452 -0
  85. package/docs/server/01-installation/01-quick-install.md +19 -0
  86. package/docs/server/01-installation/02-manual-installation-via-npm.md +9 -0
  87. package/docs/server/02-get-started/01-core-concepts.md +7 -0
  88. package/docs/server/02-get-started/02-basic-commands.md +57 -0
  89. package/docs/server/02-get-started/03-cli-reference.md +276 -0
  90. package/docs/server/02-get-started/04-cli-quick-reference.md +102 -0
  91. package/docs/server/03-service/01-start-a-new-service.md +57 -0
  92. package/docs/server/03-service/02-delete-a-service.md +48 -0
  93. package/docs/server/04-web/01-create-a-website.md +36 -0
  94. package/docs/server/04-web/02-list-websites.md +9 -0
  95. package/docs/server/04-web/03-delete-a-website.md +29 -0
  96. package/docs/server/05-subdomain/01-create-a-subdomain.md +32 -0
  97. package/docs/server/05-subdomain/02-list-subdomains.md +33 -0
  98. package/docs/server/05-subdomain/03-delete-a-subdomain.md +41 -0
  99. package/docs/server/06-ssl/01-renew-an-ssl-certificate.md +34 -0
  100. package/docs/server/07-mail/01-create-a-mail-account.md +23 -0
  101. package/docs/server/07-mail/02-delete-a-mail-account.md +20 -0
  102. package/docs/server/07-mail/03-list-mail-accounts.md +20 -0
  103. package/docs/server/07-mail/04-change-account-password.md +23 -0
  104. package/eslint.config.mjs +120 -0
  105. package/framework/index.js +4 -0
  106. package/framework/src/Auth.js +309 -0
  107. package/framework/src/Candy.js +81 -0
  108. package/framework/src/Config.js +79 -0
  109. package/framework/src/Env.js +60 -0
  110. package/framework/src/Lang.js +57 -0
  111. package/framework/src/Mail.js +83 -0
  112. package/framework/src/Mysql.js +575 -0
  113. package/framework/src/Request.js +301 -0
  114. package/framework/src/Route/Cron.js +128 -0
  115. package/framework/src/Route/Internal.js +439 -0
  116. package/framework/src/Route.js +455 -0
  117. package/framework/src/Server.js +15 -0
  118. package/framework/src/Stream.js +163 -0
  119. package/framework/src/Token.js +37 -0
  120. package/framework/src/Validator.js +271 -0
  121. package/framework/src/Var.js +211 -0
  122. package/framework/src/View/EarlyHints.js +190 -0
  123. package/framework/src/View/Form.js +600 -0
  124. package/framework/src/View.js +513 -0
  125. package/framework/web/candy.js +838 -0
  126. package/jest.config.js +22 -0
  127. package/locale/de-DE.json +80 -0
  128. package/locale/en-US.json +79 -0
  129. package/locale/es-ES.json +80 -0
  130. package/locale/fr-FR.json +80 -0
  131. package/locale/pt-BR.json +80 -0
  132. package/locale/ru-RU.json +80 -0
  133. package/locale/tr-TR.json +85 -0
  134. package/locale/zh-CN.json +80 -0
  135. package/package.json +86 -0
  136. package/server/index.js +5 -0
  137. package/server/src/Api.js +88 -0
  138. package/server/src/DNS.js +940 -0
  139. package/server/src/Hub.js +535 -0
  140. package/server/src/Mail.js +571 -0
  141. package/server/src/SSL.js +180 -0
  142. package/server/src/Server.js +27 -0
  143. package/server/src/Service.js +248 -0
  144. package/server/src/Subdomain.js +64 -0
  145. package/server/src/Web/Firewall.js +170 -0
  146. package/server/src/Web/Proxy.js +134 -0
  147. package/server/src/Web.js +451 -0
  148. package/server/src/mail/imap.js +1091 -0
  149. package/server/src/mail/server.js +32 -0
  150. package/server/src/mail/smtp.js +786 -0
  151. package/test/cli/Cli.test.js +36 -0
  152. package/test/core/Candy.test.js +234 -0
  153. package/test/core/Commands.test.js +538 -0
  154. package/test/core/Config.test.js +1435 -0
  155. package/test/core/Lang.test.js +250 -0
  156. package/test/core/Process.test.js +156 -0
  157. package/test/framework/Route.test.js +239 -0
  158. package/test/framework/View/EarlyHints.test.js +282 -0
  159. package/test/scripts/check-coverage.js +132 -0
  160. package/test/server/Api.test.js +647 -0
  161. package/test/server/Client.test.js +338 -0
  162. package/test/server/DNS.test.js +2050 -0
  163. package/test/server/DNS.test.js.bak +2084 -0
  164. package/test/server/Log.test.js +73 -0
  165. package/test/server/Mail.account.test_.js +460 -0
  166. package/test/server/Mail.init.test_.js +411 -0
  167. package/test/server/Mail.test_.js +1340 -0
  168. package/test/server/SSL.test_.js +1491 -0
  169. package/test/server/Server.test.js +765 -0
  170. package/test/server/Service.test_.js +1127 -0
  171. package/test/server/Subdomain.test.js +440 -0
  172. package/test/server/Web/Firewall.test.js +175 -0
  173. package/test/server/Web.test_.js +1562 -0
  174. package/test/server/__mocks__/acme-client.js +17 -0
  175. package/test/server/__mocks__/bcrypt.js +50 -0
  176. package/test/server/__mocks__/child_process.js +389 -0
  177. package/test/server/__mocks__/crypto.js +432 -0
  178. package/test/server/__mocks__/fs.js +450 -0
  179. package/test/server/__mocks__/globalCandy.js +227 -0
  180. package/test/server/__mocks__/http-proxy.js +105 -0
  181. package/test/server/__mocks__/http.js +575 -0
  182. package/test/server/__mocks__/https.js +272 -0
  183. package/test/server/__mocks__/index.js +249 -0
  184. package/test/server/__mocks__/mail/server.js +100 -0
  185. package/test/server/__mocks__/mail/smtp.js +31 -0
  186. package/test/server/__mocks__/mailparser.js +81 -0
  187. package/test/server/__mocks__/net.js +369 -0
  188. package/test/server/__mocks__/node-forge.js +328 -0
  189. package/test/server/__mocks__/os.js +320 -0
  190. package/test/server/__mocks__/path.js +291 -0
  191. package/test/server/__mocks__/selfsigned.js +8 -0
  192. package/test/server/__mocks__/server/src/mail/server.js +100 -0
  193. package/test/server/__mocks__/server/src/mail/smtp.js +31 -0
  194. package/test/server/__mocks__/smtp-server.js +106 -0
  195. package/test/server/__mocks__/sqlite3.js +394 -0
  196. package/test/server/__mocks__/testFactories.js +299 -0
  197. package/test/server/__mocks__/testHelpers.js +363 -0
  198. package/test/server/__mocks__/tls.js +229 -0
  199. package/watchdog/index.js +3 -0
  200. package/watchdog/src/Watchdog.js +156 -0
  201. package/web/config.json +5 -0
  202. package/web/controller/page/about.js +27 -0
  203. package/web/controller/page/index.js +34 -0
  204. package/web/package.json +18 -0
  205. package/web/public/assets/css/style.css +1835 -0
  206. package/web/public/assets/js/app.js +96 -0
  207. package/web/route/www.js +19 -0
  208. package/web/skeleton/main.html +22 -0
  209. package/web/view/content/about.html +65 -0
  210. package/web/view/content/home.html +205 -0
  211. package/web/view/footer/main.html +11 -0
  212. package/web/view/head/main.html +5 -0
  213. 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
+ })