From 684966d5f1baf8cdca1d95baa782a1a960a2a4e8 Mon Sep 17 00:00:00 2001 From: Julien Capellari Date: Sat, 25 Nov 2023 23:30:55 +0100 Subject: [PATCH] Scrollable task spinner (#909) * feat: feat rework task spinner * feat: fix task order * feat: fix & stabilize e2e tests * feat: make task-tree scroll * feat: add tree completed component * feat: factorize task flatener * feat: add stats at then end * fix: build error * fix: remove old tests * fix: handle no raw mode * fix: e2e tests * fix: remove cast * fix: tests * fix: lockfile * fix: task command tests * fix: sonarcloud review --------- Co-authored-by: Julien Capellari --- e2e/each.test.ts | 22 ++-- e2e/exec.test.ts | 4 + e2e/group.test.ts | 16 ++- e2e/run.test.ts | 8 +- src/index.ts | 6 -- src/modules/task-command.tsx | 8 +- src/tasks/task-expression.service.ts | 6 +- src/ui/group-task-spinner.tsx | 85 ---------------- src/ui/hooks/useFlatTaskTree.ts | 128 ++++++++++++++++++++++++ src/ui/hooks/useIsVerbose.ts | 16 +++ src/ui/hooks/useStdoutDimensions.ts | 24 +++++ src/ui/task-manager-spinner.tsx | 55 ---------- src/ui/task-spinner.tsx | 2 +- src/ui/task-tree-completed.tsx | 38 +++++++ src/ui/task-tree-full-spinner.tsx | 32 ++++++ src/ui/task-tree-scrollable-spinner.tsx | 65 ++++++++++++ src/ui/task-tree-spinner.tsx | 21 ++++ src/ui/task-tree-stats.tsx | 69 +++++++++++++ tests/commands/each.test.tsx | 24 +++-- tests/commands/exec.test.tsx | 24 +++-- tests/commands/group.test.tsx | 29 ++++-- tests/commands/run.test.tsx | 24 +++-- tests/modules/task-command.test.tsx | 17 +++- tests/ui/group-task-spinner.test.tsx | 77 -------------- tests/ui/task-manager-spinner.test.tsx | 59 ----------- tools/test-bed.ts | 4 +- tools/test-tasks.ts | 7 +- yarn.lock | 48 +-------- 28 files changed, 535 insertions(+), 383 deletions(-) delete mode 100644 src/ui/group-task-spinner.tsx create mode 100644 src/ui/hooks/useFlatTaskTree.ts create mode 100644 src/ui/hooks/useIsVerbose.ts create mode 100644 src/ui/hooks/useStdoutDimensions.ts delete mode 100644 src/ui/task-manager-spinner.tsx create mode 100644 src/ui/task-tree-completed.tsx create mode 100644 src/ui/task-tree-full-spinner.tsx create mode 100644 src/ui/task-tree-scrollable-spinner.tsx create mode 100644 src/ui/task-tree-spinner.tsx create mode 100644 src/ui/task-tree-stats.tsx delete mode 100644 tests/ui/group-task-spinner.test.tsx delete mode 100644 tests/ui/task-manager-spinner.test.tsx diff --git a/e2e/each.test.ts b/e2e/each.test.ts index 9fb19441b..47744a716 100644 --- a/e2e/each.test.ts +++ b/e2e/each.test.ts @@ -84,10 +84,11 @@ describe('jill each', () => { expect(res.code).toBe(0); expect(res.screen.screen).toMatchLines([ - expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), - expect.ignoreColor(/^. Run start in wks-b \(took [0-9.]+m?s\)$/), - expect.ignoreColor(/^. Run build in wks-b \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^. Run start in wks-a \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. Run build in wks-b \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. Run start in wks-b \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 4 done$/), ]); // Check script result @@ -111,13 +112,14 @@ describe('jill each', () => { expect(res.code).toBe(0); expect(res.screen.screen).toMatchLines([ - expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), - expect.ignoreColor(/^. In sequence \(took [0-9.]+m?s\)$/), - expect.ignoreColor(/^ {2}. Run build in wks-b \(took [0-9.]+m?s\)$/), - expect.ignoreColor(/^ {2}. Run start in wks-b \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^. In sequence \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run build in wks-a \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run start in wks-a \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. In sequence \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^ {2}. Run build in wks-b \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^ {2}. Run start in wks-b \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 5 done$/), ]); // Check script result @@ -144,9 +146,10 @@ describe('jill each', () => { expect(res.code).toBe(0); expect(res.screen.screen).toMatchLines([ + expect.ignoreColor(/^. Run hooked in wks-b \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^. Run hooked in wks-c \(took [0-9.]+m?s\)$/), - expect.ignoreColor(/^. Run hooked in wks-b \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 7 done$/), ]); // Check script result @@ -179,9 +182,10 @@ describe('jill each', () => { expect(res.code).toBe(1); expect(res.screen.screen).toMatchLines([ - expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^. Run fails in wks-b \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}.( yarn exec)? node -e "process.exit\(1\)" \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 1 done, . 1 failed$/), ]); // Check script result diff --git a/e2e/exec.test.ts b/e2e/exec.test.ts index 61035aa9b..4c24f428f 100644 --- a/e2e/exec.test.ts +++ b/e2e/exec.test.ts @@ -57,6 +57,7 @@ describe('jill exec', () => { expect(res.screen.screen).toMatchLines([ expect.ignoreColor(/^.( yarn exec)? node -e "require\('node:fs'\).+ \(took [0-9.]+m?s\)/), + expect.ignoreColor(/^. 1 done$/), ]); // Check script result @@ -72,6 +73,7 @@ describe('jill exec', () => { expect(res.screen.screen).toMatchLines([ expect.ignoreColor(/^.( yarn exec)? echo toto \(took [0-9.]+m?s\)/), + expect.ignoreColor(/^. 1 done$/), ]); expect(res.stderr).toMatchLines([ @@ -98,6 +100,7 @@ describe('jill exec', () => { expect(res.screen.screen).toMatchLines([ expect.ignoreColor(/^.( yarn exec)? node -e "process.exit\(1\)" \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 1 failed$/), ]); }); @@ -110,6 +113,7 @@ describe('jill exec', () => { expect(res.screen.screen).toMatchLines([ expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^.( yarn exec)? node -e "require\('node:fs'\).+ \(took [0-9.]+m?s\)/), + expect.ignoreColor(/^. 2 done$/), ]); // Check scripts result diff --git a/e2e/group.test.ts b/e2e/group.test.ts index 814901ea6..8b356dad0 100644 --- a/e2e/group.test.ts +++ b/e2e/group.test.ts @@ -77,6 +77,7 @@ describe('jill group', () => { expect.ignoreColor(/^. In parallel \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run test1 in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run test2 in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 2 done$/), ]); // Check script result @@ -95,6 +96,7 @@ describe('jill group', () => { expect.ignoreColor(/^ {2}. Run test1 in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run fails in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {4}.( yarn exec)? node -e "process.exit\(1\)" \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 1 done, . 1 failed$/), ]); // Check script result @@ -109,10 +111,11 @@ describe('jill group', () => { expect(res.code).toBe(0); expect(res.screen.screen).toMatchLines([ - expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^. In parallel \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run test1 in wks-b \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run test2 in wks-b \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 3 done$/), ]); // Check script result @@ -135,6 +138,7 @@ describe('jill group', () => { expect.ignoreColor(/^. In sequence \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run test1 in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run test2 in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 2 done$/), ]); // Check script result @@ -153,6 +157,7 @@ describe('jill group', () => { expect.ignoreColor(/^ {2}. Run test1 in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run fails in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {4}.( yarn exec)? node -e "process.exit\(1\)" \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 1 done, . 1 failed$/), ]); // Check script result @@ -167,10 +172,11 @@ describe('jill group', () => { expect(res.code).toBe(0); expect(res.screen.screen).toMatchLines([ - expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^. In sequence \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run test1 in wks-b \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run test2 in wks-b \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 3 done$/), ]); // Check script result @@ -193,6 +199,7 @@ describe('jill group', () => { expect.ignoreColor(/^. Fallbacks \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run test1 in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run fails in wks-c$/), + expect.ignoreColor(/^. 1 done$/), ]); // Check script result @@ -211,6 +218,7 @@ describe('jill group', () => { expect.ignoreColor(/^ {2}. Run fails in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {4}.( yarn exec)? node -e "process.exit\(1\)" \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run test2 in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 1 done, . 1 failed$/), ]); // Check script result @@ -230,6 +238,7 @@ describe('jill group', () => { expect.ignoreColor(/^ {4}.( yarn exec)? node -e "process.exit\(1\)" \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run fails2 in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {4}.( yarn exec)? node -e "process.exit\(1\)" \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 2 failed/), ]); }); @@ -240,11 +249,12 @@ describe('jill group', () => { expect(res.code).toBe(0); expect(res.screen.screen).toMatchLines([ - expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^. Fallbacks \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run fails in wks-b \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {4}.( yarn exec)? node -e "process.exit\(1\)" \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run test2 in wks-b \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 2 done, . 1 failed$/), ]); // Check script result diff --git a/e2e/run.test.ts b/e2e/run.test.ts index e9bd3c4fb..8b9650410 100644 --- a/e2e/run.test.ts +++ b/e2e/run.test.ts @@ -72,6 +72,7 @@ describe('jill run', () => { expect(res.screen.screen).toMatchLines([ expect.ignoreColor(/^. Run start in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 1 done$/), ]); // Check script result @@ -89,6 +90,7 @@ describe('jill run', () => { expect.ignoreColor(/^. In sequence \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run build in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run start in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 2 done$/), ]); // Check script result @@ -107,6 +109,7 @@ describe('jill run', () => { expect(res.screen.screen).toMatchLines([ expect.ignoreColor(/^. Run hooked in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 3 done$/), ]); // Check script result @@ -129,6 +132,7 @@ describe('jill run', () => { expect(res.screen.screen).toMatchLines([ expect.ignoreColor(/^. Run fails in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}.( yarn exec)? node -e "process.exit\(1\)" \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 1 failed$/), ]); }); @@ -143,6 +147,7 @@ describe('jill run', () => { expect.ignoreColor(/^ {2}. Run build in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {2}. Run fails in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^ {4}.( yarn exec)? node -e "process.exit\(1\)" \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 1 done, . 1 failed$/), ]); }); @@ -164,8 +169,9 @@ describe('jill run', () => { expect(res.code).toBe(0); expect(res.screen.screen).toMatchLines([ - expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), expect.ignoreColor(/^. Run start in wks-b \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. Run build in wks-c \(took [0-9.]+m?s\)$/), + expect.ignoreColor(/^. 2 done$/), ]); // Check scripts result diff --git a/src/index.ts b/src/index.ts index 75c780377..f995e9d8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,18 +40,12 @@ export { CONFIG } from './config/config-loader.ts'; export { type IConfig } from './config/types.ts'; // Ui -export { default as GroupTaskSpinner } from './ui/group-task-spinner.tsx'; -export * from './ui/group-task-spinner.tsx'; - export { default as Layout } from './ui/layout.tsx'; export * from './ui/layout.tsx'; export { default as List } from './ui/list.tsx'; export * from './ui/list.tsx'; -export { default as TaskManagerSpinner } from './ui/task-manager-spinner.tsx'; -export * from './ui/task-manager-spinner.tsx'; - export { default as TaskName } from './ui/task-name.tsx'; export * from './ui/task-name.tsx'; diff --git a/src/modules/task-command.tsx b/src/modules/task-command.tsx index 64f6bb7f6..9a00eb44c 100644 --- a/src/modules/task-command.tsx +++ b/src/modules/task-command.tsx @@ -10,7 +10,8 @@ import { isScriptCtx } from '@/src/tasks/script-task.ts'; import { TASK_MANAGER } from '@/src/tasks/task-manager.config.ts'; import { type AwaitableGenerator } from '@/src/types.ts'; import List from '@/src/ui/list.tsx'; -import TaskManagerSpinner from '@/src/ui/task-manager-spinner.tsx'; +import TaskTreeCompleted from '@/src/ui/task-tree-completed.tsx'; +import TaskTreeSpinner from '@/src/ui/task-tree-spinner.tsx'; import { ExitException } from '@/src/utils/exit.ts'; import { printJson } from '@/src/utils/json.ts'; @@ -73,13 +74,16 @@ export abstract class TaskCommand extends InkCommand { } } else if (tasks.tasks.length > 0) { // Render - yield ; + yield ; // Start tasks tasks.start(this.manager); const result = await waitFor$(tasks, 'finished'); + this.app.clear(); + yield ; + if (result.failed > 0) { throw new ExitException(1); } diff --git a/src/tasks/task-expression.service.ts b/src/tasks/task-expression.service.ts index 1e4c54c6c..a731a9872 100644 --- a/src/tasks/task-expression.service.ts +++ b/src/tasks/task-expression.service.ts @@ -214,11 +214,11 @@ export class TaskExpressionService { let group: GroupTask; if (node.operator === '//') { - group = new ParallelGroup('In parallel', {}, { + group = new ParallelGroup('In parallel', { workspace }, { logger: this._logger, }); } else if (node.operator === '||') { - group = new FallbackGroup('Fallbacks', {}, { + group = new FallbackGroup('Fallbacks', { workspace }, { logger: this._logger, }); } else { @@ -227,7 +227,7 @@ export class TaskExpressionService { TaskExpressionService._sequenceOperatorWarn = true; } - group = new SequenceGroup('In sequence', {}, { + group = new SequenceGroup('In sequence', { workspace }, { logger: this._logger, }); } diff --git a/src/ui/group-task-spinner.tsx b/src/ui/group-task-spinner.tsx deleted file mode 100644 index 81630f22c..000000000 --- a/src/ui/group-task-spinner.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { GroupTask } from '@jujulego/tasks'; -import { Box } from 'ink'; -import { Fragment, useLayoutEffect, useMemo, useState } from 'react'; - -import { CONFIG } from '@/src/config/config-loader.ts'; -import { container } from '@/src/inversify.config.ts'; -import { isCommandCtx } from '@/src/tasks/command-task.ts'; - -import TaskSpinner from './task-spinner.tsx'; - -// Types -export interface GroupTaskSpinnerProps { - group: GroupTask; -} - -// Components -export default function GroupTaskSpinner({ group }: GroupTaskSpinnerProps) { - // State - const [verbose, setVerbose] = useState(false); - const [status, setStatus] = useState(group.status); - const [tasks, setTasks] = useState([...group.tasks]); - const [canReduce, setCanReduce] = useState(true); - - // Memo - const forceExtended = useMemo(() => verbose || tasks.some((tsk) => !isCommandCtx(tsk.context)), [verbose, tasks]); - const isReduced = useMemo(() => !forceExtended && canReduce, [forceExtended, canReduce]); - - // Effects - useLayoutEffect(() => { - const config = container.get(CONFIG); - - if (config.verbose) { - setVerbose(['verbose', 'debug'].includes(config.verbose)); - } - }, []); - - useLayoutEffect(() => { - return group.on('status', (event) => { - setStatus(event.status); - }); - }, [group]); - - useLayoutEffect(() => { - let dirty = false; - - return group.on('task.added', () => { - if (!dirty) { - dirty = true; - - queueMicrotask(() => { - setTasks([...group.tasks]); - dirty = false; - }); - } - }); - }, [group]); - - useLayoutEffect(() => { - if (status === 'running') { - setCanReduce(false); - } else if (status === 'done') { - setCanReduce(true); - } - }, [status]); - - // Render - return ( - <> - - { isReduced || ( - - { tasks.map((task) => ( - - { (task instanceof GroupTask) ? ( - - ) : ( - - ) } - - )) } - - ) } - - ); -} diff --git a/src/ui/hooks/useFlatTaskTree.ts b/src/ui/hooks/useFlatTaskTree.ts new file mode 100644 index 000000000..9b6bf8e06 --- /dev/null +++ b/src/ui/hooks/useFlatTaskTree.ts @@ -0,0 +1,128 @@ +import { GroupTask, Task, TaskManager } from '@jujulego/tasks'; +import { useLayoutEffect, useMemo, useState } from 'react'; + +import { Workspace } from '@/src/project/workspace.ts'; +import { CommandTask, isCommandCtx } from '@/src/tasks/command-task.ts'; +import { ScriptTask } from '@/src/tasks/script-task.ts'; +import { useIsVerbose } from '@/src/ui/hooks/useIsVerbose.ts'; + +// Types +export interface FlatTask { + task: Task; + level: number; +} + +// Utils +/** + * Sorts tasks according to workspace and script, keeping command at the end + */ +export function taskComparator(a: Task, b: Task) { + // 1 - compare kind + const kindA = a instanceof CommandTask ? 0 : 1; + const kindB = b instanceof CommandTask ? 0 : 1; + + if (kindA !== kindB) { + return kindB - kindA; + } + + // 2 - compare workspaces + const wksA = 'workspace' in a.context ? (a.context.workspace as Workspace).name : '\uffff'; + const wksB = 'workspace' in b.context ? (b.context.workspace as Workspace).name : '\uffff'; + const wksDiff = wksA.localeCompare(wksB); + + if (wksDiff !== 0) { + return wksDiff; + } + + // 1 - compare scripts + const scriptA = 'script' in a.context ? a.context.script as string : '\uffff'; + const scriptB = 'script' in b.context ? b.context.script as string : '\uffff'; + + return scriptA.localeCompare(scriptB); +} + +/** + * Extract tasks to be printed, with their level in the tree + */ +export function * flatTasks(tasks: readonly Task[], isVerbose: boolean, groupId?: string, level = 0): Generator { + for (const task of tasks) { + if (task.group?.id !== groupId) { + continue; + } + + yield { task, level }; + + if (task instanceof GroupTask) { + const isCommandGroup = task.tasks.some((t) => !isCommandCtx(t.context)); + const hasFailed = task.tasks.some((t) => t.status === 'failed'); + const isStarted = task.status === 'running'; + + if (isVerbose || isCommandGroup || hasFailed || isStarted) { + let tasks = task.tasks; + + if (task instanceof ScriptTask) { + tasks = [...tasks].sort(taskComparator); + } + + yield* flatTasks(tasks, isVerbose, task.id, level + 1); + } + } + } +} + +// Hook +export function useFlatTaskTree(manager: TaskManager): FlatTask[] { + const isVerbose = useIsVerbose(); + + const [tasks, setTasks] = useState([...manager.tasks].sort(taskComparator)); + const [version, setVersion] = useState(0); + + useLayoutEffect(() => { + let dirty = false; + + return manager.on('added', () => { + if (!dirty) { + dirty = true; + + queueMicrotask(() => { + setTasks([...manager.tasks].sort(taskComparator)); + dirty = false; + }); + } + }); + }, [manager]); + + useLayoutEffect(() => { + let dirty = false; + + return manager.on('started', () => { + if (!dirty) { + dirty = true; + + setTimeout(() => { + setVersion((old) => ++old); + dirty = false; + }); + } + }); + }, [manager]); + + useLayoutEffect(() => { + let dirty = false; + + return manager.on('completed', () => { + if (!dirty) { + dirty = true; + + setTimeout(() => { + setVersion((old) => ++old); + dirty = false; + }); + } + }); + }, [manager]); + + return useMemo(() => { + return Array.from(flatTasks(tasks, isVerbose)); + }, [tasks, isVerbose, version]); +} diff --git a/src/ui/hooks/useIsVerbose.ts b/src/ui/hooks/useIsVerbose.ts new file mode 100644 index 000000000..f595e3111 --- /dev/null +++ b/src/ui/hooks/useIsVerbose.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; + +import { CONFIG } from '@/src/config/config-loader.ts'; +import { container } from '@/src/inversify.config.ts'; + +export function useIsVerbose() { + return useMemo(() => { + const config = container.get(CONFIG); + + if (config.verbose) { + return ['verbose', 'debug'].includes(config.verbose); + } else { + return false; + } + }, []); +} diff --git a/src/ui/hooks/useStdoutDimensions.ts b/src/ui/hooks/useStdoutDimensions.ts new file mode 100644 index 000000000..c59a65503 --- /dev/null +++ b/src/ui/hooks/useStdoutDimensions.ts @@ -0,0 +1,24 @@ +import { useStdout } from 'ink'; +import { useEffect, useState } from 'react'; + +export function useStdoutDimensions() { + const { stdout } = useStdout(); + const [dimensions, setDimensions] = useState({ + columns: stdout.columns ?? Infinity, + rows: stdout.rows ?? Infinity, + }); + + useEffect(() => { + const handler = () => setDimensions({ + columns: stdout.columns ?? Infinity, + rows: stdout.rows ?? Infinity, + }); + stdout.on('resize', handler); + + return () => { + stdout.off('resize', handler); + }; + }, [stdout]); + + return dimensions; +} diff --git a/src/ui/task-manager-spinner.tsx b/src/ui/task-manager-spinner.tsx deleted file mode 100644 index 7581d4e3c..000000000 --- a/src/ui/task-manager-spinner.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { GroupTask, Task, type TaskManager } from '@jujulego/tasks'; -import { useLayoutEffect, useState } from 'react'; - -import GroupTaskSpinner from './group-task-spinner.tsx'; -import TaskSpinner from './task-spinner.tsx'; - -// Types -export interface TasksSpinnerProps { - manager: TaskManager; -} - -// Utils -function taskPredicate(task: Task): boolean { - if (task.group) { - return false; - } - - if ('hidden' in task.context && task.context.hidden) { - return false; - } - - return true; -} - -// Components -export default function TaskManagerSpinner({ manager }: TasksSpinnerProps) { - const [tasks, setTasks] = useState(manager.tasks.filter(taskPredicate)); - - useLayoutEffect(() => { - let dirty = false; - - return manager.on('added', () => { - if (!dirty) { - dirty = true; - - queueMicrotask(() => { - setTasks(manager.tasks.filter(taskPredicate)); - dirty = false; - }); - } - }); - }, [manager]); - - return ( - <> - { tasks.map((task) => - (task instanceof GroupTask) ? ( - - ) : ( - - ) - ) } - - ); -} diff --git a/src/ui/task-spinner.tsx b/src/ui/task-spinner.tsx index 3b1fc26f7..f7e062ccd 100644 --- a/src/ui/task-spinner.tsx +++ b/src/ui/task-spinner.tsx @@ -19,7 +19,7 @@ export interface TaskSpinnerProps { export default function TaskSpinner({ task }: TaskSpinnerProps) { // State const [status, setStatus] = useState(task.status); - const [time, setTime] = useState(0); + const [time, setTime] = useState(task.duration); // Effects useLayoutEffect(() => { diff --git a/src/ui/task-tree-completed.tsx b/src/ui/task-tree-completed.tsx new file mode 100644 index 000000000..fab594246 --- /dev/null +++ b/src/ui/task-tree-completed.tsx @@ -0,0 +1,38 @@ +import { TaskManager } from '@jujulego/tasks'; +import { Box, Static } from 'ink'; +import { useMemo } from 'react'; + +import { flatTasks, taskComparator } from '@/src/ui/hooks/useFlatTaskTree.ts'; +import { useIsVerbose } from '@/src/ui/hooks/useIsVerbose.ts'; +import TaskSpinner from '@/src/ui/task-spinner.tsx'; +import TaskTreeStats from '@/src/ui/task-tree-stats.tsx'; + +// Types +export interface TaskTreeCompletedProps { + readonly manager: TaskManager; +} + +// Component +export default function TaskTreeCompleted({ manager }: TaskTreeCompletedProps) { + // Config + const isVerbose = useIsVerbose(); + + // Extract all tasks + const flat = useMemo(() => { + return Array.from(flatTasks([...manager.tasks].sort(taskComparator), isVerbose)); + }, [manager, isVerbose]); + + // Render + return ( + <> + + { ({ task, level }) => ( + + + + ) } + + + + ); +} diff --git a/src/ui/task-tree-full-spinner.tsx b/src/ui/task-tree-full-spinner.tsx new file mode 100644 index 000000000..67ae26c1e --- /dev/null +++ b/src/ui/task-tree-full-spinner.tsx @@ -0,0 +1,32 @@ +import { TaskManager } from '@jujulego/tasks'; +import { Box, Text } from 'ink'; + +import { useFlatTaskTree } from '@/src/ui/hooks/useFlatTaskTree.ts'; +import TaskSpinner from '@/src/ui/task-spinner.tsx'; +import TaskTreeStats from '@/src/ui/task-tree-stats.tsx'; + +// Types +export interface TaskTreeFullSpinnerProps { + readonly manager: TaskManager; +} + +// Component +export default function TaskTreeFullSpinner({ manager }: TaskTreeFullSpinnerProps) { + const flat = useFlatTaskTree(manager); + + // Render + return ( + <> + + { flat.map(({ task, level }) => ( + + + + )) } + + + + + + ); +} diff --git a/src/ui/task-tree-scrollable-spinner.tsx b/src/ui/task-tree-scrollable-spinner.tsx new file mode 100644 index 000000000..f7a9a3cc8 --- /dev/null +++ b/src/ui/task-tree-scrollable-spinner.tsx @@ -0,0 +1,65 @@ +import { TaskManager } from '@jujulego/tasks'; +import { Box, Text, useInput } from 'ink'; +import { useEffect, useMemo, useState } from 'react'; + +import { useStdoutDimensions } from '@/src/ui/hooks/useStdoutDimensions.ts'; +import { useFlatTaskTree } from '@/src/ui/hooks/useFlatTaskTree.ts'; +import TaskSpinner from '@/src/ui/task-spinner.tsx'; +import TaskTreeStats from '@/src/ui/task-tree-stats.tsx'; + +// Types +export interface TaskTreeScrollableSpinnerProps { + readonly manager: TaskManager; +} + +// Component +export default function TaskTreeScrollableSpinner({ manager }: TaskTreeScrollableSpinnerProps) { + const { rows: termHeight } = useStdoutDimensions(); + + // Extract all tasks + const flat = useFlatTaskTree(manager); + + const maxHeight = useMemo( + () => Math.min(termHeight - 4, flat.length), + [termHeight, flat] + ); + + // Manage scroll + const [start, setStart] = useState(0); + + const slice = useMemo( + () => flat.slice(start, start + maxHeight), + [flat, start, maxHeight] + ); + + useEffect(() => { + if (start + maxHeight > flat.length) { + setStart(Math.max(flat.length - maxHeight, 0)); + } + }, [start, flat, maxHeight]); + + useInput((_, key) => { + if (key.upArrow) { + setStart((old) => Math.max(0, old - 1)); + } else if (key.downArrow) { + setStart((old) => Math.min(flat.length - maxHeight, old + 1)); + } + }); + + // Render + return ( + <> + + { slice.map(({ task, level }) => ( + + + + )) } + + + + { (maxHeight < flat.length) && ( - use keyboard arrows to scroll) } + + + ); +} diff --git a/src/ui/task-tree-spinner.tsx b/src/ui/task-tree-spinner.tsx new file mode 100644 index 000000000..b6f0ab260 --- /dev/null +++ b/src/ui/task-tree-spinner.tsx @@ -0,0 +1,21 @@ +import { TaskManager } from '@jujulego/tasks'; +import { useStdin } from 'ink'; + +import TaskTreeScrollableSpinner from '@/src/ui/task-tree-scrollable-spinner.tsx'; +import TaskTreeFullSpinner from '@/src/ui/task-tree-full-spinner.tsx'; + +// Types +export interface TaskTreeSpinnerProps { + readonly manager: TaskManager; +} + +// Component +export default function TaskTreeSpinner({ manager }: TaskTreeSpinnerProps) { + const stdin = useStdin(); + + if (stdin.isRawModeSupported) { + return ; + } else { + return ; + } +} diff --git a/src/ui/task-tree-stats.tsx b/src/ui/task-tree-stats.tsx new file mode 100644 index 000000000..1cfd6141f --- /dev/null +++ b/src/ui/task-tree-stats.tsx @@ -0,0 +1,69 @@ +import { TaskManager } from '@jujulego/tasks'; +import { Text } from 'ink'; +import Spinner from 'ink-spinner'; +import symbols from 'log-symbols'; +import { useLayoutEffect, useState } from 'react'; + +// Types +export interface TaskTreeStatsProps { + readonly manager: TaskManager; +} + +// Component +export default function TaskTreeStats({ manager }: TaskTreeStatsProps) { + // Follow stats + const [stats, setStats] = useState(() => { + const base = { running: 0, done: 0, failed: 0 }; + + for (const task of manager.tasks) { + switch (task.status) { + case 'starting': + case 'running': + base.running += task.weight; + break; + + case 'done': + base.done += task.weight; + break; + + case 'failed': + base.failed += task.weight; + break; + } + } + + return base; + }); + + useLayoutEffect(() => manager.on('started', (task) => { + setStats((old) => ({ + ...old, + running: old.running + task.weight + })); + }), [manager]); + + useLayoutEffect(() => manager.on('completed', (task) => { + setStats((old) => ({ + running: old.running - task.weight, + done: task.status === 'done' ? old.done + task.weight : old.done, + failed: task.status === 'failed' ? old.failed + task.weight : old.failed, + })); + }), [manager]); + + // Render + return ( + + { (stats.running !== 0) && ( + <> { stats.running } running + ) } + { (stats.running !== 0 && stats.done !== 0) && (<>, ) } + { (stats.done !== 0) && ( + { symbols.success } { stats.done } done + ) } + { (stats.running + stats.done !== 0 && stats.failed !== 0) && (<>, ) } + { (stats.failed !== 0) && ( + { symbols.error } { stats.failed } failed + ) } + + ); +} diff --git a/tests/commands/each.test.tsx b/tests/commands/each.test.tsx index 0b9220e0c..6cc428d2f 100644 --- a/tests/commands/each.test.tsx +++ b/tests/commands/each.test.tsx @@ -1,5 +1,4 @@ import { Logger } from '@jujulego/logger'; -import { type TaskManager } from '@jujulego/tasks'; import { cleanup, render } from 'ink-testing-library'; import symbols from 'log-symbols'; import { vi } from 'vitest'; @@ -12,7 +11,7 @@ import { TASK_MANAGER } from '@/src/tasks/task-manager.config.js'; import Layout from '@/src/ui/layout.js'; import { TestBed } from '@/tools/test-bed.js'; -import { TestScriptTask } from '@/tools/test-tasks.js'; +import { TestScriptTask, TestTaskManager } from '@/tools/test-tasks.js'; import { flushPromises, spyLogger, wrapInkTestApp } from '@/tools/utils.js'; import { ExitException } from '@/src/utils/exit.js'; import { ContextService } from '@/src/commons/context.service.js'; @@ -20,7 +19,7 @@ import { ContextService } from '@/src/commons/context.service.js'; // Setup let app: ReturnType; let command: CommandModule; -let manager: TaskManager; +let manager: TestTaskManager; let context: ContextService; let logger: Logger; @@ -39,13 +38,17 @@ beforeEach(async () => { // Project bed = new TestBed(); + logger = container.get(Logger); + context = container.get(ContextService); + + manager = new TestTaskManager({ logger }); + container.rebind(TASK_MANAGER).toConstantValue(manager); + app = render(); + Object.assign(app.stdin, { ref: () => this, unref: () => this }); container.rebind(INK_APP).toConstantValue(wrapInkTestApp(app)); command = await bed.prepareCommand(EachCommand); - manager = container.get(TASK_MANAGER); - context = container.get(ContextService); - logger = container.get(Logger); // Mocks vi.restoreAllMocks(); @@ -71,8 +74,8 @@ describe('jill each', () => { // Setup tasks const tasks = [ - new TestScriptTask(workspaces[0], 'cmd', [], { logger: spyLogger }), - new TestScriptTask(workspaces[1], 'cmd', [], { logger: spyLogger }), + new TestScriptTask(workspaces[0], 'cmd', [], { logger: spyLogger, weight: 1 }), + new TestScriptTask(workspaces[1], 'cmd', [], { logger: spyLogger, weight: 1 }), ]; vi.spyOn(manager, 'tasks', 'get').mockReturnValue(tasks); @@ -97,12 +100,14 @@ describe('jill each', () => { // should print task spinners expect(app.lastFrame()).toMatchLines([ expect.ignoreColor(/^. Run cmd in wks-1$/), - expect.ignoreColor(/^. Run cmd in wks-2$/), + expect.ignoreColor(/^. Run cmd in wks-2$/) ]); // complete tasks for (const task of tasks) { vi.spyOn(task, 'status', 'get').mockReturnValue('done'); + vi.spyOn(task, 'duration', 'get').mockReturnValue(100); + task.emit('status.done', { status: 'done', previous: 'running' }); task.emit('completed', { status: 'done', duration: 100 }); } @@ -113,6 +118,7 @@ describe('jill each', () => { expect(app.lastFrame()).toEqualLines([ expect.ignoreColor(`${symbols.success} Run cmd in wks-1 (took 100ms)`), expect.ignoreColor(`${symbols.success} Run cmd in wks-2 (took 100ms)`), + expect.ignoreColor(`${symbols.success} 2 done`), ]); }); diff --git a/tests/commands/exec.test.tsx b/tests/commands/exec.test.tsx index 568072e38..f8cc0cbce 100644 --- a/tests/commands/exec.test.tsx +++ b/tests/commands/exec.test.tsx @@ -1,4 +1,4 @@ -import { type TaskManager } from '@jujulego/tasks'; +import { Logger } from '@jujulego/logger'; import { cleanup, render } from 'ink-testing-library'; import symbols from 'log-symbols'; import yargs, { type CommandModule } from 'yargs'; @@ -13,14 +13,14 @@ import { TASK_MANAGER } from '@/src/tasks/task-manager.config.js'; import Layout from '@/src/ui/layout.js'; import { TestBed } from '@/tools/test-bed.js'; -import { TestCommandTask } from '@/tools/test-tasks.js'; +import { TestCommandTask, TestTaskManager } from '@/tools/test-tasks.js'; import { flushPromises, spyLogger, wrapInkTestApp } from '@/tools/utils.js'; // Setup let app: ReturnType; let command: CommandModule; let context: ContextService; -let manager: TaskManager; +let manager: TestTaskManager; let bed: TestBed; let wks: Workspace; @@ -36,14 +36,19 @@ beforeEach(async () => { bed = new TestBed(); wks = bed.addWorkspace('wks'); - task = new TestCommandTask(wks, 'cmd', [], { logger: spyLogger }); + task = new TestCommandTask(wks, 'cmd', [], { logger: spyLogger, weight: 1 }); + + const logger = container.get(Logger); + context = container.get(ContextService); + + manager = new TestTaskManager({ logger }); + container.rebind(TASK_MANAGER).toConstantValue(manager); app = render(); + Object.assign(app.stdin, { ref: () => this, unref: () => this }); container.rebind(INK_APP).toConstantValue(wrapInkTestApp(app)); command = await bed.prepareCommand(ExecCommand, wks); - context = container.get(ContextService); - manager = container.get(TASK_MANAGER); // Mocks vi.restoreAllMocks(); @@ -79,13 +84,18 @@ describe('jill exec', () => { // complete task vi.spyOn(task, 'status', 'get').mockReturnValue('done'); + vi.spyOn(task, 'duration', 'get').mockReturnValue(100); + task.emit('status.done', { status: 'done', previous: 'running' }); task.emit('completed', { status: 'done', duration: 100 }); await prom; // should print task completed - expect(app.lastFrame()).toEqual(expect.ignoreColor(`${symbols.success} cmd (took 100ms)`)); + expect(app.lastFrame()).toEqualLines([ + expect.ignoreColor(`${symbols.success} cmd (took 100ms)`), + expect.ignoreColor(`${symbols.success} 1 done`), + ]); }); it('should use given dependency selection mode', async () => { diff --git a/tests/commands/group.test.tsx b/tests/commands/group.test.tsx index d01614d4e..8ab67d3fb 100644 --- a/tests/commands/group.test.tsx +++ b/tests/commands/group.test.tsx @@ -1,4 +1,4 @@ -import { type TaskManager } from '@jujulego/tasks'; +import { Logger } from '@jujulego/logger'; import { cleanup, render } from 'ink-testing-library'; import symbols from 'log-symbols'; import yargs, { type CommandModule } from 'yargs'; @@ -14,14 +14,14 @@ import { TASK_MANAGER } from '@/src/tasks/task-manager.config.js'; import Layout from '@/src/ui/layout.js'; import { TestBed } from '@/tools/test-bed.js'; -import { TestParallelGroup, TestScriptTask } from '@/tools/test-tasks.js'; +import { TestParallelGroup, TestScriptTask, TestTaskManager } from '@/tools/test-tasks.js'; import { flushPromises, spyLogger, wrapInkTestApp } from '@/tools/utils.js'; // Setup let app: ReturnType; let command: CommandModule; let context: ContextService; -let manager: TaskManager; +let manager: TestTaskManager; let taskExpr: TaskExpressionService; let bed: TestBed; @@ -40,18 +40,22 @@ beforeEach(async () => { wks = bed.addWorkspace('wks'); task = new TestParallelGroup('Test group', {}, { logger: spyLogger }); - task.add(new TestScriptTask(wks, 'test1', [], { logger: spyLogger })); - task.add(new TestScriptTask(wks, 'test2', [], { logger: spyLogger })); + task.add(new TestScriptTask(wks, 'test1', [], { logger: spyLogger, weight: 1 })); + task.add(new TestScriptTask(wks, 'test2', [], { logger: spyLogger, weight: 1 })); + + const logger = container.get(Logger); + context = container.get(ContextService); + taskExpr = container.get(TaskExpressionService); + + manager = new TestTaskManager({ logger }); + container.rebind(TASK_MANAGER).toConstantValue(manager); app = render(); + Object.assign(app.stdin, { ref: () => this, unref: () => this }); container.rebind(INK_APP).toConstantValue(wrapInkTestApp(app)); command = await bed.prepareCommand(GroupCommand, wks); - context = container.get(ContextService); - manager = container.get(TASK_MANAGER); - taskExpr = container.get(TaskExpressionService); - // Mocks vi.restoreAllMocks(); @@ -98,14 +102,20 @@ describe('jill group', () => { for (const child of task.tasks as TestScriptTask[]) { vi.spyOn(child, 'status', 'get').mockReturnValue('done'); + vi.spyOn(child, 'duration', 'get').mockReturnValue(100); child.emit('status.done', { status: 'done', previous: 'running' }); child.emit('completed', { status: 'done', duration: 100 }); } + vi.spyOn(task, 'status', 'get').mockReturnValue('done'); + vi.spyOn(task, 'duration', 'get').mockReturnValue(100); + task.emit('status.done', { status: 'done', previous: 'running' }); task.emit('completed', { status: 'done', duration: 100 }); + vi.spyOn(manager, 'tasks', 'get').mockReturnValue([task, ...task.tasks]); + await prom; // Should print all tasks @@ -113,6 +123,7 @@ describe('jill group', () => { expect.ignoreColor(`${symbols.success} Test group (took 100ms)`), expect.ignoreColor(` ${symbols.success} Run test1 in wks (took 100ms)`), expect.ignoreColor(` ${symbols.success} Run test2 in wks (took 100ms)`), + expect.ignoreColor(`${symbols.success} 2 done`), ]); }); diff --git a/tests/commands/run.test.tsx b/tests/commands/run.test.tsx index 1019d7c09..bde05ef3d 100644 --- a/tests/commands/run.test.tsx +++ b/tests/commands/run.test.tsx @@ -1,4 +1,4 @@ -import { type TaskManager } from '@jujulego/tasks'; +import { Logger } from '@jujulego/logger'; import { cleanup, render } from 'ink-testing-library'; import symbols from 'log-symbols'; import yargs, { type CommandModule } from 'yargs'; @@ -14,14 +14,14 @@ import Layout from '@/src/ui/layout.js'; import { ExitException } from '@/src/utils/exit.js'; import { TestBed } from '@/tools/test-bed.js'; -import { TestScriptTask } from '@/tools/test-tasks.js'; +import { TestScriptTask, TestTaskManager } from '@/tools/test-tasks.js'; import { flushPromises, spyLogger, wrapInkTestApp } from '@/tools/utils.js'; // Setup let app: ReturnType; let command: CommandModule; let context: ContextService; -let manager: TaskManager; +let manager: TestTaskManager; let bed: TestBed; let wks: Workspace; @@ -37,14 +37,19 @@ beforeEach(async () => { bed = new TestBed(); wks = bed.addWorkspace('wks'); - task = new TestScriptTask(wks, 'cmd', [], { logger: spyLogger }); + task = new TestScriptTask(wks, 'cmd', [], { logger: spyLogger, weight: 1 }); + + const logger = container.get(Logger); + context = container.get(ContextService); + + manager = new TestTaskManager({ logger }); + container.rebind(TASK_MANAGER).toConstantValue(manager); app = render(); + Object.assign(app.stdin, { ref: () => this, unref: () => this }); container.rebind(INK_APP).toConstantValue(wrapInkTestApp(app)); command = await bed.prepareCommand(RunCommand, wks); - context = container.get(ContextService); - manager = container.get(TASK_MANAGER); // Mocks vi.restoreAllMocks(); @@ -80,13 +85,18 @@ describe('jill run', () => { // complete task vi.spyOn(task, 'status', 'get').mockReturnValue('done'); + vi.spyOn(task, 'duration', 'get').mockReturnValue(100); + task.emit('status.done', { status: 'done', previous: 'running' }); task.emit('completed', { status: 'done', duration: 100 }); await prom; // should print task completed - expect(app.lastFrame()).toEqual(expect.ignoreColor(`${symbols.success} Run cmd in wks (took 100ms)`)); + expect(app.lastFrame()).toEqualLines([ + expect.ignoreColor(`${symbols.success} Run cmd in wks (took 100ms)`), + expect.ignoreColor(`${symbols.success} 1 done`), + ]); }); it('should use given dependency selection mode', async () => { diff --git a/tests/modules/task-command.test.tsx b/tests/modules/task-command.test.tsx index 3a3d522c4..003e197ec 100644 --- a/tests/modules/task-command.test.tsx +++ b/tests/modules/task-command.test.tsx @@ -44,9 +44,10 @@ beforeEach(async () => { bed = new TestBed(); wks = bed.addWorkspace('wks'); - task = new TestScriptTask(wks, 'cmd', [], { logger: spyLogger }); + task = new TestScriptTask(wks, 'cmd', [], { logger: spyLogger, weight: 1 }); app = render(); + Object.assign(app.stdin, { ref: () => this, unref: () => this }); container.rebind(INK_APP).toConstantValue(wrapInkTestApp(app)); command = container.resolve(TestTaskCommand); @@ -84,13 +85,18 @@ describe('TaskCommand', () => { // complete task vi.spyOn(task, 'status', 'get').mockReturnValue('done'); + vi.spyOn(task, 'duration', 'get').mockReturnValue(100); + task.emit('status.done', { status: 'done', previous: 'running' }); task.emit('completed', { status: 'done', duration: 100 }); await prom; // should print task completed - expect(app.lastFrame()).toEqual(expect.ignoreColor(`${symbols.success} Run cmd in wks (took 100ms)`)); + expect(app.lastFrame()).toEqualLines([ + expect.ignoreColor(`${symbols.success} Run cmd in wks (took 100ms)`), + expect.ignoreColor(`${symbols.success} 1 done`), + ]); }); it('should exit 1 if a task fails', async () => { @@ -100,13 +106,18 @@ describe('TaskCommand', () => { // complete task vi.spyOn(task, 'status', 'get').mockReturnValue('failed'); + vi.spyOn(task, 'duration', 'get').mockReturnValue(100); + task.emit('status.failed', { status: 'failed', previous: 'running' }); task.emit('completed', { status: 'failed', duration: 100 }); await expect(prom).rejects.toEqual(new ExitException(1)); // should print task failed - expect(app.lastFrame()).toEqual(expect.ignoreColor(`${symbols.error} Run cmd in wks (took 100ms)`)); + expect(app.lastFrame()).toEqualLines([ + expect.ignoreColor(`${symbols.error} Run cmd in wks (took 100ms)`), + expect.ignoreColor(`${symbols.error} 1 failed`) + ]); }); it('should log and exit if no task were yielded', async () => { diff --git a/tests/ui/group-task-spinner.test.tsx b/tests/ui/group-task-spinner.test.tsx deleted file mode 100644 index 7ec6b1223..000000000 --- a/tests/ui/group-task-spinner.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { ParallelGroup, SpawnTask } from '@jujulego/tasks'; -import { render, cleanup } from 'ink-testing-library'; -import symbols from 'log-symbols'; -import { vi } from 'vitest'; - -import GroupTaskSpinner from '@/src/ui/group-task-spinner.js'; -import { flushPromises, spyLogger } from '@/tools/utils.js'; - -// Setup -let taskA: SpawnTask; -let taskB: SpawnTask; -let taskC: SpawnTask; - -let group: ParallelGroup; - -beforeEach(() => { - taskA = new SpawnTask('cmd-a', [], {}, { logger: spyLogger }); - taskB = new SpawnTask('cmd-b', [], {}, { logger: spyLogger }); - taskC = new SpawnTask('cmd-c', [], {}, { logger: spyLogger }); - - vi.spyOn(taskA, 'status', 'get').mockReturnValue('done'); - vi.spyOn(taskB, 'status', 'get').mockReturnValue('done'); - vi.spyOn(taskC, 'status', 'get').mockReturnValue('done'); - - group = new ParallelGroup('Test group', {}, { logger: spyLogger }); - group.add(taskA); - group.add(taskB); - - vi.spyOn(group, 'status', 'get').mockReturnValue('done'); -}); - -afterEach(() => { - cleanup(); -}); - -// Tests -describe('', () => { - it('should print group and it\'s dependencies', async () => { - const { lastFrame } = render(); - - expect(lastFrame()).toEqualLines([ - expect.ignoreColor(`${symbols.success} Test group (took 0ms)`), - expect.ignoreColor(` ${symbols.success} cmd-a (took 0ms)`), - expect.ignoreColor(` ${symbols.success} cmd-b (took 0ms)`), - ]); - - // Latter added dependency - group.add(taskC); - await flushPromises(); - - expect(lastFrame()).toEqualLines([ - expect.ignoreColor(`${symbols.success} Test group (took 0ms)`), - expect.ignoreColor(` ${symbols.success} cmd-a (took 0ms)`), - expect.ignoreColor(` ${symbols.success} cmd-b (took 0ms)`), - expect.ignoreColor(` ${symbols.success} cmd-c (took 0ms)`), - ]); - }); - - it('should print group children', () => { - // Add a "root" group - const root = new ParallelGroup('Root', {}); - root.add(group); - root.add(taskC); - - vi.spyOn(root, 'status', 'get').mockReturnValue('done'); - - const { lastFrame } = render(); - - expect(lastFrame()).toEqualLines([ - expect.ignoreColor(`${symbols.success} Root (took 0ms)`), - expect.ignoreColor(` ${symbols.success} Test group (took 0ms)`), - expect.ignoreColor(` ${symbols.success} cmd-a (took 0ms)`), - expect.ignoreColor(` ${symbols.success} cmd-b (took 0ms)`), - expect.ignoreColor(` ${symbols.success} cmd-c (took 0ms)`), - ]); - }); -}); diff --git a/tests/ui/task-manager-spinner.test.tsx b/tests/ui/task-manager-spinner.test.tsx deleted file mode 100644 index aee018c58..000000000 --- a/tests/ui/task-manager-spinner.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { SpawnTask, TaskManager } from '@jujulego/tasks'; -import { render, cleanup } from 'ink-testing-library'; -import symbols from 'log-symbols'; -import { vi } from 'vitest'; - -import TaskManagerSpinner from '@/src/ui/task-manager-spinner.js'; -import { spyLogger } from '@/tools/utils.js'; - -// Setup -let taskA: SpawnTask; -let taskB: SpawnTask; -let taskC: SpawnTask; - -beforeEach(() => { - taskA = new SpawnTask('cmd-a', [], {}, { logger: spyLogger }); - taskB = new SpawnTask('cmd-b', [], {}, { logger: spyLogger }); - taskC = new SpawnTask('cmd-c', [], {}, { logger: spyLogger }); - - vi.spyOn(taskA, 'status', 'get').mockReturnValue('done'); - vi.spyOn(taskB, 'status', 'get').mockReturnValue('done'); - vi.spyOn(taskC, 'status', 'get').mockReturnValue('done'); -}); - -afterEach(() => { - cleanup(); -}); - -// Tests -describe('', () => { - it('should print every managed tasks', () => { - const manager = new TaskManager({ jobs: 0, logger: spyLogger }); - manager.add(taskA); - manager.add(taskB); - - const { lastFrame } = render(); - - expect(lastFrame()).toEqualLines([ - expect.ignoreColor(`${symbols.success} cmd-a (took 0ms)`), - expect.ignoreColor(`${symbols.success} cmd-b (took 0ms)`), - ]); - }); - - it('should print added tasks while running', async () => { - const manager = new TaskManager({ jobs: 0, logger: spyLogger }); - manager.add(taskA); - manager.add(taskB); - - const { lastFrame } = render(); - - manager.add(taskC); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(lastFrame()).toEqualLines([ - expect.ignoreColor(`${symbols.success} cmd-a (took 0ms)`), - expect.ignoreColor(`${symbols.success} cmd-b (took 0ms)`), - expect.ignoreColor(`${symbols.success} cmd-c (took 0ms)`), - ]); - }); -}); diff --git a/tools/test-bed.ts b/tools/test-bed.ts index f8743cc16..944c0889d 100644 --- a/tools/test-bed.ts +++ b/tools/test-bed.ts @@ -1,9 +1,9 @@ -import { type Package } from 'normalize-package-data'; import { ContainerModule } from 'inversify'; -import { type CommandModule } from 'yargs'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { type Package } from 'normalize-package-data'; +import { type CommandModule } from 'yargs'; import { ContextService } from '@/src/commons/context.service.ts'; import { CONFIG } from '@/src/config/config-loader.ts'; diff --git a/tools/test-tasks.ts b/tools/test-tasks.ts index 0e1357d3c..27f9be7bc 100644 --- a/tools/test-tasks.ts +++ b/tools/test-tasks.ts @@ -1,9 +1,14 @@ -import { ParallelGroup, SpawnTask, type TaskContext } from '@jujulego/tasks'; +import { ParallelGroup, SpawnTask, type TaskContext, TaskManager } from '@jujulego/tasks'; import { CommandTask } from '@/src/tasks/command-task.js'; import { ScriptTask } from '@/src/tasks/script-task.js'; // Classes +export class TestTaskManager extends TaskManager { + // Methods + readonly emit = this._events.emit; +} + export class TestParallelGroup extends ParallelGroup { // Methods readonly emit = this._groupEvents.emit; diff --git a/yarn.lock b/yarn.lock index 9f50b4a0d..bfa424651 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1794,9 +1794,9 @@ __metadata: linkType: hard "cli-spinners@npm:^2.7.0": - version: 2.9.1 - resolution: "cli-spinners@npm:2.9.1" - checksum: 1780618be58309c469205bc315db697934bac68bce78cd5dfd46248e507a533172d623c7348ecfd904734f597ce0a4e5538684843d2cfb7af485d4466699940c + version: 2.9.2 + resolution: "cli-spinners@npm:2.9.2" + checksum: 1bd588289b28432e4676cb5d40505cfe3e53f2e4e10fbe05c8a710a154d6fe0ce7836844b00d6858f740f2ffe67cdc36e0fce9c7b6a8430e80e6388d5aa4956c languageName: node linkType: hard @@ -5531,7 +5531,7 @@ __metadata: languageName: node linkType: hard -"vite@npm:5.0.0": +"vite@npm:5.0.0, vite@npm:^3.0.0 || ^4.0.0 || ^5.0.0-0, vite@npm:^3.1.0 || ^4.0.0 || ^5.0.0-0": version: 5.0.0 resolution: "vite@npm:5.0.0" dependencies: @@ -5571,46 +5571,6 @@ __metadata: languageName: node linkType: hard -"vite@npm:^3.0.0 || ^4.0.0 || ^5.0.0-0, vite@npm:^3.1.0 || ^4.0.0 || ^5.0.0-0": - version: 5.0.0-beta.18 - resolution: "vite@npm:5.0.0-beta.18" - dependencies: - esbuild: ^0.19.3 - fsevents: ~2.3.3 - postcss: ^8.4.31 - rollup: ^4.2.0 - peerDependencies: - "@types/node": ^18.0.0 || >=20.0.0 - less: "*" - lightningcss: ^1.21.0 - sass: "*" - stylus: "*" - sugarss: "*" - terser: ^5.4.0 - dependenciesMeta: - fsevents: - optional: true - peerDependenciesMeta: - "@types/node": - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - bin: - vite: bin/vite.js - checksum: 9481814791af3932266fa05112779d51d2f330b964d3472ca3373861b0a3c33169c55b5bc5fa82752f7d046ff320dfaaa0db77f2b78ec8cbe3dd6310eb7e8d69 - languageName: node - linkType: hard - "vitest@npm:0.34.6": version: 0.34.6 resolution: "vitest@npm:0.34.6"