[READ-ONLY] Mirror of https://github.com/just-cameron/tangled-cli. Automation-first CLI for Tangled.org
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});