diff --git a/packages/cli/src/__tests__/Listr2Mock.ts b/packages/cli/src/__tests__/Listr2Mock.ts new file mode 100644 index 000000000000..9c6a3be90703 --- /dev/null +++ b/packages/cli/src/__tests__/Listr2Mock.ts @@ -0,0 +1,179 @@ +import type Enquirer from 'enquirer' +import type * as Listr from 'listr2' + +type Ctx = Record + +type TListrTask = Listr.ListrTask +type EnquirerPromptOptions = Parameters[0] +type Function = { length: number; name: string } +type PlainPromptOptions = ReturnType> +type ListrPromptOptions = Parameters< + Listr.ListrTaskWrapper['prompt'] +>[0] + +function isNotFunctionPromptOptions( + opts: EnquirerPromptOptions, +): opts is PlainPromptOptions | PlainPromptOptions[] { + return ( + typeof opts !== 'function' && + (Array.isArray(opts) ? opts.every((o) => typeof o !== 'function') : true) + ) +} + +class Listr2TaskWrapper { + task: Listr.ListrTaskObject + promptOutput: string + prompt: (options: ListrPromptOptions) => Promise + skip: (msg: string) => void + + // This is part of Listr.TaskWrapper, but we don't need it + // private options: Record | undefined + + constructor({ + task, + prompt, + skip, + // options, + }: { + task: Listr.ListrTaskObject + prompt: (options: ListrPromptOptions) => Promise + skip: (msg: string) => void + options?: Record | undefined + }) { + this.task = task + this.prompt = prompt + this.skip = skip + // this.options = options + + this.promptOutput = '' + } + + async run() {} + report() {} + cancelPrompt() {} + stdout() { + return process.stdout + } + + get title(): string | any[] | undefined { + return this.task.title + } + set title(title: string) { + this.task.title = title + } + + get output(): string | undefined { + return this.task.output + } + + newListr(tasks: TListrTask[], options?: Listr.ListrOptions) { + return new Listr2Mock(tasks, options) + } + + isRetrying() { + return false + } +} + +export class Listr2Mock { + static executedTaskTitles: string[] + static skippedTaskTitles: string[] + + ctx: Ctx + tasks: TListrTask[] + listrOptions?: Listr.ListrOptions | undefined + + constructor( + tasks: TListrTask[], + listrOptions?: Listr.ListrOptions | undefined, + ) { + this.ctx = {} + this.tasks = tasks + this.listrOptions = listrOptions + } + + async run() { + Listr2Mock.executedTaskTitles = [] + Listr2Mock.skippedTaskTitles = [] + + for (const task of this.tasks) { + const skip = typeof task.skip === 'function' ? task.skip : () => task.skip + + const skipReturnValue = skip(this.ctx) + + if (typeof skipReturnValue === 'string') { + // skip() => 'message' + Listr2Mock.skippedTaskTitles.push(skipReturnValue) + continue + } else if (skipReturnValue) { + // skip() => true + const taskTitle = typeof task.title === 'string' ? task.title : '' + Listr2Mock.skippedTaskTitles.push(taskTitle) + continue + } + + const augmentedTask = new Listr2TaskWrapper({ + // @ts-expect-error - TODO: Fix the types here + task: task.task, + prompt: async (options: ListrPromptOptions) => { + const enquirer = this.listrOptions?.injectWrapper?.enquirer as + | Enquirer + | 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 + >, + ) + + // 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') { + Listr2Mock.executedTaskTitles.push(task.title) + } + } + } +} diff --git a/packages/cli/src/commands/generate/dbAuth/__tests__/dbAuth.mockListr.test.js b/packages/cli/src/commands/generate/dbAuth/__tests__/dbAuth.mockListr.test.js index d99578db0e65..4f1ee5f52348 100644 --- a/packages/cli/src/commands/generate/dbAuth/__tests__/dbAuth.mockListr.test.js +++ b/packages/cli/src/commands/generate/dbAuth/__tests__/dbAuth.mockListr.test.js @@ -1,6 +1,3 @@ -let mockExecutedTaskTitles = [] -let mockSkippedTaskTitles = [] - global.__dirname = __dirname vi.mock('fs-extra') @@ -23,65 +20,13 @@ import { afterAll, } from 'vitest' +import { Listr2Mock } from '../../../../__tests__/Listr2Mock' import { getPaths } from '../../../../lib' import * as dbAuth from '../dbAuth' -vi.mock('listr2', async () => { - const ctx = {} - const listrImpl = (tasks, listrOptions) => { - return { - ctx, - 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 { - const augmentedTask = { - ...task, - newListr: listrImpl, - prompt: async (options) => { - const enquirer = listrOptions?.injectWrapper?.enquirer - - if (enquirer) { - if (!Array.isArray(options)) { - options = [{ ...options, name: 'default' }] - } else if (options.length === 1) { - options[0].name = 'default' - } - - const response = await enquirer.prompt(options) - - if (options.length === 1) { - return response.default - } - } - }, - skip: (msg) => { - mockSkippedTaskTitles.push(msg || task.title) - }, - } - await task.task(ctx, augmentedTask) - - // storing the title after running the task in case the task - // modifies its own title - mockExecutedTaskTitles.push(augmentedTask.title) - } - } - }, - } - } - - return { - // Return a constructor function, since we're calling `new` on Listr - Listr: vi.fn().mockImplementation(listrImpl), - } -}) +vi.mock('listr2', () => ({ + Listr: Listr2Mock, +})) // Mock files needed for each test const mockFiles = {} @@ -159,7 +104,7 @@ describe('dbAuth handler WebAuthn task title', () => { listr2: { silentRendererCondition: true }, }) - expect(mockExecutedTaskTitles[1]).toEqual( + expect(Listr2Mock.executedTaskTitles[1]).toEqual( 'Querying WebAuthn addition: WebAuthn addition included', ) }) @@ -193,7 +138,7 @@ export const { AuthProvider, useAuth } = createAuth(dbAuthClient) passwordLabel: 'password', }) - expect(mockSkippedTaskTitles[1]).toEqual( + expect(Listr2Mock.skippedTaskTitles[1]).toEqual( 'Querying WebAuthn addition: WebAuthn setup detected - ' + 'support will be included in pages', ) @@ -227,7 +172,7 @@ export const { AuthProvider, useAuth } = createAuth(dbAuthClient) passwordLabel: 'password', }) - expect(mockSkippedTaskTitles[1]).toEqual( + expect(Listr2Mock.skippedTaskTitles[1]).toEqual( 'Querying WebAuthn addition: No WebAuthn setup detected - ' + 'support will not be included in pages', ) @@ -250,7 +195,7 @@ export const { AuthProvider, useAuth } = createAuth(dbAuthClient) listr2: { silentRendererCondition: true }, }) - expect(mockExecutedTaskTitles[1]).toEqual( + expect(Listr2Mock.executedTaskTitles[1]).toEqual( 'Querying WebAuthn addition: WebAuthn addition not included', ) }) @@ -267,7 +212,7 @@ export const { AuthProvider, useAuth } = createAuth(dbAuthClient) webauthn: true, }) - expect(mockSkippedTaskTitles[0]).toEqual( + expect(Listr2Mock.skippedTaskTitles[0]).toEqual( 'Querying WebAuthn addition: argument webauthn passed, WebAuthn included', ) }) @@ -284,7 +229,7 @@ export const { AuthProvider, useAuth } = createAuth(dbAuthClient) webauthn: false, }) - expect(mockSkippedTaskTitles[0]).toEqual( + expect(Listr2Mock.skippedTaskTitles[0]).toEqual( 'Querying WebAuthn addition: argument webauthn passed, WebAuthn not included', ) }) diff --git a/packages/cli/src/commands/setup/__tests__/jobsHandler.test.ts b/packages/cli/src/commands/setup/__tests__/jobsHandler.test.ts index feab16449469..bba189b35039 100644 --- a/packages/cli/src/commands/setup/__tests__/jobsHandler.test.ts +++ b/packages/cli/src/commands/setup/__tests__/jobsHandler.test.ts @@ -1,6 +1,3 @@ -let mockExecutedTaskTitles = [] -let mockSkippedTaskTitles = [] - vi.mock('fs-extra') import '../../../lib/mockTelemetry' @@ -16,6 +13,10 @@ import { afterAll, } from 'vitest' +import type * as ProjectConfig from '@redwoodjs/project-config' + +import { Listr2Mock } from '../../../__tests__/Listr2Mock' +// @ts-expect-error - This is a JS file import * as jobsHandler from '../jobs/jobsHandler.js' vi.mock('fs', async () => ({ ...memfsFs, default: { ...memfsFs } })) @@ -35,9 +36,10 @@ vi.mock('@redwoodjs/cli-helpers', () => ({ task: async () => {}, }), })) + vi.mock('@redwoodjs/project-config', async (importOriginal) => { const path = require('path') - const originalProjectConfig = await importOriginal() + const originalProjectConfig = await importOriginal() return { ...originalProjectConfig, getPaths: () => { @@ -58,65 +60,9 @@ vi.mock('@redwoodjs/project-config', async (importOriginal) => { } }) -vi.mock('listr2', async () => { - const ctx = {} - const listrImpl = (tasks, listrOptions) => { - return { - ctx, - run: async () => { - mockExecutedTaskTitles = [] - mockSkippedTaskTitles = [] - - for (const task of tasks) { - const skip = - typeof task.skip === 'function' ? task.skip : () => task.skip - - const skipReturnValue = skip() - if (typeof skipReturnValue === 'string') { - mockSkippedTaskTitles.push(skipReturnValue) - } else if (skipReturnValue) { - mockSkippedTaskTitles.push(task.title) - } else { - const augmentedTask = { - ...task, - newListr: listrImpl, - prompt: async (options) => { - const enquirer = listrOptions?.injectWrapper?.enquirer - - if (enquirer) { - if (!Array.isArray(options)) { - options = [{ ...options, name: 'default' }] - } else if (options.length === 1) { - options[0].name = 'default' - } - - const response = await enquirer.prompt(options) - - if (options.length === 1) { - return response.default - } - } - }, - skip: (msg) => { - mockSkippedTaskTitles.push(msg || task.title) - }, - } - await task.task(ctx, augmentedTask) - - // storing the title after running the task in case the task - // modifies its own title - mockExecutedTaskTitles.push(augmentedTask.title) - } - } - }, - } - } - - return { - // Return a constructor function, since we're calling `new` on Listr - Listr: vi.fn().mockImplementation(listrImpl), - } -}) +vi.mock('listr2', () => ({ + Listr: Listr2Mock, +})) beforeAll(() => { vi.spyOn(console, 'log') @@ -135,9 +81,9 @@ beforeEach(() => { 'package.json': '{}', 'api/tsconfig.json': '', 'api/db/schema.prisma': '', - 'api/src/lib': {}, + 'api/src/lib': null, // api/src/jobs already exists – this should not cause an error - 'api/src/jobs': {}, + 'api/src/jobs': null, [__dirname + '/../jobs/templates/jobs.ts.template']: '', }, '/path/to/project', @@ -148,7 +94,7 @@ describe('jobsHandler', () => { it('skips creating the BackgroundJobs model if it already exists', async () => { await jobsHandler.handler({ force: false }) - expect(mockSkippedTaskTitles).toEqual([ + expect(Listr2Mock.skippedTaskTitles).toEqual([ 'BackgroundJob model exists, skipping', ]) expect(console.error).not.toHaveBeenCalled() @@ -158,7 +104,7 @@ describe('jobsHandler', () => { await jobsHandler.handler({ force: false }) expect( - mockExecutedTaskTitles.some( + Listr2Mock.executedTaskTitles.some( (title) => title === 'Creating jobs dir at api/src/jobs...', ), ).toBeTruthy() diff --git a/packages/cli/vitest.workspaces.ts b/packages/cli/vitest.workspaces.ts index 6d95ad515960..aeaa126fd74d 100644 --- a/packages/cli/vitest.workspaces.ts +++ b/packages/cli/vitest.workspaces.ts @@ -5,7 +5,7 @@ export default defineWorkspace([ extends: './vitest.config.mts', test: { name: 'root', - include: ['**/__tests__/**/*.[jt]s?(x)', '**/*.test.[jt]s?(x)'], + include: ['**/*.test.[jt]s?(x)'], exclude: [ ...configDefaults.exclude, '__fixtures__',