Skip to content

Commit

Permalink
chore(listr2mock): Improve types and make it more capable (#11352)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tobbe authored Aug 23, 2024
1 parent a13b44f commit 6618bd5
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 242 deletions.
190 changes: 100 additions & 90 deletions packages/cli/src/__tests__/Listr2Mock.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,52 @@
import type Enquirer from 'enquirer'
import type * as Listr from 'listr2'
import type { vi } from 'vitest'

type Ctx = Record<string, any>

type TListrTask = Listr.ListrTask<Ctx, typeof Listr.ListrRenderer>
type EnquirerPromptOptions = Parameters<Enquirer['prompt']>[0]
type Function = { length: number; name: string }
type PlainPromptOptions = ReturnType<Extract<EnquirerPromptOptions, Function>>
type ListrPromptOptions = Parameters<
Listr.ListrTaskWrapper<Ctx, typeof Listr.ListrRenderer>['prompt']
>[0]

function isNotFunctionPromptOptions(
opts: EnquirerPromptOptions,
): opts is PlainPromptOptions | PlainPromptOptions[] {
return (
typeof opts !== 'function' &&
(Array.isArray(opts) ? opts.every((o) => typeof o !== 'function') : true)
function isSupportedOptionsType(
options: unknown,
): options is PlainPromptOptions | PlainPromptOptions[] {
const optionsArray = Array.isArray(options) ? options : [options]

return optionsArray.every(
(option) =>
// message is the only required property in `BasePromptOptions` in Listr2
typeof option !== 'function' && 'message' in option,
)
}

class Listr2TaskWrapper {
task: Listr.ListrTaskObject<Ctx, typeof Listr.ListrRenderer>
task: Listr.ListrTask<Ctx, typeof Listr.ListrRenderer>
promptOutput: string
prompt: <T = any>(options: ListrPromptOptions) => Promise<T>
skip: (msg: string) => void

// This is part of Listr.TaskWrapper, but we don't need it
// private options: Record<PropertyKey, any> | undefined
listrOptions?: Listr.ListrOptions | undefined

constructor({
task,
prompt,
skip,
// options,
options,
}: {
task: Listr.ListrTaskObject<Ctx, typeof Listr.ListrRenderer>
prompt: <T = any>(options: ListrPromptOptions) => Promise<T>
skip: (msg: string) => void
task: Listr.ListrTask<Ctx, typeof Listr.ListrRenderer>
options?: Record<PropertyKey, any> | undefined
}) {
this.task = task
this.prompt = prompt
this.skip = skip
// this.options = options

this.listrOptions = options
this.promptOutput = ''
}

async run() {}
report() {}
cancelPrompt() {}
readonly output = ''

stdout() {
return process.stdout
}
Expand All @@ -62,42 +58,111 @@ class Listr2TaskWrapper {
this.task.title = title
}

get output(): string | undefined {
return this.task.output
}

newListr(tasks: TListrTask[], options?: Listr.ListrOptions) {
newListr(
tasks: Listr.ListrTask<Ctx, typeof Listr.ListrRenderer>[],
options?: Listr.ListrOptions,
) {
return new Listr2Mock(tasks, options)
}

isRetrying() {
return false
}

run(ctx: Ctx, task: Listr2TaskWrapper) {
// TODO: fix this by removing the type casts.
// The reason we have to do this is because of private fields in
// Listr.ListrTaskWrapper
return this.task.task(
ctx,
task as unknown as Listr.ListrTaskWrapper<
Ctx,
typeof Listr.ListrRenderer
>,
)
}

async prompt<T extends object = any>(options: ListrPromptOptions) {
const enquirer = Listr2Mock.mockPrompt
? { prompt: Listr2Mock.mockPrompt }
: this.listrOptions?.injectWrapper?.enquirer

if (!enquirer) {
throw new Error('Enquirer instance not available')
}

if (!isSupportedOptionsType(options)) {
console.error('Unsupported prompt options', options)
throw new Error('Unsupported prompt options type')
}

const enquirerOptions = !Array.isArray(options)
? [{ ...options, name: 'default' }]
: options

if (enquirerOptions.length === 1) {
enquirerOptions[0].name = 'default'
}

const response = await enquirer.prompt(enquirerOptions)

if (enquirerOptions.length === 1) {
if (typeof response !== 'object') {
throw new Error(
'Expected an object response from prompt().\n' +
'Make sure you\'re returning `{ default: "value" }` if you\'re ' +
'mocking the prompt return value',
)
}

if ('default' in response) {
// The type cast here isn't great. But Listr2 itself also type cast
// the response (but they cast it to `any`)
// https://github.com/listr2/listr2/blob/b4f544ebce9582f56b2b42fdbe834d70678ce966/packages/prompt-adapter-enquirer/src/prompt.ts#L74
return response.default as T
}
}

return response
}

skip(msg: string) {
const taskTitle = typeof this.task.title === 'string' ? this.task.title : ''
Listr2Mock.skippedTaskTitles.push(msg || taskTitle)
}
}

export class Listr2Mock {
static executedTaskTitles: string[]
static skippedTaskTitles: string[]
static mockPrompt:
| Parameters<
typeof vi.fn<
(args: EnquirerPromptOptions) => Promise<object | object[]>
>
>[0]
| undefined

ctx: Ctx
tasks: TListrTask[]
listrOptions?: Listr.ListrOptions | undefined
tasks: Listr2TaskWrapper[]

constructor(
tasks: TListrTask[],
listrOptions?: Listr.ListrOptions | undefined,
tasks: Listr.ListrTask<Ctx, typeof Listr.ListrRenderer>[],
options?: Listr.ListrOptions | undefined,
) {
this.ctx = {}
this.tasks = tasks
this.listrOptions = listrOptions
this.tasks = tasks.map((task) => new Listr2TaskWrapper({ task, options }))
}

async run() {
Listr2Mock.executedTaskTitles = []
Listr2Mock.skippedTaskTitles = []

for (const task of this.tasks) {
const skip = typeof task.skip === 'function' ? task.skip : () => task.skip
const skip =
typeof task.task.skip === 'function'
? task.task.skip
: () => task.task.skip

const skipReturnValue = skip(this.ctx)

Expand All @@ -112,66 +177,11 @@ export class Listr2Mock {
continue
}

const augmentedTask = new Listr2TaskWrapper({
// @ts-expect-error - TODO: Fix the types here
task: task.task,
prompt: async <T = any>(options: ListrPromptOptions) => {
const enquirer = this.listrOptions?.injectWrapper?.enquirer as
| Enquirer<T extends object ? T : never>
| undefined

if (!enquirer) {
throw new Error('Enquirer instance not available')
}

// TODO: Fix the types here
if (!isNotFunctionPromptOptions(options as EnquirerPromptOptions)) {
throw new Error(
'Function prompt options are not supported by the mock',
)
}

const enquirerOptions = !Array.isArray(options)
? [{ ...options, name: 'default' }]
: options

if (enquirerOptions.length === 1) {
enquirerOptions[0].name = 'default'
}

const response = await enquirer.prompt(
// @ts-expect-error - the type should be EnquirerPromptOptions
enquirerOptions,
)

if (enquirerOptions.length === 1 && 'default' in response) {
return response.default as T
}

return response
},
skip: (msg: string) => {
const taskTitle = typeof task.title === 'string' ? task.title : ''
Listr2Mock.skippedTaskTitles.push(msg || taskTitle)
},
})

await task.task(
this.ctx,
// TODO: fix this by removing the type casts.
// The reason we have to do this is because of private fields in
// our own Listr2TaskWrapper and Listr.ListrTaskWrapper
augmentedTask as unknown as Listr.ListrTaskWrapper<
Ctx,
typeof Listr.ListrRenderer
>,
)
await task.run(this.ctx, task)

// storing the title after running the task in case the task
// modifies its own title
if (typeof augmentedTask.title === 'string') {
Listr2Mock.executedTaskTitles.push(augmentedTask.title)
} else if (typeof task.title === 'string') {
if (typeof task.title === 'string') {
Listr2Mock.executedTaskTitles.push(task.title)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,5 @@
let mockExecutedTaskTitles: string[] = []
let mockSkippedTaskTitles: string[] = []

vi.mock('fs', async () => {
const memfs = await import('memfs')
return {
...memfs.fs,
default: {
...memfs.fs,
},
}
})
vi.mock('node:fs', async () => {
const memfs = await import('memfs')
return {
...memfs.fs,
default: {
...memfs.fs,
},
}
})
vi.mock('fs', async () => ({ ...memfsFs, default: { ...memfsFs } }))
vi.mock('node:fs', async () => ({ ...memfsFs, default: { ...memfsFs } }))
vi.mock('execa')
// The jscodeshift parts are tested by another test
vi.mock('../../../../../../lib/runTransform', () => {
Expand All @@ -29,37 +10,16 @@ vi.mock('../../../../../../lib/runTransform', () => {
}
})

vi.mock('listr2', () => {
return {
// Return a constructor function, since we're calling `new` on Listr
Listr: vi.fn().mockImplementation((tasks: any[]) => {
return {
run: async () => {
mockExecutedTaskTitles = []
mockSkippedTaskTitles = []

for (const task of tasks) {
const skip =
typeof task.skip === 'function' ? task.skip : () => task.skip

if (skip()) {
mockSkippedTaskTitles.push(task.title)
} else {
mockExecutedTaskTitles.push(task.title)
await task.task()
}
}
},
}
}),
}
})

import { vol } from 'memfs'
import { vol, fs as memfsFs } from 'memfs'
import { vi, beforeAll, afterAll, test, expect } from 'vitest'

import { Listr2Mock } from '../../../../../../__tests__/Listr2Mock'
import { handler } from '../fragmentsHandler'

vi.mock('listr2', () => ({
Listr: Listr2Mock,
}))

// Set up RWJS_CWD
let original_RWJS_CWD: string | undefined
const FIXTURE_PATH = '/redwood-app'
Expand All @@ -80,7 +40,7 @@ test('all tasks are being called', async () => {

await handler({ force: false })

expect(mockExecutedTaskTitles).toMatchInlineSnapshot(`
expect(Listr2Mock.executedTaskTitles).toMatchInlineSnapshot(`
[
"Update Redwood Project Configuration to enable GraphQL Fragments",
"Generate possibleTypes.ts",
Expand All @@ -101,17 +61,17 @@ test('redwood.toml update is skipped if fragments are already enabled', async ()

await handler({ force: false })

expect(mockExecutedTaskTitles).toMatchInlineSnapshot(`
expect(Listr2Mock.executedTaskTitles).toMatchInlineSnapshot(`
[
"Generate possibleTypes.ts",
"Import possibleTypes in App.tsx",
"Add possibleTypes to the GraphQL cache config",
]
`)

expect(mockSkippedTaskTitles).toMatchInlineSnapshot(`
expect(Listr2Mock.skippedTaskTitles).toMatchInlineSnapshot(`
[
"Update Redwood Project Configuration to enable GraphQL Fragments",
"GraphQL Fragments are already enabled.",
]
`)
})
Expand All @@ -126,9 +86,9 @@ fragments = true

await handler({ force: false })

expect(mockSkippedTaskTitles).toMatchInlineSnapshot(`
expect(Listr2Mock.skippedTaskTitles).toMatchInlineSnapshot(`
[
"Update Redwood Project Configuration to enable GraphQL Fragments",
"GraphQL Fragments are already enabled.",
]
`)

Expand Down
Loading

0 comments on commit 6618bd5

Please sign in to comment.