instar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -0
- package/.claude/skills/setup-wizard/skill.md +343 -0
- package/.github/workflows/ci.yml +78 -0
- package/CLAUDE.md +82 -0
- package/README.md +194 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +141 -0
- package/dist/commands/init.d.ts +40 -0
- package/dist/commands/init.js +568 -0
- package/dist/commands/job.d.ts +20 -0
- package/dist/commands/job.js +84 -0
- package/dist/commands/server.d.ts +19 -0
- package/dist/commands/server.js +273 -0
- package/dist/commands/setup.d.ts +24 -0
- package/dist/commands/setup.js +865 -0
- package/dist/commands/status.d.ts +11 -0
- package/dist/commands/status.js +114 -0
- package/dist/commands/user.d.ts +17 -0
- package/dist/commands/user.js +53 -0
- package/dist/core/Config.d.ts +16 -0
- package/dist/core/Config.js +144 -0
- package/dist/core/Prerequisites.d.ts +28 -0
- package/dist/core/Prerequisites.js +159 -0
- package/dist/core/RelationshipManager.d.ts +73 -0
- package/dist/core/RelationshipManager.js +318 -0
- package/dist/core/SessionManager.d.ts +89 -0
- package/dist/core/SessionManager.js +326 -0
- package/dist/core/StateManager.d.ts +28 -0
- package/dist/core/StateManager.js +96 -0
- package/dist/core/types.d.ts +279 -0
- package/dist/core/types.js +8 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +23 -0
- package/dist/messaging/TelegramAdapter.d.ts +73 -0
- package/dist/messaging/TelegramAdapter.js +288 -0
- package/dist/monitoring/HealthChecker.d.ts +38 -0
- package/dist/monitoring/HealthChecker.js +148 -0
- package/dist/scaffold/bootstrap.d.ts +21 -0
- package/dist/scaffold/bootstrap.js +110 -0
- package/dist/scaffold/templates.d.ts +34 -0
- package/dist/scaffold/templates.js +187 -0
- package/dist/scheduler/JobLoader.d.ts +18 -0
- package/dist/scheduler/JobLoader.js +70 -0
- package/dist/scheduler/JobScheduler.d.ts +111 -0
- package/dist/scheduler/JobScheduler.js +402 -0
- package/dist/server/AgentServer.d.ts +40 -0
- package/dist/server/AgentServer.js +73 -0
- package/dist/server/middleware.d.ts +12 -0
- package/dist/server/middleware.js +50 -0
- package/dist/server/routes.d.ts +25 -0
- package/dist/server/routes.js +224 -0
- package/dist/users/UserManager.d.ts +45 -0
- package/dist/users/UserManager.js +113 -0
- package/docs/dawn-audit-report.md +412 -0
- package/docs/positioning-vs-openclaw.md +246 -0
- package/package.json +52 -0
- package/src/cli.ts +169 -0
- package/src/commands/init.ts +654 -0
- package/src/commands/job.ts +110 -0
- package/src/commands/server.ts +325 -0
- package/src/commands/setup.ts +958 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/user.ts +71 -0
- package/src/core/Config.ts +161 -0
- package/src/core/Prerequisites.ts +187 -0
- package/src/core/RelationshipManager.ts +366 -0
- package/src/core/SessionManager.ts +385 -0
- package/src/core/StateManager.ts +121 -0
- package/src/core/types.ts +320 -0
- package/src/index.ts +58 -0
- package/src/messaging/TelegramAdapter.ts +365 -0
- package/src/monitoring/HealthChecker.ts +172 -0
- package/src/scaffold/bootstrap.ts +122 -0
- package/src/scaffold/templates.ts +204 -0
- package/src/scheduler/JobLoader.ts +85 -0
- package/src/scheduler/JobScheduler.ts +476 -0
- package/src/server/AgentServer.ts +93 -0
- package/src/server/middleware.ts +58 -0
- package/src/server/routes.ts +278 -0
- package/src/templates/default-jobs.json +47 -0
- package/src/templates/hooks/compaction-recovery.sh +23 -0
- package/src/templates/hooks/dangerous-command-guard.sh +35 -0
- package/src/templates/hooks/grounding-before-messaging.sh +22 -0
- package/src/templates/hooks/session-start.sh +37 -0
- package/src/templates/hooks/settings-template.json +45 -0
- package/src/templates/scripts/health-watchdog.sh +63 -0
- package/src/templates/scripts/telegram-reply.sh +54 -0
- package/src/users/UserManager.ts +129 -0
- package/tests/e2e/lifecycle.test.ts +376 -0
- package/tests/fixtures/test-repo/CLAUDE.md +3 -0
- package/tests/fixtures/test-repo/README.md +1 -0
- package/tests/helpers/setup.ts +209 -0
- package/tests/integration/fresh-install.test.ts +218 -0
- package/tests/integration/scheduler-basic.test.ts +109 -0
- package/tests/integration/server-full.test.ts +284 -0
- package/tests/integration/session-lifecycle.test.ts +181 -0
- package/tests/unit/Config.test.ts +22 -0
- package/tests/unit/HealthChecker.test.ts +168 -0
- package/tests/unit/JobLoader.test.ts +151 -0
- package/tests/unit/JobScheduler.test.ts +267 -0
- package/tests/unit/Prerequisites.test.ts +59 -0
- package/tests/unit/RelationshipManager.test.ts +345 -0
- package/tests/unit/StateManager.test.ts +143 -0
- package/tests/unit/TelegramAdapter.test.ts +165 -0
- package/tests/unit/UserManager.test.ts +131 -0
- package/tests/unit/bootstrap.test.ts +28 -0
- package/tests/unit/commands.test.ts +138 -0
- package/tests/unit/middleware.test.ts +92 -0
- package/tests/unit/relationship-routes.test.ts +131 -0
- package/tests/unit/scaffold-templates.test.ts +132 -0
- package/tests/unit/server.test.ts +163 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +9 -0
- package/vitest.e2e.config.ts +9 -0
- package/vitest.integration.config.ts +9 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { loadJobs, validateJob } from '../../src/scheduler/JobLoader.js';
|
|
6
|
+
|
|
7
|
+
describe('JobLoader', () => {
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-loader-'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const validJob = {
|
|
19
|
+
slug: 'test-job',
|
|
20
|
+
name: 'Test Job',
|
|
21
|
+
description: 'A test job',
|
|
22
|
+
schedule: '0 */4 * * *',
|
|
23
|
+
priority: 'medium',
|
|
24
|
+
expectedDurationMinutes: 5,
|
|
25
|
+
model: 'sonnet',
|
|
26
|
+
enabled: true,
|
|
27
|
+
execute: { type: 'skill', value: 'scan' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function writeJobsFile(jobs: unknown[]): string {
|
|
31
|
+
const filePath = path.join(tmpDir, 'jobs.json');
|
|
32
|
+
fs.writeFileSync(filePath, JSON.stringify(jobs));
|
|
33
|
+
return filePath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('loadJobs', () => {
|
|
37
|
+
it('loads a valid jobs file', () => {
|
|
38
|
+
const file = writeJobsFile([validJob]);
|
|
39
|
+
const jobs = loadJobs(file);
|
|
40
|
+
expect(jobs).toHaveLength(1);
|
|
41
|
+
expect(jobs[0].slug).toBe('test-job');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('loads empty array', () => {
|
|
45
|
+
const file = writeJobsFile([]);
|
|
46
|
+
const jobs = loadJobs(file);
|
|
47
|
+
expect(jobs).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('loads multiple jobs', () => {
|
|
51
|
+
const file = writeJobsFile([
|
|
52
|
+
validJob,
|
|
53
|
+
{ ...validJob, slug: 'second-job', name: 'Second' },
|
|
54
|
+
]);
|
|
55
|
+
const jobs = loadJobs(file);
|
|
56
|
+
expect(jobs).toHaveLength(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('includes disabled jobs (filtering is caller responsibility)', () => {
|
|
60
|
+
const file = writeJobsFile([
|
|
61
|
+
validJob,
|
|
62
|
+
{ ...validJob, slug: 'off', enabled: false },
|
|
63
|
+
]);
|
|
64
|
+
const jobs = loadJobs(file);
|
|
65
|
+
expect(jobs).toHaveLength(2);
|
|
66
|
+
expect(jobs[1].enabled).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('throws for missing file', () => {
|
|
70
|
+
expect(() => loadJobs('/nonexistent/jobs.json'))
|
|
71
|
+
.toThrow('Jobs file not found');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('throws for non-array JSON', () => {
|
|
75
|
+
const file = path.join(tmpDir, 'bad.json');
|
|
76
|
+
fs.writeFileSync(file, JSON.stringify({ jobs: [] }));
|
|
77
|
+
expect(() => loadJobs(file))
|
|
78
|
+
.toThrow('must contain a JSON array');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('validateJob', () => {
|
|
83
|
+
it('accepts a valid job', () => {
|
|
84
|
+
expect(() => validateJob(validJob)).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('rejects null', () => {
|
|
88
|
+
expect(() => validateJob(null)).toThrow('must be an object');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects missing slug', () => {
|
|
92
|
+
const { slug, ...noSlug } = validJob;
|
|
93
|
+
expect(() => validateJob(noSlug)).toThrow('"slug" is required');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects empty slug', () => {
|
|
97
|
+
expect(() => validateJob({ ...validJob, slug: ' ' }))
|
|
98
|
+
.toThrow('"slug" is required');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rejects missing name', () => {
|
|
102
|
+
const { name, ...noName } = validJob;
|
|
103
|
+
expect(() => validateJob(noName)).toThrow('"name" is required');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('rejects missing description', () => {
|
|
107
|
+
const { description, ...noDesc } = validJob;
|
|
108
|
+
expect(() => validateJob(noDesc)).toThrow('"description" is required');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects missing schedule', () => {
|
|
112
|
+
const { schedule, ...noSchedule } = validJob;
|
|
113
|
+
expect(() => validateJob(noSchedule)).toThrow('"schedule" is required');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('rejects invalid priority', () => {
|
|
117
|
+
expect(() => validateJob({ ...validJob, priority: 'urgent' }))
|
|
118
|
+
.toThrow('"priority" must be one of');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('rejects invalid cron expression', () => {
|
|
122
|
+
expect(() => validateJob({ ...validJob, schedule: 'not a cron' }))
|
|
123
|
+
.toThrow('invalid cron expression');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('rejects non-boolean enabled', () => {
|
|
127
|
+
expect(() => validateJob({ ...validJob, enabled: 'yes' }))
|
|
128
|
+
.toThrow('"enabled" must be a boolean');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('rejects missing execute', () => {
|
|
132
|
+
const { execute, ...noExec } = validJob;
|
|
133
|
+
expect(() => validateJob(noExec))
|
|
134
|
+
.toThrow('"execute" must be an object');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('rejects invalid execute.type', () => {
|
|
138
|
+
expect(() => validateJob({ ...validJob, execute: { type: 'unknown', value: 'x' } }))
|
|
139
|
+
.toThrow('execute.type must be');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects empty execute.value', () => {
|
|
143
|
+
expect(() => validateJob({ ...validJob, execute: { type: 'skill', value: '' } }))
|
|
144
|
+
.toThrow('execute.value is required');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('includes index in error message', () => {
|
|
148
|
+
expect(() => validateJob(null, 3)).toThrow('Job[3]');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { JobScheduler } from '../../src/scheduler/JobScheduler.js';
|
|
3
|
+
import { createTempProject, createMockSessionManager, createSampleJobsFile } from '../helpers/setup.js';
|
|
4
|
+
import type { TempProject, MockSessionManager } from '../helpers/setup.js';
|
|
5
|
+
import type { JobSchedulerConfig } from '../../src/core/types.js';
|
|
6
|
+
|
|
7
|
+
describe('JobScheduler', () => {
|
|
8
|
+
let project: TempProject;
|
|
9
|
+
let mockSM: MockSessionManager;
|
|
10
|
+
let scheduler: JobScheduler;
|
|
11
|
+
let jobsFile: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
project = createTempProject();
|
|
15
|
+
mockSM = createMockSessionManager();
|
|
16
|
+
jobsFile = createSampleJobsFile(project.stateDir);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
scheduler?.stop();
|
|
21
|
+
project.cleanup();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function makeConfig(overrides?: Partial<JobSchedulerConfig>): JobSchedulerConfig {
|
|
25
|
+
return {
|
|
26
|
+
jobsFile,
|
|
27
|
+
enabled: true,
|
|
28
|
+
maxParallelJobs: 2,
|
|
29
|
+
quotaThresholds: { normal: 50, elevated: 70, critical: 85, shutdown: 95 },
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createScheduler(configOverrides?: Partial<JobSchedulerConfig>): JobScheduler {
|
|
35
|
+
scheduler = new JobScheduler(
|
|
36
|
+
makeConfig(configOverrides),
|
|
37
|
+
mockSM as any,
|
|
38
|
+
project.state,
|
|
39
|
+
);
|
|
40
|
+
return scheduler;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('start/stop', () => {
|
|
44
|
+
it('starts and loads jobs', () => {
|
|
45
|
+
createScheduler();
|
|
46
|
+
scheduler.start();
|
|
47
|
+
|
|
48
|
+
const status = scheduler.getStatus();
|
|
49
|
+
expect(status.running).toBe(true);
|
|
50
|
+
expect(status.jobCount).toBe(3); // 2 enabled + 1 disabled
|
|
51
|
+
expect(status.enabledJobs).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('stops cleanly', () => {
|
|
55
|
+
createScheduler();
|
|
56
|
+
scheduler.start();
|
|
57
|
+
scheduler.stop();
|
|
58
|
+
|
|
59
|
+
expect(scheduler.getStatus().running).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('start is idempotent', () => {
|
|
63
|
+
createScheduler();
|
|
64
|
+
scheduler.start();
|
|
65
|
+
scheduler.start(); // Should not throw
|
|
66
|
+
expect(scheduler.getStatus().running).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('triggerJob', () => {
|
|
71
|
+
it('triggers a known job', () => {
|
|
72
|
+
createScheduler();
|
|
73
|
+
scheduler.start();
|
|
74
|
+
|
|
75
|
+
const result = scheduler.triggerJob('health-check', 'test');
|
|
76
|
+
expect(result).toBe('triggered');
|
|
77
|
+
expect(mockSM._spawnCount).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('throws for unknown job', () => {
|
|
81
|
+
createScheduler();
|
|
82
|
+
scheduler.start();
|
|
83
|
+
|
|
84
|
+
expect(() => scheduler.triggerJob('nonexistent', 'test'))
|
|
85
|
+
.toThrow('Unknown job: nonexistent');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('queues when at max parallel jobs', () => {
|
|
89
|
+
createScheduler({ maxParallelJobs: 1 });
|
|
90
|
+
scheduler.start();
|
|
91
|
+
|
|
92
|
+
// First trigger succeeds
|
|
93
|
+
const r1 = scheduler.triggerJob('health-check', 'test');
|
|
94
|
+
expect(r1).toBe('triggered');
|
|
95
|
+
|
|
96
|
+
// Second trigger gets queued — at capacity
|
|
97
|
+
const r2 = scheduler.triggerJob('email-check', 'test');
|
|
98
|
+
expect(r2).toBe('queued');
|
|
99
|
+
|
|
100
|
+
expect(scheduler.getStatus().queueLength).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('skips when paused', () => {
|
|
104
|
+
createScheduler();
|
|
105
|
+
scheduler.start();
|
|
106
|
+
scheduler.pause();
|
|
107
|
+
|
|
108
|
+
const result = scheduler.triggerJob('health-check', 'test');
|
|
109
|
+
expect(result).toBe('skipped');
|
|
110
|
+
expect(mockSM._spawnCount).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('skips when quota callback returns false', () => {
|
|
114
|
+
createScheduler();
|
|
115
|
+
scheduler.start();
|
|
116
|
+
scheduler.canRunJob = () => false;
|
|
117
|
+
|
|
118
|
+
const result = scheduler.triggerJob('health-check', 'test');
|
|
119
|
+
expect(result).toBe('skipped');
|
|
120
|
+
expect(mockSM._spawnCount).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('queue processing', () => {
|
|
125
|
+
it('drains queue when slot opens', () => {
|
|
126
|
+
createScheduler({ maxParallelJobs: 1 });
|
|
127
|
+
scheduler.start();
|
|
128
|
+
|
|
129
|
+
// Fill the slot
|
|
130
|
+
scheduler.triggerJob('health-check', 'test');
|
|
131
|
+
expect(mockSM._spawnCount).toBe(1);
|
|
132
|
+
|
|
133
|
+
// Queue a job
|
|
134
|
+
scheduler.triggerJob('email-check', 'test');
|
|
135
|
+
expect(scheduler.getStatus().queueLength).toBe(1);
|
|
136
|
+
|
|
137
|
+
// Simulate session completion — mark the running session as completed
|
|
138
|
+
const session = mockSM._sessions[0];
|
|
139
|
+
session.status = 'completed';
|
|
140
|
+
mockSM._aliveSet.delete(session.tmuxSession);
|
|
141
|
+
|
|
142
|
+
// Process the queue
|
|
143
|
+
scheduler.processQueue();
|
|
144
|
+
expect(mockSM._spawnCount).toBe(2);
|
|
145
|
+
expect(scheduler.getStatus().queueLength).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does not dequeue duplicates', () => {
|
|
149
|
+
createScheduler({ maxParallelJobs: 1 });
|
|
150
|
+
scheduler.start();
|
|
151
|
+
|
|
152
|
+
scheduler.triggerJob('health-check', 'test');
|
|
153
|
+
scheduler.triggerJob('email-check', 'test-1');
|
|
154
|
+
scheduler.triggerJob('email-check', 'test-2'); // duplicate slug
|
|
155
|
+
|
|
156
|
+
expect(scheduler.getStatus().queueLength).toBe(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('does not process queue when paused', () => {
|
|
160
|
+
createScheduler({ maxParallelJobs: 1 });
|
|
161
|
+
scheduler.start();
|
|
162
|
+
|
|
163
|
+
scheduler.triggerJob('health-check', 'test');
|
|
164
|
+
scheduler.triggerJob('email-check', 'test');
|
|
165
|
+
|
|
166
|
+
// Clear the slot
|
|
167
|
+
mockSM._sessions[0].status = 'completed';
|
|
168
|
+
mockSM._aliveSet.delete(mockSM._sessions[0].tmuxSession);
|
|
169
|
+
|
|
170
|
+
scheduler.pause();
|
|
171
|
+
scheduler.processQueue();
|
|
172
|
+
expect(mockSM._spawnCount).toBe(1); // Only the first one
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('pause/resume', () => {
|
|
177
|
+
it('resume processes pending queue', () => {
|
|
178
|
+
createScheduler({ maxParallelJobs: 1 });
|
|
179
|
+
scheduler.start();
|
|
180
|
+
|
|
181
|
+
scheduler.triggerJob('health-check', 'test');
|
|
182
|
+
scheduler.triggerJob('email-check', 'test');
|
|
183
|
+
scheduler.pause();
|
|
184
|
+
|
|
185
|
+
// Clear the slot
|
|
186
|
+
mockSM._sessions[0].status = 'completed';
|
|
187
|
+
mockSM._aliveSet.delete(mockSM._sessions[0].tmuxSession);
|
|
188
|
+
|
|
189
|
+
scheduler.resume();
|
|
190
|
+
expect(mockSM._spawnCount).toBe(2);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('getStatus reflects paused state', () => {
|
|
194
|
+
createScheduler();
|
|
195
|
+
scheduler.start();
|
|
196
|
+
|
|
197
|
+
expect(scheduler.getStatus().paused).toBe(false);
|
|
198
|
+
scheduler.pause();
|
|
199
|
+
expect(scheduler.getStatus().paused).toBe(true);
|
|
200
|
+
scheduler.resume();
|
|
201
|
+
expect(scheduler.getStatus().paused).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('failure tracking', () => {
|
|
206
|
+
it('increments consecutive failures on spawn error', async () => {
|
|
207
|
+
createScheduler();
|
|
208
|
+
scheduler.start();
|
|
209
|
+
|
|
210
|
+
// Make spawnSession reject
|
|
211
|
+
mockSM.spawnSession = async () => { throw new Error('tmux failed'); };
|
|
212
|
+
|
|
213
|
+
scheduler.triggerJob('health-check', 'test');
|
|
214
|
+
|
|
215
|
+
// Wait for the async rejection to be handled
|
|
216
|
+
await new Promise(r => setTimeout(r, 50));
|
|
217
|
+
|
|
218
|
+
const jobState = project.state.getJobState('health-check');
|
|
219
|
+
expect(jobState?.lastResult).toBe('failure');
|
|
220
|
+
expect(jobState?.consecutiveFailures).toBe(1);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('getJobs', () => {
|
|
225
|
+
it('returns loaded job definitions', () => {
|
|
226
|
+
createScheduler();
|
|
227
|
+
scheduler.start();
|
|
228
|
+
|
|
229
|
+
const jobs = scheduler.getJobs();
|
|
230
|
+
expect(jobs).toHaveLength(3);
|
|
231
|
+
expect(jobs.map(j => j.slug)).toContain('health-check');
|
|
232
|
+
expect(jobs.map(j => j.slug)).toContain('disabled-job');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('activity events', () => {
|
|
237
|
+
it('emits scheduler_start event', () => {
|
|
238
|
+
createScheduler();
|
|
239
|
+
scheduler.start();
|
|
240
|
+
|
|
241
|
+
const events = project.state.queryEvents({ type: 'scheduler_start' });
|
|
242
|
+
expect(events).toHaveLength(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('emits job_triggered event', async () => {
|
|
246
|
+
createScheduler();
|
|
247
|
+
scheduler.start();
|
|
248
|
+
scheduler.triggerJob('health-check', 'manual');
|
|
249
|
+
|
|
250
|
+
// Wait for async spawn to complete
|
|
251
|
+
await new Promise(r => setTimeout(r, 50));
|
|
252
|
+
|
|
253
|
+
const events = project.state.queryEvents({ type: 'job_triggered' });
|
|
254
|
+
expect(events).toHaveLength(1);
|
|
255
|
+
expect(events[0].summary).toContain('health-check');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('emits scheduler_stop event', () => {
|
|
259
|
+
createScheduler();
|
|
260
|
+
scheduler.start();
|
|
261
|
+
scheduler.stop();
|
|
262
|
+
|
|
263
|
+
const events = project.state.queryEvents({ type: 'scheduler_stop' });
|
|
264
|
+
expect(events).toHaveLength(1);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for prerequisite detection.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { checkPrerequisites } from '../../src/core/Prerequisites.js';
|
|
7
|
+
|
|
8
|
+
describe('Prerequisites', () => {
|
|
9
|
+
it('returns structured results for all checks', () => {
|
|
10
|
+
const result = checkPrerequisites();
|
|
11
|
+
|
|
12
|
+
expect(result.results).toHaveLength(3);
|
|
13
|
+
expect(result.results.map(r => r.name)).toEqual(['Node.js', 'tmux', 'Claude CLI']);
|
|
14
|
+
|
|
15
|
+
// Each result has the expected shape
|
|
16
|
+
for (const r of result.results) {
|
|
17
|
+
expect(r).toHaveProperty('name');
|
|
18
|
+
expect(r).toHaveProperty('found');
|
|
19
|
+
expect(r).toHaveProperty('installHint');
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('detects Node.js version correctly', () => {
|
|
24
|
+
const result = checkPrerequisites();
|
|
25
|
+
const node = result.results.find(r => r.name === 'Node.js')!;
|
|
26
|
+
|
|
27
|
+
expect(node.found).toBe(true); // We're running tests, so Node must be available
|
|
28
|
+
expect(node.version).toMatch(/^v\d+/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reports allMet based on found status', () => {
|
|
32
|
+
const result = checkPrerequisites();
|
|
33
|
+
|
|
34
|
+
// allMet should be true only if everything is found
|
|
35
|
+
const allFound = result.results.every(r => r.found);
|
|
36
|
+
expect(result.allMet).toBe(allFound);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('missing array contains only unfound prerequisites', () => {
|
|
40
|
+
const result = checkPrerequisites();
|
|
41
|
+
|
|
42
|
+
for (const m of result.missing) {
|
|
43
|
+
expect(m.found).toBe(false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// missing.length + found.length should equal total
|
|
47
|
+
const foundCount = result.results.filter(r => r.found).length;
|
|
48
|
+
expect(result.missing.length + foundCount).toBe(result.results.length);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('provides install hints for all results', () => {
|
|
52
|
+
const result = checkPrerequisites();
|
|
53
|
+
|
|
54
|
+
// Missing items should have non-empty install hints
|
|
55
|
+
for (const m of result.missing) {
|
|
56
|
+
expect(m.installHint.length).toBeGreaterThan(0);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|