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

chore(listr2mock): Improve types and make it more capable #11352

Merged
merged 4 commits into from
Aug 23, 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
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
Loading