Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement .skipTo and refactor .stop #12

Merged
merged 2 commits into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 83 additions & 11 deletions src/multi-stage-output.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
StagesProps,
} from './components/stages.js'
import {Design, RequiredDesign, constructDesignParams} from './design.js'
import {StageTracker} from './stage-tracker.js'
import {StageStatus, StageTracker} from './stage-tracker.js'
import {readableTime} from './utils.js'

// Taken from https://github.com/sindresorhus/is-in-ci
Expand Down Expand Up @@ -281,6 +281,24 @@ export class MultiStageOutput<T extends Record<string, unknown>> implements Disp
}
}

/**
* Stop multi-stage output from running with a failed status.
*/
public error(): void {
this.stop('failed')
}

/**
* Go to a stage, marking any stages in between the current stage and the provided stage as completed.
*
* If the stage does not exist or is before the current stage, nothing will happen.
*
* If the stage is the same as the current stage, the data will be updated.
*
* @param stage Stage to go to
* @param data - Optional data to pass to the next stage.
* @returns void
*/
public goto(stage: string, data?: Partial<T>): void {
if (this.stopped) return

Expand All @@ -290,29 +308,73 @@ export class MultiStageOutput<T extends Record<string, unknown>> implements Disp
// prevent going to a previous stage
if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current ?? this.stages[0])) return

this.update(stage, data)
this.update(stage, 'completed', data)
}

/**
* Moves to the next stage of the process.
*
* @param data - Optional data to pass to the next stage.
* @returns void
*/
public next(data?: Partial<T>): void {
if (this.stopped) return

const nextStageIndex = this.stages.indexOf(this.stageTracker.current ?? this.stages[0]) + 1
if (nextStageIndex < this.stages.length) {
this.update(this.stages[nextStageIndex], data)
this.update(this.stages[nextStageIndex], 'completed', data)
}
}

public stop(error?: Error): void {
/**
* Go to a stage, marking any stages in between the current stage and the provided stage as skipped.
*
* If the stage does not exist or is before the current stage, nothing will happen.
*
* If the stage is the same as the current stage, the data will be updated.
*
* @param stage Stage to go to
* @param data - Optional data to pass to the next stage.
* @returns void
*/
public skipTo(stage: string, data?: Partial<T>): void {
if (this.stopped) return

// ignore non-existent stages
if (!this.stages.includes(stage)) return

// prevent going to a previous stage
if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current ?? this.stages[0])) return

this.update(stage, 'skipped', data)
}

/**
* Stop multi-stage output from running.
*
* The stage currently running will be changed to the provided `finalStatus`.
*
* @param finalStatus - The status to set the current stage to.
* @returns void
*/
public stop(finalStatus: StageStatus = 'completed'): void {
if (this.stopped) return
this.stopped = true

this.stageTracker.refresh(this.stageTracker.current ?? this.stages[0], {hasError: Boolean(error), isStopping: true})
this.stageTracker.refresh(this.stageTracker.current ?? this.stages[0], {
finalStatus,
})

if (isInCi) {
this.ciInstance?.stop(this.stageTracker)
return
}

// The underlying components expect an Error, although they don't currently use anything on the error - they check if it exists.
// Instead of refactoring the components to take a boolean, we pass in a placeholder Error,
// which, gives us the flexibility in the future to pass in an actual Error if we want
const error = finalStatus === 'failed' ? new Error('Error') : undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, good call


const stagesInput = {...this.generateStagesInput(), ...(error ? {error} : {})}

this.inkInstance?.rerender(<Stages {...stagesInput} />)
Expand All @@ -323,11 +385,17 @@ export class MultiStageOutput<T extends Record<string, unknown>> implements Disp
this.inkInstance?.unmount()
}

/**
* Updates the data of the component.
*
* @param data - The partial data object to update the component's data with.
* @returns void
*/
public updateData(data: Partial<T>): void {
if (this.stopped) return
this.data = {...this.data, ...data} as T

this.update(this.stageTracker.current ?? this.stages[0], data)
this.rerender()
}

private formatKeyValuePairs(infoBlock: InfoBlock<T> | StageInfoBlock<T> | undefined): FormattedKeyValue[] {
Expand Down Expand Up @@ -361,15 +429,19 @@ export class MultiStageOutput<T extends Record<string, unknown>> implements Disp
}
}

private update(stage: string, data?: Partial<T>): void {
this.data = {...this.data, ...data} as Partial<T>

this.stageTracker.refresh(stage)

private rerender(): void {
if (isInCi) {
this.ciInstance?.update(this.stageTracker, this.data)
} else {
this.inkInstance?.rerender(<Stages {...this.generateStagesInput()} />)
}
}

private update(stage: string, bypassStatus: StageStatus, data?: Partial<T>): void {
this.data = {...this.data, ...data} as Partial<T>

this.stageTracker.refresh(stage, {bypassStatus})

this.rerender()
}
}
22 changes: 8 additions & 14 deletions src/stage-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,16 @@ export class StageTracker {
return this.map.get(stage)
}

public refresh(nextStage: string, opts?: {hasError?: boolean; isStopping?: boolean}): void {
public refresh(nextStage: string, opts?: {finalStatus?: StageStatus; bypassStatus?: StageStatus}): void {
const stages = [...this.map.keys()]

for (const stage of stages) {
if (this.map.get(stage) === 'skipped') continue
if (this.map.get(stage) === 'failed') continue

// .stop() was called with an error => set the stage to failed
if (nextStage === stage && opts?.hasError) {
this.set(stage, 'failed')
this.stopMarker(stage)
continue
}

// .stop() was called without an error => set the stage to completed
if (nextStage === stage && opts?.isStopping) {
this.set(stage, 'completed')
// .stop() was called with a finalStatus
if (nextStage === stage && opts?.finalStatus) {
this.set(stage, opts.finalStatus)
this.stopMarker(stage)
continue
}
Expand All @@ -50,13 +44,13 @@ export class StageTracker {
continue
}

// any stage before the current stage should be marked as skipped if it's still pending
// any pending stage before the current stage should be marked using opts.bypassStatus
if (stages.indexOf(stage) < stages.indexOf(nextStage) && this.map.get(stage) === 'pending') {
this.set(stage, 'skipped')
this.set(stage, opts?.bypassStatus ?? 'completed')
continue
}

// any stage before the current stage should be as completed (if it hasn't been marked as skipped or failed yet)
// any stage before the current stage should be marked as completed (if it hasn't been marked as skipped or failed yet)
if (stages.indexOf(nextStage) > stages.indexOf(stage)) {
this.set(stage, 'completed')
this.stopMarker(stage)
Expand Down
24 changes: 22 additions & 2 deletions test/stage-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,33 @@ describe('StageTracker', () => {

it("should set the current stage to error when there's an error", () => {
const tracker = new StageTracker(['one', 'two', 'three'])
tracker.refresh('two', {hasError: true})
tracker.refresh('two', {finalStatus: 'failed'})
expect(tracker.get('two')).to.equal('failed')
})

it('should set the current stage to completed when stopping', () => {
const tracker = new StageTracker(['one', 'two', 'three'])
tracker.refresh('two', {isStopping: true})
tracker.refresh('two', {finalStatus: 'completed'})
expect(tracker.get('two')).to.equal('completed')
})

it('should mark bypassed steps as completed', () => {
const tracker = new StageTracker(['one', 'two', 'three'])
tracker.refresh('three', {bypassStatus: 'completed'})
expect(tracker.get('two')).to.equal('completed')
})

it('should mark bypassed steps as skipped', () => {
const tracker = new StageTracker(['one', 'two', 'three'])
tracker.refresh('three', {bypassStatus: 'skipped'})
expect(tracker.get('two')).to.equal('skipped')
})

it('should mark previous current step as completed', () => {
const tracker = new StageTracker(['one', 'two', 'three'])
tracker.refresh('one')
tracker.refresh('two')
expect(tracker.get('one')).to.equal('completed')
expect(tracker.get('two')).to.equal('current')
})
})