UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

270 lines 12 kB
import { ConcurrentOutput, useConcurrentOutputContext } from './ConcurrentOutput.js'; import { render } from '../../testing/ui.js'; import { AbortController } from '../../../../public/node/abort.js'; import { unstyled } from '../../../../public/node/output.js'; import React from 'react'; import { describe, expect, test } from 'vitest'; /** * ConcurrentOutput tests are unreliable unless we await a promise that resolves after the process has written to stdout. */ class Synchronizer { constructor() { this.resolve = () => { }; this.promise = new Promise((resolve, _reject) => { this.resolve = resolve; }); } } describe('ConcurrentOutput', () => { test('renders a stream of concurrent outputs from sub-processes', async () => { // Given const backendSync = new Synchronizer(); const frontendSync = new Synchronizer(); const backendProcess = { prefix: 'backend', action: async (stdout, _stderr, _signal) => { stdout.write('first backend message'); stdout.write('second backend message'); stdout.write('third backend message'); backendSync.resolve(); }, }; const frontendProcess = { prefix: 'frontend', action: async (stdout, _stderr, _signal) => { await backendSync.promise; stdout.write('first frontend message'); stdout.write('second frontend message'); stdout.write('third frontend message'); frontendSync.resolve(); }, }; // When const renderInstance = render(React.createElement(ConcurrentOutput, { processes: [backendProcess, frontendProcess], abortSignal: new AbortController().signal })); await frontendSync.promise; // Then expect(unstyled(renderInstance.lastFrame().replace(/\d/g, '0'))).toMatchInlineSnapshot(` "00:00:00 │ backend │ first backend message 00:00:00 │ backend │ second backend message 00:00:00 │ backend │ third backend message 00:00:00 │ frontend │ first frontend message 00:00:00 │ frontend │ second frontend message 00:00:00 │ frontend │ third frontend message " `); }); test('strips ansi codes from the output by default', async () => { const output = 'foo'; // Given const processSync = new Synchronizer(); const processes = [ { prefix: '1', action: async (stdout, _stderr, _signal) => { stdout.write(`\u001b[32m${output}\u001b[39m`); processSync.resolve(); }, }, ]; // When const renderInstance = render(React.createElement(ConcurrentOutput, { processes: processes, abortSignal: new AbortController().signal })); await processSync.promise; // Then const logColumns = renderInstance.lastFrame().split('│'); expect(logColumns.length).toBe(3); expect(logColumns[2]?.trim()).toEqual(output); }); test('does not strip ansi codes from the output when stripAnsi is false', async () => { const output = '\u001b[32mfoo\u001b[39m'; // Given const processSync = new Synchronizer(); const processes = [ { prefix: '1', action: async (stdout, _stderr, _signal) => { // eslint-disable-next-line react-hooks/rules-of-hooks useConcurrentOutputContext({ stripAnsi: false }, () => { stdout.write(output); }); processSync.resolve(); }, }, ]; // When const renderInstance = render(React.createElement(ConcurrentOutput, { processes: processes, abortSignal: new AbortController().signal })); await processSync.promise; // Then const logColumns = renderInstance.lastFrame().split('│'); expect(logColumns.length).toBe(3); expect(logColumns[2]?.trim()).toEqual(output); }); test('renders custom prefixes on log lines', async () => { // Given const processSync = new Synchronizer(); const extensionName = 'my-extension'; const processes = [ { prefix: '1', action: async (stdout, _stderr, _signal) => { // eslint-disable-next-line react-hooks/rules-of-hooks useConcurrentOutputContext({ outputPrefix: extensionName }, () => { stdout.write('foo bar'); }); processSync.resolve(); }, }, ]; // When const renderInstance = render(React.createElement(ConcurrentOutput, { processes: processes, // Ensure it's not truncated prefixColumnSize: extensionName.length, abortSignal: new AbortController().signal })); await processSync.promise; // Then const logColumns = unstyled(renderInstance.lastFrame()).split('│'); expect(logColumns.length).toBe(3); expect(logColumns[1]?.trim()).toEqual(extensionName); }); test('renders prefix column width based on prefixColumnSize', async () => { // Given const processSync1 = new Synchronizer(); const processSync2 = new Synchronizer(); const columnSize = 5; const processes = [ { prefix: '1234567890', action: async (stdout, _stderr, _signal) => { stdout.write('foo'); processSync1.resolve(); }, }, { prefix: '1', action: async (stdout, _stderr, _signal) => { stdout.write('bar'); processSync2.resolve(); }, }, ]; // When const renderInstance = render(React.createElement(ConcurrentOutput, { processes: processes, prefixColumnSize: columnSize, abortSignal: new AbortController().signal })); await Promise.all([processSync1.promise, processSync2.promise]); // Then const logLines = unstyled(renderInstance.lastFrame()).split('\n').filter(Boolean); expect(logLines.length).toBe(2); logLines.forEach((line) => { const logColumns = line.split('│'); expect(logColumns.length).toBe(3); // Including spacing expect(logColumns[1]?.length).toBe(columnSize + 2); }); }); test('renders prefix column width based on processes by default', async () => { // Given const processSync = new Synchronizer(); const processes = [ { prefix: '1', action: async (stdout, _stderr, _signal) => { stdout.write('foo'); processSync.resolve(); }, }, { prefix: '12', action: async () => { } }, { prefix: '123', action: async () => { } }, { prefix: '1234', action: async () => { } }, ]; // When const renderInstance = render(React.createElement(ConcurrentOutput, { processes: processes, abortSignal: new AbortController().signal })); await processSync.promise; // Then const logColumns = unstyled(renderInstance.lastFrame()).split('│'); expect(logColumns.length).toBe(3); // 4 is largest prefix, plus spacing expect(logColumns[1]?.length).toBe(4 + 2); }); test('does not render prefix column larger than max', async () => { // Given const processSync = new Synchronizer(); const processes = [ { prefix: '1', action: async (stdout, _stderr, _signal) => { stdout.write('foo'); processSync.resolve(); }, }, { prefix: new Array(26).join('0'), action: async () => { } }, ]; // When const renderInstance = render(React.createElement(ConcurrentOutput, { processes: processes, abortSignal: new AbortController().signal })); await processSync.promise; // Then const logColumns = unstyled(renderInstance.lastFrame()).split('│'); expect(logColumns.length).toBe(3); // 25 is largest column allowed, plus spacing expect(logColumns[1]?.length).toBe(25 + 2); }); test('rejects with the error thrown inside one of the processes', async () => { // Given const backendProcess = { prefix: 'backend', action: async (stdout, _stderr, _signal) => { stdout.write('first backend message'); stdout.write('second backend message'); stdout.write('third backend message'); throw new Error('something went wrong'); }, }; // When const renderInstance = render(React.createElement(ConcurrentOutput, { processes: [backendProcess], abortSignal: new AbortController().signal })); const renderPromise = renderInstance.waitUntilExit(); await expect(renderPromise).rejects.toThrowError('something went wrong'); expect(renderPromise.isRejected()).toBe(true); }); test("doesn't reject when an error is thrown inside one of the processes and keepRunningAfterProcessesResolve is true", async () => { // Given const backendProcess = { prefix: 'backend', action: async (stdout, _stderr, _signal) => { stdout.write('first backend message'); stdout.write('second backend message'); stdout.write('third backend message'); throw new Error('something went wrong'); }, }; // When const renderInstance = render(React.createElement(ConcurrentOutput, { processes: [backendProcess], abortSignal: new AbortController().signal, keepRunningAfterProcessesResolve: true })); await new Promise((resolve) => setTimeout(resolve, 500)); expect(renderInstance.waitUntilExit().isRejected()).toBe(false); }); test('render promise resolves when all processes resolve by default', async () => { const backendProcess = { prefix: 'backend', action: async (stdout, _stderr, _signal) => { stdout.write('first backend message'); stdout.write('second backend message'); stdout.write('third backend message'); }, }; // When const renderInstance = render(React.createElement(ConcurrentOutput, { processes: [backendProcess], abortSignal: new AbortController().signal })); const renderPromise = renderInstance.waitUntilExit(); await renderPromise; expect(renderPromise.isFulfilled()).toBe(true); }); test("render promise doesn't resolve when all processes resolve and keepRunningAfterProcessesResolve is true", async () => { const backendProcess = { prefix: 'backend', action: async (stdout, _stderr, _signal) => { stdout.write('first backend message'); stdout.write('second backend message'); stdout.write('third backend message'); }, }; // When const renderInstance = render(React.createElement(ConcurrentOutput, { keepRunningAfterProcessesResolve: true, processes: [backendProcess], abortSignal: new AbortController().signal })); await new Promise((resolve) => setTimeout(resolve, 500)); expect(renderInstance.waitUntilExit().isFulfilled()).toBe(false); }); }); //# sourceMappingURL=ConcurrentOutput.test.js.map