UNPKG

wgc

Version:

The official CLI tool to manage the GraphQL Federation Platform Cosmo

310 lines 13.5 kB
import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { createClient, createRouterTransport } from '@connectrpc/connect'; import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; import { execa } from 'execa'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { checkDockerReadiness, getDemoLogPath, prepareSupportingData, publishAllPlugins, runRouterContainer, } from '../../src/commands/demo/util.js'; import { publishPluginPipeline, readPluginFiles } from '../../src/core/plugin-publish.js'; import { config } from '../../src/core/config.js'; const mocks = vi.hoisted(() => ({ cacheDir: '' })); vi.mock('execa'); vi.stubGlobal('fetch', vi.fn()); // runRouterContainer and publishAllPlugins pipe long-running docker output into a write stream. // Real streams open asynchronously and would race against the tmpdir cleanup in afterEach. vi.mock('node:fs', async (importOriginal) => { const mod = await importOriginal(); return { ...mod, createWriteStream: vi.fn(() => ({ write: vi.fn(), end: vi.fn(), on: vi.fn(), once: vi.fn(), })), }; }); vi.mock('../../src/core/config.js', async (importOriginal) => { const mod = await importOriginal(); return { ...mod, get cacheDir() { return mocks.cacheDir; }, }; }); vi.mock('../../src/core/plugin-publish.js', async (importOriginal) => { const mod = await importOriginal(); return { ...mod, publishPluginPipeline: vi.fn(), readPluginFiles: vi.fn() }; }); // Suppress the logo, spinner output, and commander error banners so CI logs stay readable. function silenceOutput() { vi.spyOn(console, 'log').mockImplementation(() => { }); vi.spyOn(console, 'error').mockImplementation(() => { }); vi.spyOn(process.stderr, 'write').mockImplementation(() => true); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); } function spyOnExit() { return vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); }); } // Build a fully-typed execa Result so tests can hand mockResolvedValueOnce a value that satisfies // the declared return type of `execa(...)` without `as never` at every call site. Only `stdout` // is typically relevant for the code under test; the rest are neutral defaults. // The error-related fields (name/message/stack/cause/shortMessage/originalMessage/code) are // typed as `never` on a successful Result — execa uses this as a structural signal that these // only exist on ExecaError. We set them to `undefined as never` so the literal satisfies the type. function execaResult(overrides = {}) { return { stdout: '', stderr: '', all: undefined, stdio: [undefined, '', ''], ipcOutput: [], pipedFrom: [], command: '', escapedCommand: '', cwd: '', durationMs: 0, failed: false, timedOut: false, isCanceled: false, isGracefullyCanceled: false, isMaxBuffer: false, isTerminated: false, isForcefullyTerminated: false, name: undefined, message: undefined, stack: undefined, cause: undefined, shortMessage: undefined, originalMessage: undefined, code: undefined, ...overrides, }; } function createMockTransport() { return createRouterTransport(({ service }) => { service(PlatformService, {}); }); } describe('prepareSupportingData', () => { let tmpDir; let exitSpy; beforeEach(() => { tmpDir = mkdtempSync(path.join(tmpdir(), 'demo-prep-')); mocks.cacheDir = tmpDir; silenceOutput(); exitSpy = spyOnExit(); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); it('downloads plugin files from github into cacheDir', async () => { vi.mocked(fetch) .mockResolvedValueOnce(new Response(JSON.stringify({ tree: [ { type: 'blob', path: 'plugins/products/schema.graphql' }, { type: 'blob', path: 'plugins/reviews/Dockerfile' }, ], }))) .mockResolvedValueOnce(new Response(new TextEncoder().encode('schema content'))) .mockResolvedValueOnce(new Response(new TextEncoder().encode('docker content'))); const result = await prepareSupportingData(); expect(result).toBe(path.join(tmpDir, 'demo')); expect(existsSync(path.join(tmpDir, 'demo', 'plugins/products/schema.graphql'))).toBe(true); expect(existsSync(path.join(tmpDir, 'demo', 'plugins/reviews/Dockerfile'))).toBe(true); }); it('exits when github tree api fails', async () => { vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 502, statusText: 'Bad Gateway' })); await expect(prepareSupportingData()).rejects.toThrow('process.exit'); expect(exitSpy).toHaveBeenCalledWith(1); }); it('exits when tree response is malformed', async () => { vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ invalid: 'shape' }))); await expect(prepareSupportingData()).rejects.toThrow('process.exit'); expect(exitSpy).toHaveBeenCalledWith(1); }); it('exits when a file fetch fails', async () => { vi.mocked(fetch) .mockResolvedValueOnce(new Response(JSON.stringify({ tree: [{ type: 'blob', path: 'plugins/products/schema.graphql' }], }))) .mockResolvedValueOnce(new Response(null, { status: 404, statusText: 'Not Found' })); await expect(prepareSupportingData()).rejects.toThrow('process.exit'); expect(exitSpy).toHaveBeenCalledWith(1); }); }); describe('checkDockerReadiness', () => { let exitSpy; beforeEach(() => { silenceOutput(); exitSpy = spyOnExit(); }); it('succeeds when docker, buildx, and the docker-container builder are all present', async () => { vi.mocked(execa) .mockResolvedValueOnce(execaResult({ stdout: '25.0.0' })) .mockResolvedValueOnce(execaResult({ stdout: 'v0.12.0' })) .mockResolvedValueOnce(execaResult({ stdout: 'NAME STATUS\ndefault docker\ncosmo-builder docker-container running', })); await expect(checkDockerReadiness()).resolves.toBeUndefined(); }); it('exits when docker is not available', async () => { vi.mocked(execa).mockRejectedValueOnce(new Error('docker not found')); await expect(checkDockerReadiness()).rejects.toThrow('process.exit'); expect(exitSpy).toHaveBeenCalledWith(1); }); it('exits when buildx is not available', async () => { vi.mocked(execa) .mockResolvedValueOnce(execaResult({ stdout: '25.0.0' })) .mockRejectedValueOnce(new Error('buildx not installed')); await expect(checkDockerReadiness()).rejects.toThrow('process.exit'); expect(exitSpy).toHaveBeenCalledWith(1); }); it('creates docker-container builder when missing', async () => { vi.mocked(execa) .mockResolvedValueOnce(execaResult({ stdout: '25.0.0' })) .mockResolvedValueOnce(execaResult({ stdout: 'v0.12.0' })) .mockResolvedValueOnce(execaResult({ stdout: 'NAME\ndefault docker' })) .mockResolvedValueOnce(execaResult()) .mockResolvedValueOnce(execaResult()); await expect(checkDockerReadiness()).resolves.toBeUndefined(); expect(execa).toHaveBeenCalledWith('docker', expect.arrayContaining(['buildx', 'create', '--use', '--driver', 'docker-container'])); }); it('exits when builder creation fails', async () => { vi.mocked(execa) .mockResolvedValueOnce(execaResult({ stdout: '25.0.0' })) .mockResolvedValueOnce(execaResult({ stdout: 'v0.12.0' })) .mockResolvedValueOnce(execaResult({ stdout: 'NAME\ndefault docker' })) .mockRejectedValueOnce(new Error('permission denied')); await expect(checkDockerReadiness()).rejects.toThrow('process.exit'); expect(exitSpy).toHaveBeenCalledWith(1); }); }); // The long-running docker process is simulated with a Promise that never resolves so it stays // alive while readiness polling runs; pipeToLog safely no-ops on null streams. function mockDockerProc() { const proc = Object.assign(new Promise(() => { }), { stdout: null, stderr: null, }); return proc; } describe('runRouterContainer', () => { let tmpDir; let logPath; beforeEach(() => { tmpDir = mkdtempSync(path.join(tmpdir(), 'demo-router-')); logPath = path.join(tmpDir, 'demo.log'); silenceOutput(); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); it('returns success when the router becomes ready', async () => { vi.mocked(execa).mockResolvedValueOnce(execaResult()).mockReturnValueOnce(mockDockerProc()); vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 200 })); const controller = new AbortController(); const result = await runRouterContainer({ routerToken: 'test-token', routerBaseUrl: 'http://localhost:3002', signal: controller.signal, logPath, }); expect(result).toEqual({ error: null }); expect(fetch).toHaveBeenCalledWith('http://localhost:3002/health/ready', expect.objectContaining({ signal: controller.signal })); }); it('returns error when readiness check times out', async () => { var _a; vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); vi.mocked(execa).mockResolvedValueOnce(execaResult()).mockReturnValueOnce(mockDockerProc()); vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 })); const controller = new AbortController(); const promise = runRouterContainer({ routerToken: 'test-token', routerBaseUrl: 'http://localhost:3002', signal: controller.signal, logPath, }); await vi.advanceTimersByTimeAsync(65000); const result = await promise; expect((_a = result.error) === null || _a === void 0 ? void 0 : _a.message).toContain('timed out'); vi.useRealTimers(); }); it('removes any stale router container before starting a new one', async () => { vi.mocked(execa).mockResolvedValueOnce(execaResult()).mockReturnValueOnce(mockDockerProc()); vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 200 })); const controller = new AbortController(); await runRouterContainer({ routerToken: 'test-token', routerBaseUrl: 'http://localhost:3002', signal: controller.signal, logPath, }); expect(execa).toHaveBeenCalledWith('docker', ['rm', '-f', config.demoRouterContainerName]); }); }); describe('publishAllPlugins', () => { let tmpDir; let logPath; beforeEach(() => { tmpDir = mkdtempSync(path.join(tmpdir(), 'demo-publish-')); logPath = path.join(tmpDir, 'demo.log'); silenceOutput(); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); it('publishes all configured plugins sequentially', async () => { vi.mocked(readPluginFiles).mockResolvedValue({}); vi.mocked(publishPluginPipeline).mockResolvedValue({ error: null }); const result = await publishAllPlugins({ client: { platform: createClient(PlatformService, createMockTransport()), }, supportDir: tmpDir, signal: new AbortController().signal, logPath, }); expect(result).toEqual({ error: null }); expect(publishPluginPipeline).toHaveBeenCalledTimes(2); }); it('stops and returns the error when the first plugin fails', async () => { var _a; vi.mocked(readPluginFiles).mockResolvedValue({}); vi.mocked(publishPluginPipeline).mockResolvedValueOnce({ error: new Error('build failed'), }); const result = await publishAllPlugins({ client: { platform: createClient(PlatformService, createMockTransport()), }, supportDir: tmpDir, signal: new AbortController().signal, logPath, }); expect((_a = result.error) === null || _a === void 0 ? void 0 : _a.message).toBe('build failed'); expect(publishPluginPipeline).toHaveBeenCalledTimes(1); }); }); describe('getDemoLogPath', () => { let tmpDir; beforeEach(() => { tmpDir = mkdtempSync(path.join(tmpdir(), 'demo-log-')); mocks.cacheDir = tmpDir; }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); it('returns the demo.log path under cacheDir', () => { expect(getDemoLogPath()).toBe(path.join(tmpDir, 'demo', 'demo.log')); }); it('creates the demo directory when missing', () => { const result = getDemoLogPath(); expect(existsSync(path.dirname(result))).toBe(true); }); }); //# sourceMappingURL=util.test.js.map