|
| 1 | +import fs from 'node:fs'; |
| 2 | +import os from 'node:os'; |
| 3 | +import path from 'node:path'; |
| 4 | +import { expect, test } from 'vitest'; |
| 5 | +import { DoublespeedApiClient } from './api-client.ts'; |
| 6 | +import { readySimulator, scriptedFetch } from './runtime.fixtures.ts'; |
| 7 | + |
| 8 | +function client(fetchImpl: typeof fetch, apiUrl?: string) { |
| 9 | + return new DoublespeedApiClient({ |
| 10 | + apiKey: 'dsx_test_key', |
| 11 | + clientVersion: '1.2.3', |
| 12 | + fetch: fetchImpl, |
| 13 | + ...(apiUrl ? { apiUrl } : {}), |
| 14 | + }); |
| 15 | +} |
| 16 | + |
| 17 | +test('creates a simulator, identifies the CLI, and polls until the session is ready', async () => { |
| 18 | + const { fetch, calls } = scriptedFetch([ |
| 19 | + () => ({ |
| 20 | + status: 202, |
| 21 | + body: readySimulator({ ready: false, status: 'queued', api_url: null }), |
| 22 | + }), |
| 23 | + () => ({ body: readySimulator({ ready: false, status: 'preparing', api_url: null }) }), |
| 24 | + () => ({ body: readySimulator() }), |
| 25 | + ]); |
| 26 | + |
| 27 | + const simulator = await client(fetch, 'https://api.example/').createSimulator({ |
| 28 | + device: 'iPhone 16 Pro', |
| 29 | + labels: { leaseId: 'lease-a' }, |
| 30 | + idleTimeoutSeconds: 600, |
| 31 | + }); |
| 32 | + |
| 33 | + expect(simulator.ready).toBe(true); |
| 34 | + expect(simulator.api_url).toBe('https://worker.example/i/token-a'); |
| 35 | + expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([ |
| 36 | + 'POST https://api.example/v1/xcode/simulators', |
| 37 | + 'GET https://api.example/v1/xcode/simulators/sim-a?wait=1', |
| 38 | + 'GET https://api.example/v1/xcode/simulators/sim-a?wait=1', |
| 39 | + ]); |
| 40 | + expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ |
| 41 | + device: 'iPhone 16 Pro', |
| 42 | + labels: { leaseId: 'lease-a' }, |
| 43 | + idle_timeout_seconds: 600, |
| 44 | + wait: true, |
| 45 | + }); |
| 46 | + expect(calls[0]?.init.headers).toMatchObject({ |
| 47 | + authorization: 'Bearer dsx_test_key', |
| 48 | + 'x-agent-device-client': 'agent-device-cli', |
| 49 | + 'x-agent-device-version': '1.2.3', |
| 50 | + }); |
| 51 | +}); |
| 52 | + |
| 53 | +test('fails closed when the simulator job ends before it is ready', async () => { |
| 54 | + const { fetch } = scriptedFetch([ |
| 55 | + () => ({ |
| 56 | + status: 202, |
| 57 | + body: readySimulator({ |
| 58 | + ready: false, |
| 59 | + status: 'failed', |
| 60 | + api_url: null, |
| 61 | + error: { code: 'PREVIEW_SETUP', message: 'no simulator named "iPhone 3"' }, |
| 62 | + }), |
| 63 | + }), |
| 64 | + ]); |
| 65 | + await expect(client(fetch).createSimulator({ labels: {} })).rejects.toMatchObject({ |
| 66 | + code: 'COMMAND_FAILED', |
| 67 | + details: { status: 'failed', providerError: { code: 'PREVIEW_SETUP' } }, |
| 68 | + }); |
| 69 | +}); |
| 70 | + |
| 71 | +test('classifies authentication and credit failures without echoing the key', async () => { |
| 72 | + const unauthorized = scriptedFetch([ |
| 73 | + () => ({ status: 401, body: { error: { code: 'UNAUTHORIZED', message: 'bad key' } } }), |
| 74 | + ]); |
| 75 | + await expect(client(unauthorized.fetch).listSimulators({})).rejects.toSatisfy( |
| 76 | + (error: unknown) => { |
| 77 | + expect(error).toMatchObject({ code: 'UNAUTHORIZED' }); |
| 78 | + expect(JSON.stringify(error)).not.toContain('dsx_test_key'); |
| 79 | + return true; |
| 80 | + }, |
| 81 | + ); |
| 82 | + const credits = scriptedFetch([ |
| 83 | + () => ({ |
| 84 | + status: 402, |
| 85 | + body: { error: { code: 'INSUFFICIENT_CREDITS', message: 'no credits' } }, |
| 86 | + }), |
| 87 | + ]); |
| 88 | + await expect(client(credits.fetch).createSimulator({ labels: {} })).rejects.toMatchObject({ |
| 89 | + code: 'COMMAND_FAILED', |
| 90 | + details: { status: 402, providerCode: 'INSUFFICIENT_CREDITS' }, |
| 91 | + }); |
| 92 | +}); |
| 93 | + |
| 94 | +test('lists simulators by label selector and deletes by id', async () => { |
| 95 | + const { fetch, calls } = scriptedFetch([ |
| 96 | + () => ({ body: { simulators: [readySimulator()] } }), |
| 97 | + () => ({ body: readySimulator({ status: 'cancelled', ready: false }) }), |
| 98 | + ]); |
| 99 | + const api = client(fetch); |
| 100 | + const simulators = await api.listSimulators({ provider: 'doublespeed', leaseId: 'lease-a' }); |
| 101 | + await api.deleteSimulator('sim-a'); |
| 102 | + expect(simulators.map((simulator) => simulator.id)).toEqual(['sim-a']); |
| 103 | + expect(calls[0]?.url).toBe( |
| 104 | + 'https://api.mac.doublespeed.ai/v1/xcode/simulators?label_selector=provider%3Ddoublespeed%2CleaseId%3Dlease-a', |
| 105 | + ); |
| 106 | + expect(`${calls[1]?.init.method} ${calls[1]?.url}`).toBe( |
| 107 | + 'DELETE https://api.mac.doublespeed.ai/v1/xcode/simulators/sim-a', |
| 108 | + ); |
| 109 | +}); |
| 110 | + |
| 111 | +test('registers, uploads and completes an asset with the signed URL as the only capability', async () => { |
| 112 | + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doublespeed-asset-')); |
| 113 | + const filePath = path.join(tempDir, 'app.zip'); |
| 114 | + fs.writeFileSync(filePath, 'zip-bytes'); |
| 115 | + const { fetch, calls } = scriptedFetch([ |
| 116 | + () => ({ |
| 117 | + body: { |
| 118 | + sha256: 'abc', |
| 119 | + exists: false, |
| 120 | + upload_url: 'https://blob.example/upload?token=x', |
| 121 | + download_url: null, |
| 122 | + }, |
| 123 | + }), |
| 124 | + () => ({ body: {} }), |
| 125 | + () => ({ |
| 126 | + body: { |
| 127 | + sha256: 'abc', |
| 128 | + exists: true, |
| 129 | + upload_url: null, |
| 130 | + download_url: 'https://blob.example/get', |
| 131 | + }, |
| 132 | + }), |
| 133 | + ]); |
| 134 | + const api = client(fetch); |
| 135 | + const registered = await api.registerAsset({ sha256: 'abc', size: 9, name: 'app.zip' }); |
| 136 | + await api.uploadAsset(registered.upload_url!, filePath); |
| 137 | + const completed = await api.completeAsset('abc', 9); |
| 138 | + |
| 139 | + expect(completed.download_url).toBe('https://blob.example/get'); |
| 140 | + expect(`${calls[1]?.init.method} ${calls[1]?.url}`).toBe( |
| 141 | + 'PUT https://blob.example/upload?token=x', |
| 142 | + ); |
| 143 | + expect(calls[1]?.init.headers).not.toHaveProperty('authorization'); |
| 144 | + expect(String(calls[1]?.init.body)).toBe('zip-bytes'); |
| 145 | + expect(calls[2]?.url).toBe('https://api.mac.doublespeed.ai/v1/xcode/assets/abc/complete'); |
| 146 | + fs.rmSync(tempDir, { recursive: true, force: true }); |
| 147 | +}); |
0 commit comments