@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.
Files changed (38) hide show
  1. package/.turbo/turbo-test.log +82 -0
  2. package/.turbo/turbo-typecheck.log +1 -0
  3. package/Dockerfile +23 -0
  4. package/README.md +44 -0
  5. package/debug-launch.ts +66 -0
  6. package/package.json +26 -0
  7. package/server.ts +7 -0
  8. package/simulate-github.ts +55 -0
  9. package/src/Application/MissionControl.ts +59 -0
  10. package/src/Application/PayloadInjector.ts +60 -0
  11. package/src/Application/PoolManager.ts +81 -0
  12. package/src/Application/RefurbishUnit.ts +40 -0
  13. package/src/Domain/Events.ts +28 -0
  14. package/src/Domain/Interfaces.ts +62 -0
  15. package/src/Domain/Mission.ts +27 -0
  16. package/src/Domain/Rocket.ts +116 -0
  17. package/src/Domain/RocketStatus.ts +10 -0
  18. package/src/Infrastructure/Docker/DockerAdapter.ts +127 -0
  19. package/src/Infrastructure/Git/ShellGitAdapter.ts +22 -0
  20. package/src/Infrastructure/GitHub/OctokitGitHubAdapter.ts +42 -0
  21. package/src/Infrastructure/Persistence/CachedRocketRepository.ts +42 -0
  22. package/src/Infrastructure/Persistence/InMemoryRocketRepository.ts +32 -0
  23. package/src/Infrastructure/Router/BunProxyAdapter.ts +54 -0
  24. package/src/index.ts +223 -0
  25. package/tests/Deployment.test.ts +50 -0
  26. package/tests/Integration.test.ts +42 -0
  27. package/tests/PoolManager.test.ts +60 -0
  28. package/tests/Refurbishment.test.ts +43 -0
  29. package/tests/Rocket.test.ts +49 -0
  30. package/tests/Telemetry.test.ts +50 -0
  31. package/tests/docker-adapter.test.ts +110 -0
  32. package/tests/enterprise-coverage.test.ts +62 -0
  33. package/tests/mission-control.test.ts +79 -0
  34. package/tests/mission.test.ts +18 -0
  35. package/tests/payload-injector.test.ts +64 -0
  36. package/tests/pool-manager.test.ts +93 -0
  37. package/tests/repository.test.ts +31 -0
  38. 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
+ })