[READ-ONLY] Mirror of https://github.com/just-cameron/tangled-cli. Automation-first CLI for Tangled.org
0

Configure Feed

Select the types of activity you want to include in your feed.

tangled-cli / tests / lib / ci-api.test.ts
5.7 kB 169 lines
1import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2import type { TangledApiClient } from '../../src/lib/api-client.js'; 3import { 4 cancelPipeline, 5 decodePipelineLogFrame, 6 pipelineId, 7 queryAllPipelines, 8 queryPipelines, 9 triggerPipeline, 10} from '../../src/lib/ci-api.js'; 11 12function cbor(value: unknown): number[] { 13 if (typeof value === 'number' && Number.isInteger(value)) { 14 if (value >= 0 && value < 24) return [value]; 15 if (value < 0 && value >= -24) return [0x20 + (-1 - value)]; 16 } 17 if (typeof value === 'string') { 18 const bytes = [...new TextEncoder().encode(value)]; 19 if (bytes.length >= 24) throw new Error('test encoder only supports short strings'); 20 return [0x60 + bytes.length, ...bytes]; 21 } 22 if (value && typeof value === 'object' && !Array.isArray(value)) { 23 const entries = Object.entries(value); 24 if (entries.length >= 24) throw new Error('test encoder only supports short maps'); 25 return [ 26 0xa0 + entries.length, 27 ...entries.flatMap(([key, item]) => [...cbor(key), ...cbor(item)]), 28 ]; 29 } 30 throw new Error(`Unsupported test CBOR value: ${String(value)}`); 31} 32 33describe('CI API', () => { 34 const fetchMock = vi.fn(); 35 36 beforeEach(() => { 37 vi.stubGlobal('fetch', fetchMock); 38 fetchMock.mockReset(); 39 }); 40 41 afterEach(() => vi.unstubAllGlobals()); 42 43 it('queries the canonical Spindle XRPC namespace with pagination parameters', async () => { 44 fetchMock.mockResolvedValue( 45 new Response(JSON.stringify({ total: 0, pipelines: [], cursor: 'next' }), { 46 headers: { 'content-type': 'application/json' }, 47 }) 48 ); 49 await queryPipelines({ 50 spindle: 'https://spindle.example', 51 repoDid: 'did:plc:repo', 52 commits: ['abc'], 53 limit: 20, 54 cursor: 'page-2', 55 }); 56 const url = new URL(fetchMock.mock.calls[0][0]); 57 expect(url.pathname).toBe('/xrpc/sh.tangled.ci.queryPipelines'); 58 expect(url.searchParams.get('repo')).toBe('did:plc:repo'); 59 expect(url.searchParams.get('commits')).toBe('abc'); 60 expect(url.searchParams.get('limit')).toBe('20'); 61 expect(url.searchParams.get('cursor')).toBe('page-2'); 62 }); 63 64 it('follows every pipeline page', async () => { 65 fetchMock 66 .mockResolvedValueOnce( 67 new Response( 68 JSON.stringify({ 69 total: 2, 70 cursor: 'next', 71 pipelines: [{ id: 'aaa', commit: '1', trigger: {}, workflows: [] }], 72 }) 73 ) 74 ) 75 .mockResolvedValueOnce( 76 new Response( 77 JSON.stringify({ 78 total: 2, 79 pipelines: [{ id: 'bbb', commit: '2', trigger: {}, workflows: [] }], 80 }) 81 ) 82 ); 83 const pipelines = await queryAllPipelines({ 84 spindle: 'https://spindle.example', 85 repoDid: 'did:plc:repo', 86 }); 87 expect(pipelines.map(({ id }) => id)).toEqual(['aaa', 'bbb']); 88 expect(fetchMock).toHaveBeenCalledTimes(2); 89 }); 90 91 it('mints a method-bound service token for each CI mutation', async () => { 92 const getServiceAuth = vi.fn().mockResolvedValue({ data: { token: 'secret-service-token' } }); 93 const client = { 94 getAgent: vi.fn().mockResolvedValue({ com: { atproto: { server: { getServiceAuth } } } }), 95 } as unknown as TangledApiClient; 96 fetchMock 97 .mockResolvedValueOnce(new Response(JSON.stringify({ pipeline: 'abc' }))) 98 .mockResolvedValueOnce(new Response(null, { status: 204 })); 99 100 await triggerPipeline({ 101 client, 102 spindle: 'https://spindle.example', 103 repoDid: 'did:plc:repo', 104 trigger: { $type: 'sh.tangled.ci.trigger#manual', sha: 'deadbeef' }, 105 }); 106 await cancelPipeline({ 107 client, 108 spindle: 'https://spindle.example', 109 repoDid: 'did:plc:repo', 110 pipeline: 'abc', 111 }); 112 113 expect(getServiceAuth).toHaveBeenNthCalledWith(1, { 114 aud: 'did:web:spindle.example', 115 lxm: 'sh.tangled.ci.triggerPipeline', 116 }); 117 expect(getServiceAuth).toHaveBeenNthCalledWith(2, { 118 aud: 'did:web:spindle.example', 119 lxm: 'sh.tangled.ci.cancelPipeline', 120 }); 121 expect(fetchMock.mock.calls[0][1].headers.authorization).toBe('Bearer secret-service-token'); 122 }); 123 124 it('preserves and did:web-encodes a custom Spindle port in the service audience', async () => { 125 const getServiceAuth = vi.fn().mockResolvedValue({ data: { token: 'service-token' } }); 126 const client = { 127 getAgent: vi.fn().mockResolvedValue({ com: { atproto: { server: { getServiceAuth } } } }), 128 } as unknown as TangledApiClient; 129 fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ pipeline: 'abc' }))); 130 131 await triggerPipeline({ 132 client, 133 spindle: 'http://localhost:6555', 134 repoDid: 'did:plc:repo', 135 trigger: { $type: 'sh.tangled.ci.trigger#manual', sha: 'deadbeef' }, 136 }); 137 138 expect(getServiceAuth).toHaveBeenCalledWith({ 139 aud: 'did:web:localhost%3A6555', 140 lxm: 'sh.tangled.ci.triggerPipeline', 141 }); 142 }); 143 144 it('decodes concatenated CBOR event header and data payload frames', () => { 145 const bytes = new Uint8Array([ 146 ...cbor({ op: 1, t: '#data' }), 147 ...cbor({ 148 time: 'now', 149 workflow: 'check', 150 step: 2, 151 content: 'ok', 152 stream: 'stdout', 153 }), 154 ]); 155 expect(decodePipelineLogFrame(bytes)).toEqual({ 156 type: 'data', 157 time: 'now', 158 workflow: 'check', 159 step: 2, 160 content: 'ok', 161 stream: 'stdout', 162 }); 163 }); 164 165 it('normalizes pipeline URLs and rejects malformed identifiers', () => { 166 expect(pipelineId('https://spindle.example/pipelines/abc234')).toBe('abc234'); 167 expect(() => pipelineId('not valid!')).toThrow('Invalid pipeline identifier'); 168 }); 169});