@gravito/launchpad 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/.turbo/turbo-test.log +82 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/Dockerfile +23 -0
- package/README.md +44 -0
- package/debug-launch.ts +66 -0
- package/package.json +26 -0
- package/server.ts +7 -0
- package/simulate-github.ts +55 -0
- package/src/Application/MissionControl.ts +59 -0
- package/src/Application/PayloadInjector.ts +60 -0
- package/src/Application/PoolManager.ts +81 -0
- package/src/Application/RefurbishUnit.ts +40 -0
- package/src/Domain/Events.ts +28 -0
- package/src/Domain/Interfaces.ts +62 -0
- package/src/Domain/Mission.ts +27 -0
- package/src/Domain/Rocket.ts +116 -0
- package/src/Domain/RocketStatus.ts +10 -0
- package/src/Infrastructure/Docker/DockerAdapter.ts +127 -0
- package/src/Infrastructure/Git/ShellGitAdapter.ts +22 -0
- package/src/Infrastructure/GitHub/OctokitGitHubAdapter.ts +42 -0
- package/src/Infrastructure/Persistence/CachedRocketRepository.ts +42 -0
- package/src/Infrastructure/Persistence/InMemoryRocketRepository.ts +32 -0
- package/src/Infrastructure/Router/BunProxyAdapter.ts +54 -0
- package/src/index.ts +223 -0
- package/tests/Deployment.test.ts +50 -0
- package/tests/Integration.test.ts +42 -0
- package/tests/PoolManager.test.ts +60 -0
- package/tests/Refurbishment.test.ts +43 -0
- package/tests/Rocket.test.ts +49 -0
- package/tests/Telemetry.test.ts +50 -0
- package/tests/docker-adapter.test.ts +110 -0
- package/tests/enterprise-coverage.test.ts +62 -0
- package/tests/mission-control.test.ts +79 -0
- package/tests/mission.test.ts +18 -0
- package/tests/payload-injector.test.ts +64 -0
- package/tests/pool-manager.test.ts +93 -0
- package/tests/repository.test.ts +31 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { PayloadInjector } from '../src/Application/PayloadInjector'
|
|
3
|
+
import { PoolManager } from '../src/Application/PoolManager'
|
|
4
|
+
import { Mission } from '../src/Domain/Mission'
|
|
5
|
+
import { RocketStatus } from '../src/Domain/RocketStatus'
|
|
6
|
+
|
|
7
|
+
describe('Payload Injection Flow', () => {
|
|
8
|
+
// Mocks
|
|
9
|
+
const mockDocker: any = {
|
|
10
|
+
createBaseContainer: mock(() => Promise.resolve('cid-1')),
|
|
11
|
+
copyFiles: mock(() => Promise.resolve()),
|
|
12
|
+
executeCommand: mock(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '' })),
|
|
13
|
+
removeContainer: mock(() => Promise.resolve()),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const mockGit: any = {
|
|
17
|
+
clone: mock(() => Promise.resolve('/tmp/mock-code')),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const mockRepo: any = {
|
|
21
|
+
save: mock(() => Promise.resolve()),
|
|
22
|
+
findIdle: mock(() => Promise.resolve(null)), // Force create
|
|
23
|
+
findAll: mock(() => Promise.resolve([])),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const pool = new PoolManager(mockDocker, mockRepo)
|
|
27
|
+
const injector = new PayloadInjector(mockDocker, mockGit)
|
|
28
|
+
|
|
29
|
+
it('應該能從指派任務到成功點火', async () => {
|
|
30
|
+
// 1. Assign
|
|
31
|
+
const mission = Mission.create({
|
|
32
|
+
id: 'pr-2',
|
|
33
|
+
repoUrl: 'http://git',
|
|
34
|
+
branch: 'dev',
|
|
35
|
+
commitSha: 'sha',
|
|
36
|
+
})
|
|
37
|
+
const rocket = await pool.assignMission(mission)
|
|
38
|
+
|
|
39
|
+
expect(rocket.status).toBe(RocketStatus.PREPARING)
|
|
40
|
+
|
|
41
|
+
// 2. Deploy
|
|
42
|
+
await injector.deploy(rocket)
|
|
43
|
+
|
|
44
|
+
expect(mockGit.clone).toHaveBeenCalled()
|
|
45
|
+
expect(mockDocker.copyFiles).toHaveBeenCalled()
|
|
46
|
+
expect(mockDocker.executeCommand).toHaveBeenCalled() // bun install
|
|
47
|
+
|
|
48
|
+
expect(rocket.status).toBe(RocketStatus.ORBITING)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { PoolManager } from '../src/Application/PoolManager'
|
|
3
|
+
import { DockerAdapter } from '../src/Infrastructure/Docker/DockerAdapter'
|
|
4
|
+
import { InMemoryRocketRepository } from '../src/Infrastructure/Persistence/InMemoryRocketRepository'
|
|
5
|
+
|
|
6
|
+
describe('LaunchPad 集成測試 (真實 Docker)', () => {
|
|
7
|
+
const docker = new DockerAdapter()
|
|
8
|
+
const repo = new InMemoryRocketRepository()
|
|
9
|
+
const manager = new PoolManager(docker, repo)
|
|
10
|
+
|
|
11
|
+
afterAll(async () => {
|
|
12
|
+
// 清理測試建立的容器
|
|
13
|
+
const rockets = await repo.findAll()
|
|
14
|
+
for (const rocket of rockets) {
|
|
15
|
+
await docker.removeContainer(rocket.containerId)
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('應該能成功熱機並在容器內執行指令', async () => {
|
|
20
|
+
try {
|
|
21
|
+
// 1. Warmup
|
|
22
|
+
await manager.warmup(1)
|
|
23
|
+
const rockets = await repo.findAll()
|
|
24
|
+
expect(rockets.length).toBe(1)
|
|
25
|
+
|
|
26
|
+
const rocket = rockets[0]
|
|
27
|
+
expect(rocket.containerId).toBeDefined()
|
|
28
|
+
|
|
29
|
+
// 2. 測試指令執行
|
|
30
|
+
const result = await docker.executeCommand(rocket.containerId, ['bun', '--version'])
|
|
31
|
+
console.log(`[Test] 容器內 Bun 版本: ${result.stdout.trim()}`)
|
|
32
|
+
expect(result.exitCode).toBe(0)
|
|
33
|
+
expect(result.stdout).toContain('1.') // 應該是 1.x 版本
|
|
34
|
+
} catch (e: any) {
|
|
35
|
+
if (e.message.includes('docker') || e.message.includes('ENOENT')) {
|
|
36
|
+
console.warn('⚠️ 跳過測試:本地環境未偵測到 Docker Daemon')
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
throw e
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { PoolManager } from '../src/Application/PoolManager'
|
|
3
|
+
import { Mission } from '../src/Domain/Mission'
|
|
4
|
+
import { RocketStatus } from '../src/Domain/RocketStatus'
|
|
5
|
+
|
|
6
|
+
describe('PoolManager (Application Service)', () => {
|
|
7
|
+
// Mock Docker Adapter
|
|
8
|
+
const mockDocker: any = {
|
|
9
|
+
createBaseContainer: mock(() => Promise.resolve('container-id-123')),
|
|
10
|
+
removeContainer: mock(() => Promise.resolve()),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Simple In-memory Repository Mock
|
|
14
|
+
const rockets: any[] = []
|
|
15
|
+
const mockRepo: any = {
|
|
16
|
+
save: mock((r) => {
|
|
17
|
+
const idx = rockets.findIndex((x) => x.id === r.id)
|
|
18
|
+
if (idx >= 0) {
|
|
19
|
+
rockets[idx] = r
|
|
20
|
+
} else {
|
|
21
|
+
rockets.push(r)
|
|
22
|
+
}
|
|
23
|
+
return Promise.resolve()
|
|
24
|
+
}),
|
|
25
|
+
findAll: mock(() => Promise.resolve(rockets)),
|
|
26
|
+
findIdle: mock(() =>
|
|
27
|
+
Promise.resolve(rockets.find((r) => r.status === RocketStatus.IDLE) || null)
|
|
28
|
+
),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const manager = new PoolManager(mockDocker, mockRepo)
|
|
32
|
+
|
|
33
|
+
it('應該能正確熱機並指派任務', async () => {
|
|
34
|
+
// Warmup
|
|
35
|
+
await manager.warmup(2)
|
|
36
|
+
expect(rockets.length).toBe(2)
|
|
37
|
+
expect(mockDocker.createBaseContainer).toHaveBeenCalledTimes(2)
|
|
38
|
+
|
|
39
|
+
// Assign Mission
|
|
40
|
+
const mission = Mission.create({
|
|
41
|
+
id: 'pr-1',
|
|
42
|
+
repoUrl: 'url',
|
|
43
|
+
branch: 'b',
|
|
44
|
+
commitSha: 's',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const assignedRocket = await manager.assignMission(mission)
|
|
48
|
+
expect(assignedRocket.status).toBe(RocketStatus.PREPARING)
|
|
49
|
+
expect(assignedRocket.currentMission?.id).toBe('pr-1')
|
|
50
|
+
|
|
51
|
+
// Ignite before recycle (Must be ORBITING to splashDown)
|
|
52
|
+
assignedRocket.ignite()
|
|
53
|
+
expect(assignedRocket.status).toBe(RocketStatus.ORBITING)
|
|
54
|
+
|
|
55
|
+
// Recycle
|
|
56
|
+
await manager.recycle('pr-1')
|
|
57
|
+
expect(assignedRocket.status).toBe(RocketStatus.IDLE)
|
|
58
|
+
expect(assignedRocket.currentMission).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { RefurbishUnit } from '../src/Application/RefurbishUnit'
|
|
3
|
+
import { Mission } from '../src/Domain/Mission'
|
|
4
|
+
import { Rocket } from '../src/Domain/Rocket'
|
|
5
|
+
import { RocketStatus } from '../src/Domain/RocketStatus'
|
|
6
|
+
|
|
7
|
+
describe('RefurbishUnit (Rocket Recovery)', () => {
|
|
8
|
+
it('應該能執行深度清理並將火箭重置為 IDLE', async () => {
|
|
9
|
+
// 1. Setup
|
|
10
|
+
const mockDocker: any = {
|
|
11
|
+
executeCommand: mock(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '' })),
|
|
12
|
+
}
|
|
13
|
+
const unit = new RefurbishUnit(mockDocker)
|
|
14
|
+
const rocket = new Rocket('r1', 'c1')
|
|
15
|
+
|
|
16
|
+
// 模擬火箭正在運行
|
|
17
|
+
rocket.assignMission(Mission.create({ id: 'pr-1', repoUrl: 'u', branch: 'b', commitSha: 's' }))
|
|
18
|
+
rocket.ignite()
|
|
19
|
+
expect(rocket.status).toBe(RocketStatus.ORBITING)
|
|
20
|
+
|
|
21
|
+
// 2. Execute Refurbish
|
|
22
|
+
await unit.refurbish(rocket)
|
|
23
|
+
|
|
24
|
+
// 3. Verify
|
|
25
|
+
expect(mockDocker.executeCommand).toHaveBeenCalled()
|
|
26
|
+
expect(rocket.status).toBe(RocketStatus.IDLE)
|
|
27
|
+
expect(rocket.currentMission).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('如果清理失敗應該將火箭除役', async () => {
|
|
31
|
+
const mockDocker: any = {
|
|
32
|
+
executeCommand: mock(() => Promise.resolve({ exitCode: 1, stdout: '', stderr: 'Disk Full' })),
|
|
33
|
+
}
|
|
34
|
+
const unit = new RefurbishUnit(mockDocker)
|
|
35
|
+
const rocket = new Rocket('r2', 'c2')
|
|
36
|
+
rocket.assignMission(Mission.create({ id: 'pr-2', repoUrl: 'u', branch: 'b', commitSha: 's' }))
|
|
37
|
+
rocket.ignite()
|
|
38
|
+
|
|
39
|
+
await unit.refurbish(rocket)
|
|
40
|
+
|
|
41
|
+
expect(rocket.status).toBe(RocketStatus.DECOMMISSIONED)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
MissionAssigned,
|
|
4
|
+
RefurbishmentCompleted,
|
|
5
|
+
RocketIgnited,
|
|
6
|
+
RocketSplashedDown,
|
|
7
|
+
} from '../src/Domain/Events'
|
|
8
|
+
import { Mission } from '../src/Domain/Mission'
|
|
9
|
+
import { Rocket } from '../src/Domain/Rocket'
|
|
10
|
+
import { RocketStatus } from '../src/Domain/RocketStatus'
|
|
11
|
+
|
|
12
|
+
describe('Rocket', () => {
|
|
13
|
+
test('transitions through mission lifecycle and emits events', () => {
|
|
14
|
+
const mission = Mission.create({
|
|
15
|
+
id: 'mission-1',
|
|
16
|
+
repoUrl: 'https://example.com/repo.git',
|
|
17
|
+
branch: 'main',
|
|
18
|
+
commitSha: 'abc123',
|
|
19
|
+
})
|
|
20
|
+
const rocket = new Rocket('rocket-1', 'container-1')
|
|
21
|
+
|
|
22
|
+
rocket.assignMission(mission)
|
|
23
|
+
expect(rocket.status).toBe(RocketStatus.PREPARING)
|
|
24
|
+
expect(rocket.currentMission).toBe(mission)
|
|
25
|
+
expect(rocket.pullDomainEvents()[0]).toBeInstanceOf(MissionAssigned)
|
|
26
|
+
|
|
27
|
+
rocket.ignite()
|
|
28
|
+
expect(rocket.status).toBe(RocketStatus.ORBITING)
|
|
29
|
+
expect(rocket.pullDomainEvents()[0]).toBeInstanceOf(RocketIgnited)
|
|
30
|
+
|
|
31
|
+
rocket.splashDown()
|
|
32
|
+
expect(rocket.status).toBe(RocketStatus.REFURBISHING)
|
|
33
|
+
expect(rocket.pullDomainEvents()[0]).toBeInstanceOf(RocketSplashedDown)
|
|
34
|
+
|
|
35
|
+
rocket.finishRefurbishment()
|
|
36
|
+
expect(rocket.status).toBe(RocketStatus.IDLE)
|
|
37
|
+
expect(rocket.currentMission).toBeNull()
|
|
38
|
+
expect(rocket.pullDomainEvents()[0]).toBeInstanceOf(RefurbishmentCompleted)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('guards invalid transitions', () => {
|
|
42
|
+
const rocket = new Rocket('rocket-2', 'container-2')
|
|
43
|
+
|
|
44
|
+
expect(() => rocket.ignite()).toThrow()
|
|
45
|
+
expect(() => rocket.splashDown()).toThrow()
|
|
46
|
+
rocket.decommission()
|
|
47
|
+
expect(rocket.status).toBe(RocketStatus.DECOMMISSIONED)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { MissionControl } from '../src/Application/MissionControl'
|
|
3
|
+
import { Mission } from '../src/Domain/Mission'
|
|
4
|
+
|
|
5
|
+
describe('MissionControl Telemetry', () => {
|
|
6
|
+
it('發射時應該能正確啟動日誌串流與效能監控', async () => {
|
|
7
|
+
const mockRocket = {
|
|
8
|
+
id: 'r1',
|
|
9
|
+
containerId: 'c1',
|
|
10
|
+
status: 'PREPARING',
|
|
11
|
+
assignDomain: mock(() => {}),
|
|
12
|
+
assignMission: mock(() => {}),
|
|
13
|
+
ignite: mock(() => {}),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const mockPool: any = {
|
|
17
|
+
assignMission: mock(() => Promise.resolve(mockRocket)),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const mockInjector: any = {
|
|
21
|
+
deploy: mock(() => Promise.resolve()),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const mockDocker: any = {
|
|
25
|
+
getExposedPort: mock(() => Promise.resolve(3000)),
|
|
26
|
+
streamLogs: mock((_id, cb) => {
|
|
27
|
+
cb('hello world') // 模擬一條日誌
|
|
28
|
+
}),
|
|
29
|
+
getStats: mock(() => Promise.resolve({ cpu: '10%', memory: '100MB' })),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ctrl = new MissionControl(mockPool, mockInjector, mockDocker)
|
|
33
|
+
const telemetryLogs: any[] = []
|
|
34
|
+
|
|
35
|
+
await ctrl.launch(
|
|
36
|
+
Mission.create({ id: 'pr-1', repoUrl: 'url', branch: 'b', commitSha: 's' }),
|
|
37
|
+
(type, data) => telemetryLogs.push({ type, data })
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expect(mockPool.assignMission).toHaveBeenCalled()
|
|
41
|
+
expect(mockInjector.deploy).toHaveBeenCalled()
|
|
42
|
+
expect(mockDocker.streamLogs).toHaveBeenCalledWith('c1', expect.any(Function))
|
|
43
|
+
|
|
44
|
+
// 驗證是否收到模擬日誌
|
|
45
|
+
expect(telemetryLogs).toContainEqual({
|
|
46
|
+
type: 'log',
|
|
47
|
+
data: { rocketId: 'r1', text: 'hello world' },
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { DockerAdapter } from '../src/Infrastructure/Docker/DockerAdapter'
|
|
3
|
+
|
|
4
|
+
const makeStream = (text: string) => new Response(text).body as ReadableStream
|
|
5
|
+
|
|
6
|
+
function makeProcess(stdoutText: string, stderrText: string, exitCode = 0) {
|
|
7
|
+
return {
|
|
8
|
+
stdout: makeStream(stdoutText),
|
|
9
|
+
stderr: makeStream(stderrText),
|
|
10
|
+
exitCode,
|
|
11
|
+
exited: Promise.resolve(),
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('DockerAdapter', () => {
|
|
16
|
+
test('creates base container when stdout has container id', async () => {
|
|
17
|
+
const adapter = new DockerAdapter()
|
|
18
|
+
const originalSpawn = Bun.spawn
|
|
19
|
+
const containerId = 'a'.repeat(64)
|
|
20
|
+
|
|
21
|
+
Bun.spawn = () => makeProcess(containerId, '', 1) as any
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const result = await adapter.createBaseContainer()
|
|
25
|
+
expect(result).toBe(containerId)
|
|
26
|
+
} finally {
|
|
27
|
+
Bun.spawn = originalSpawn
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('throws when container creation fails', async () => {
|
|
32
|
+
const adapter = new DockerAdapter()
|
|
33
|
+
const originalSpawn = Bun.spawn
|
|
34
|
+
|
|
35
|
+
Bun.spawn = () => makeProcess('bad', 'boom', 1) as any
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await expect(adapter.createBaseContainer()).rejects.toThrow('boom')
|
|
39
|
+
} finally {
|
|
40
|
+
Bun.spawn = originalSpawn
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('copyFiles throws on non-zero exit code', async () => {
|
|
45
|
+
const adapter = new DockerAdapter()
|
|
46
|
+
const originalSpawn = Bun.spawn
|
|
47
|
+
|
|
48
|
+
Bun.spawn = () => makeProcess('', 'copy failed', 1) as any
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await expect(adapter.copyFiles('cid', '/src', '/target')).rejects.toThrow('copy failed')
|
|
52
|
+
} finally {
|
|
53
|
+
Bun.spawn = originalSpawn
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('executeCommand returns stdout and stderr', async () => {
|
|
58
|
+
const adapter = new DockerAdapter()
|
|
59
|
+
const originalSpawn = Bun.spawn
|
|
60
|
+
|
|
61
|
+
Bun.spawn = () => makeProcess('ok', 'warn', 0) as any
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const result = await adapter.executeCommand('cid', ['echo', 'ok'])
|
|
65
|
+
expect(result.stdout).toBe('ok')
|
|
66
|
+
expect(result.stderr).toBe('warn')
|
|
67
|
+
expect(result.exitCode).toBe(0)
|
|
68
|
+
} finally {
|
|
69
|
+
Bun.spawn = originalSpawn
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('getStats parses cpu and memory output', async () => {
|
|
74
|
+
const adapter = new DockerAdapter()
|
|
75
|
+
const originalSpawn = Bun.spawn
|
|
76
|
+
|
|
77
|
+
Bun.spawn = () => makeProcess('12%,10MiB / 20MiB', '', 0) as any
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const stats = await adapter.getStats('cid')
|
|
81
|
+
expect(stats.cpu).toBe('12%')
|
|
82
|
+
expect(stats.memory).toBe('10MiB / 20MiB')
|
|
83
|
+
} finally {
|
|
84
|
+
Bun.spawn = originalSpawn
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('streamLogs forwards stdout and stderr', async () => {
|
|
89
|
+
const adapter = new DockerAdapter()
|
|
90
|
+
const originalSpawn = Bun.spawn
|
|
91
|
+
|
|
92
|
+
Bun.spawn = () =>
|
|
93
|
+
({
|
|
94
|
+
stdout: makeStream('out'),
|
|
95
|
+
stderr: makeStream('err'),
|
|
96
|
+
exited: Promise.resolve(),
|
|
97
|
+
}) as any
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const logs: string[] = []
|
|
101
|
+
adapter.streamLogs('cid', (data) => logs.push(data))
|
|
102
|
+
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
104
|
+
expect(logs.join('')).toContain('out')
|
|
105
|
+
expect(logs.join('')).toContain('err')
|
|
106
|
+
} finally {
|
|
107
|
+
Bun.spawn = originalSpawn
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
AggregateRoot,
|
|
4
|
+
Command,
|
|
5
|
+
DomainEvent,
|
|
6
|
+
Entity,
|
|
7
|
+
Query,
|
|
8
|
+
UseCase,
|
|
9
|
+
ValueObject,
|
|
10
|
+
} from '@gravito/enterprise'
|
|
11
|
+
|
|
12
|
+
class ExampleEntity extends Entity<string> {}
|
|
13
|
+
class ExampleValue extends ValueObject<{ value: string }> {}
|
|
14
|
+
class ExampleEvent extends DomainEvent {}
|
|
15
|
+
|
|
16
|
+
class ExampleAggregate extends AggregateRoot<string> {
|
|
17
|
+
record(event: DomainEvent) {
|
|
18
|
+
this.addDomainEvent(event)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class ExampleCommand extends Command {
|
|
23
|
+
constructor(public readonly name: string) {
|
|
24
|
+
super()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class ExampleQuery extends Query {
|
|
29
|
+
constructor(public readonly id: string) {
|
|
30
|
+
super()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class ExampleUseCase extends UseCase<string, string> {
|
|
35
|
+
execute(input: string): string {
|
|
36
|
+
return input.toUpperCase()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('Launchpad enterprise coverage', () => {
|
|
41
|
+
test('covers core enterprise primitives', () => {
|
|
42
|
+
const entity = new ExampleEntity('1')
|
|
43
|
+
const same = new ExampleEntity('1')
|
|
44
|
+
expect(entity.equals(same)).toBe(true)
|
|
45
|
+
|
|
46
|
+
const value = new ExampleValue({ value: 'a' })
|
|
47
|
+
const valueSame = new ExampleValue({ value: 'a' })
|
|
48
|
+
expect(value.equals(valueSame)).toBe(true)
|
|
49
|
+
|
|
50
|
+
const agg = new ExampleAggregate('agg-1')
|
|
51
|
+
agg.record(new ExampleEvent())
|
|
52
|
+
expect(agg.pullDomainEvents().length).toBe(1)
|
|
53
|
+
|
|
54
|
+
const cmd = new ExampleCommand('go')
|
|
55
|
+
const query = new ExampleQuery('q1')
|
|
56
|
+
expect(cmd.name).toBe('go')
|
|
57
|
+
expect(query.id).toBe('q1')
|
|
58
|
+
|
|
59
|
+
const useCase = new ExampleUseCase()
|
|
60
|
+
expect(useCase.execute('ok')).toBe('OK')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import { MissionControl } from '../src/Application/MissionControl'
|
|
3
|
+
import { Mission } from '../src/Domain/Mission'
|
|
4
|
+
|
|
5
|
+
function createRocket() {
|
|
6
|
+
return {
|
|
7
|
+
id: 'rocket-telemetry',
|
|
8
|
+
containerId: 'container-telemetry',
|
|
9
|
+
status: 'ORBITING',
|
|
10
|
+
assignDomain: mock(() => {}),
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('MissionControl timers', () => {
|
|
15
|
+
test('emits stats and schedules recycle', async () => {
|
|
16
|
+
const rocket = createRocket()
|
|
17
|
+
const mission = Mission.create({
|
|
18
|
+
id: 'mission-telemetry',
|
|
19
|
+
repoUrl: 'https://example.com/repo.git',
|
|
20
|
+
branch: 'main',
|
|
21
|
+
commitSha: 'abc',
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const poolManager = {
|
|
25
|
+
assignMission: async () => rocket,
|
|
26
|
+
recycle: mock(async () => {}),
|
|
27
|
+
}
|
|
28
|
+
const injector = {
|
|
29
|
+
deploy: mock(async () => {}),
|
|
30
|
+
}
|
|
31
|
+
const docker = {
|
|
32
|
+
streamLogs: mock(() => {}),
|
|
33
|
+
getStats: mock(async () => ({ cpu: '5%', memory: '10MB' })),
|
|
34
|
+
getExposedPort: mock(async () => 3000),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const originalSetInterval = global.setInterval
|
|
38
|
+
const originalSetTimeout = global.setTimeout
|
|
39
|
+
const originalClearInterval = global.clearInterval
|
|
40
|
+
|
|
41
|
+
let intervalFn: (() => Promise<void>) | null = null
|
|
42
|
+
const clearIntervalMock = mock(() => {})
|
|
43
|
+
let timeoutPromise: Promise<void> | null = null
|
|
44
|
+
|
|
45
|
+
global.setInterval = ((fn: () => Promise<void>) => {
|
|
46
|
+
intervalFn = fn
|
|
47
|
+
return 123 as any
|
|
48
|
+
}) as any
|
|
49
|
+
|
|
50
|
+
global.clearInterval = clearIntervalMock as any
|
|
51
|
+
|
|
52
|
+
global.setTimeout = ((fn: () => Promise<void>) => {
|
|
53
|
+
timeoutPromise = Promise.resolve(fn())
|
|
54
|
+
return 456 as any
|
|
55
|
+
}) as any
|
|
56
|
+
|
|
57
|
+
const mc = new MissionControl(poolManager as any, injector as any, docker as any)
|
|
58
|
+
const onTelemetry = mock(() => {})
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await mc.launch(mission, onTelemetry)
|
|
62
|
+
|
|
63
|
+
expect(intervalFn).not.toBeNull()
|
|
64
|
+
await intervalFn?.()
|
|
65
|
+
expect(docker.getStats).toHaveBeenCalledWith(rocket.containerId)
|
|
66
|
+
|
|
67
|
+
rocket.status = 'IDLE'
|
|
68
|
+
await intervalFn?.()
|
|
69
|
+
expect(clearIntervalMock).toHaveBeenCalled()
|
|
70
|
+
|
|
71
|
+
await timeoutPromise
|
|
72
|
+
expect(poolManager.recycle).toHaveBeenCalledWith('mission-telemetry')
|
|
73
|
+
} finally {
|
|
74
|
+
global.setInterval = originalSetInterval
|
|
75
|
+
global.setTimeout = originalSetTimeout
|
|
76
|
+
global.clearInterval = originalClearInterval
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Mission } from '../src/Domain/Mission'
|
|
3
|
+
|
|
4
|
+
describe('Mission', () => {
|
|
5
|
+
test('exposes mission properties', () => {
|
|
6
|
+
const mission = Mission.create({
|
|
7
|
+
id: 'm1',
|
|
8
|
+
repoUrl: 'https://example.com/repo.git',
|
|
9
|
+
branch: 'main',
|
|
10
|
+
commitSha: 'abc123',
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
expect(mission.id).toBe('m1')
|
|
14
|
+
expect(mission.repoUrl).toBe('https://example.com/repo.git')
|
|
15
|
+
expect(mission.branch).toBe('main')
|
|
16
|
+
expect(mission.commitSha).toBe('abc123')
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { PayloadInjector } from '../src/Application/PayloadInjector'
|
|
3
|
+
import { Mission } from '../src/Domain/Mission'
|
|
4
|
+
import { Rocket } from '../src/Domain/Rocket'
|
|
5
|
+
import { RocketStatus } from '../src/Domain/RocketStatus'
|
|
6
|
+
|
|
7
|
+
class FakeGit {
|
|
8
|
+
cloned: Array<{ repoUrl: string; branch: string }> = []
|
|
9
|
+
async clone(repoUrl: string, branch: string): Promise<string> {
|
|
10
|
+
this.cloned.push({ repoUrl, branch })
|
|
11
|
+
return '/tmp/fake-repo'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class FakeDocker {
|
|
16
|
+
copyCalls: Array<{ containerId: string; source: string; target: string }> = []
|
|
17
|
+
commands: string[][] = []
|
|
18
|
+
|
|
19
|
+
async copyFiles(containerId: string, source: string, target: string): Promise<void> {
|
|
20
|
+
this.copyCalls.push({ containerId, source, target })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async executeCommand(containerId: string, command: string[]) {
|
|
24
|
+
this.commands.push([containerId, ...command])
|
|
25
|
+
return { stdout: '', stderr: '', exitCode: 0 }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('PayloadInjector', () => {
|
|
30
|
+
test('throws when rocket has no mission', async () => {
|
|
31
|
+
const docker = new FakeDocker()
|
|
32
|
+
const git = new FakeGit()
|
|
33
|
+
const injector = new PayloadInjector(docker as any, git as any)
|
|
34
|
+
const rocket = new Rocket('rocket-9', 'container-9')
|
|
35
|
+
|
|
36
|
+
await expect(injector.deploy(rocket)).rejects.toThrow('沒有指派任務')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('deploys payload and ignites rocket', async () => {
|
|
40
|
+
const docker = new FakeDocker()
|
|
41
|
+
const git = new FakeGit()
|
|
42
|
+
const injector = new PayloadInjector(docker as any, git as any)
|
|
43
|
+
|
|
44
|
+
const rocket = new Rocket('rocket-10', 'container-10')
|
|
45
|
+
const mission = Mission.create({
|
|
46
|
+
id: 'mission-10',
|
|
47
|
+
repoUrl: 'https://example.com/repo.git',
|
|
48
|
+
branch: 'main',
|
|
49
|
+
commitSha: 'abc123',
|
|
50
|
+
})
|
|
51
|
+
rocket.assignMission(mission)
|
|
52
|
+
|
|
53
|
+
await injector.deploy(rocket)
|
|
54
|
+
|
|
55
|
+
expect(git.cloned[0]).toEqual({ repoUrl: mission.repoUrl, branch: mission.branch })
|
|
56
|
+
expect(docker.copyCalls[0]).toEqual({
|
|
57
|
+
containerId: rocket.containerId,
|
|
58
|
+
source: '/tmp/fake-repo',
|
|
59
|
+
target: '/app',
|
|
60
|
+
})
|
|
61
|
+
expect(docker.commands.length).toBeGreaterThanOrEqual(4)
|
|
62
|
+
expect(rocket.status).toBe(RocketStatus.ORBITING)
|
|
63
|
+
})
|
|
64
|
+
})
|