diff --git a/package.json b/package.json index 351af814a3..9f77b71534 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,8 @@ "project:deps": "node ./tasks/framework-tools/frameworkDepsToProject.mjs", "project:sync": "node ./tasks/framework-tools/frameworkSyncToProject.mjs", "project:tarsync": "tsx ./tasks/framework-tools/tarsync/bin.mts", - "rebuild-test-project-fixture": "tsx ./tasks/test-project/rebuild-test-project-fixture.ts", - "rebuild-test-project-fixture-esm": "tsx ./tasks/test-project/rebuild-test-project-fixture-esm.ts", + "rebuild-test-project-fixture": "tsx ./tasks/test-project/rebuild-test-project-fixture.mts", + "rebuild-test-project-fixture-esm": "tsx ./tasks/test-project/rebuild-test-project-fixture-esm.mts", "smoke-tests": "node ./tasks/smoke-tests/smoke-tests.mjs", "test": "nx run-many -t test -- --minWorkers=1 --maxWorkers=4", "test:k6": "tsx ./tasks/k6-test/run-k6-tests.mts", diff --git a/tasks/test-project/add-gql-fragments.ts b/tasks/test-project/add-gql-fragments.mts similarity index 93% rename from tasks/test-project/add-gql-fragments.ts rename to tasks/test-project/add-gql-fragments.mts index 79cddc3393..d11eecb5fb 100755 --- a/tasks/test-project/add-gql-fragments.ts +++ b/tasks/test-project/add-gql-fragments.mts @@ -4,7 +4,7 @@ import path from 'node:path' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -import { fragmentsTasks } from './tasks.js' +import { fragmentsTasks } from './tasks.mjs' const args = yargs(hideBin(process.argv)) .usage('Usage: $0 ') diff --git a/tasks/test-project/base-tasks.mts b/tasks/test-project/base-tasks.mts new file mode 100644 index 0000000000..e8833ea9ba --- /dev/null +++ b/tasks/test-project/base-tasks.mts @@ -0,0 +1,893 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { ListrTask } from 'listr2' +import type { Options, StdioOption } from 'execa' + +import { + applyCodemod, + fullPath, + getCfwBin, + getExecaOptions, + updatePkgJsonScripts, + exec, +} from './util.mjs' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export interface CommonTaskOptions { + outputPath: string + linkWithLatestFwBuild?: boolean + isFixture?: boolean + esmProject?: boolean + stdio?: Options['stdio'] +} + +export interface HighLevelTask { + title: string + /** Use this to create subtasks */ + tasksGetter?: ( + options: CommonTaskOptions, + ) => ListrTask[] | Promise + /** Use this for a single task that doesn't have subtasks */ + task?: (options: CommonTaskOptions) => void | Promise | Promise + enabled?: boolean | ((options: CommonTaskOptions) => boolean) +} + +export function createBuilder(cmd: string, dir = '', opts: CommonTaskOptions) { + return function (positionalArguments?: string | string[]) { + const execaOptions = { + ...getExecaOptions(path.join(opts.outputPath, dir)), + stdio: opts.stdio, + } + + const args = Array.isArray(positionalArguments) + ? positionalArguments + : positionalArguments + ? [positionalArguments] + : [] + + const subprocess = exec(cmd, args, execaOptions) + + return subprocess + } +} + +export const getWebTasks = (options: CommonTaskOptions): HighLevelTask[] => { + return [ + { + title: 'Creating pages', + tasksGetter: (opts) => getCreatePagesTasks(opts), + }, + { + title: 'Creating layout', + tasksGetter: (opts) => getCreateLayoutTasks(opts), + }, + { + title: 'Creating components', + tasksGetter: (opts) => getCreateComponentsTasks(opts), + }, + { + title: 'Creating cells', + tasksGetter: (opts) => getCreateCellsTasks(opts), + }, + { + title: 'Updating cell mocks', + tasksGetter: (opts) => getUpdateCellMocksTasks(opts), + }, + { + title: 'Changing routes', + task: () => applyCodemod('routes.js', fullPath('web/src/Routes')), + }, + { + title: 'Install tailwind dependencies', + task: (opts) => + exec( + 'yarn workspace web add -D postcss postcss-loader tailwindcss autoprefixer prettier-plugin-tailwindcss@^0.5.12', + [], + { ...getExecaOptions(opts.outputPath), stdio: opts.stdio }, + ), + enabled: (opts) => !!opts.linkWithLatestFwBuild, + }, + { + title: '[link] Copy local framework files again', + task: (opts) => + exec(`yarn ${getCfwBin(opts.outputPath)} project:copy`, [], { + ...getExecaOptions(opts.outputPath), + stdio: opts.stdio, + }), + enabled: (opts) => !!opts.linkWithLatestFwBuild, + }, + { + title: 'Adding Tailwind', + task: async (opts) => { + await exec( + 'yarn cedar setup ui tailwindcss', + ['--force', opts.linkWithLatestFwBuild && '--no-install'].filter( + Boolean, + ) as string[], + { ...getExecaOptions(opts.outputPath), stdio: opts.stdio }, + ) + }, + }, + ] +} + +export async function addModel(outputPath: string, schema: string) { + const prismaPath = path.join(outputPath, 'api/db/schema.prisma') + const current = fs.readFileSync(prismaPath, 'utf-8') + fs.writeFileSync(prismaPath, `${current.trim()}\n\n${schema}\n`) +} + +export const getApiTasks = (options: CommonTaskOptions): HighLevelTask[] => { + const addDbAuth = async (opts: CommonTaskOptions) => { + updatePkgJsonScripts({ + projectPath: opts.outputPath, + scripts: { postinstall: '' }, + }) + + if (opts.isFixture) { + // Special tarball installation for fixture + const packages = ['setup', 'api', 'web'] + for (const pkg of packages) { + const pkgPath = path.join( + __dirname, + '../../', + 'packages', + 'auth-providers', + 'dbAuth', + pkg, + ) + await exec('yarn build:pack', [], { + ...getExecaOptions(pkgPath), + stdio: opts.stdio, + }) + const tgzDest = path.join( + opts.outputPath, + `cedarjs-auth-dbauth-${pkg}.tgz`, + ) + fs.copyFileSync( + path.join(pkgPath, `cedarjs-auth-dbauth-${pkg}.tgz`), + tgzDest, + ) + } + + const pkgJsonPath = path.join(opts.outputPath, 'package.json') + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) + const oldResolutions = pkgJson.resolutions + pkgJson.resolutions = { + ...pkgJson.resolutions, + '@cedarjs/auth-dbauth-setup': './cedarjs-auth-dbauth-setup.tgz', + '@cedarjs/auth-dbauth-api': './cedarjs-auth-dbauth-api.tgz', + '@cedarjs/auth-dbauth-web': './cedarjs-auth-dbauth-web.tgz', + } + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)) + + await exec('yarn install', [], { + ...getExecaOptions(opts.outputPath), + stdio: opts.stdio, + }) + await exec( + 'yarn cedar setup auth dbAuth --force --no-webauthn --no-createUserModel --no-generateAuthPages', + [], + { ...getExecaOptions(opts.outputPath), stdio: opts.stdio }, + ) + + if (oldResolutions) { + pkgJson.resolutions = oldResolutions + } else { + delete pkgJson.resolutions + } + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)) + } else { + const dbAuthSetupPath = path.join( + opts.outputPath, + 'node_modules', + '@cedarjs', + 'auth-dbauth-setup', + ) + fs.rmSync(dbAuthSetupPath, { recursive: true, force: true }) + + await exec('yarn cedar setup auth dbAuth --force --no-webauthn', [], { + ...getExecaOptions(opts.outputPath), + stdio: opts.stdio, + }) + } + + updatePkgJsonScripts({ + projectPath: opts.outputPath, + scripts: { + postinstall: `yarn ${getCfwBin(opts.outputPath)} project:copy`, + }, + }) + + if (opts.linkWithLatestFwBuild) { + await exec(`yarn ${getCfwBin(opts.outputPath)} project:copy`, [], { + ...getExecaOptions(opts.outputPath), + stdio: opts.stdio, + }) + } + + await exec( + 'yarn cedar g dbAuth --no-webauthn --username-label=username --password-label=password', + [], + { ...getExecaOptions(opts.outputPath), stdio: opts.stdio }, + ) + + // Codemods for SDLs + const pathContactsSdl = path.join( + opts.outputPath, + 'api/src/graphql/contacts.sdl.ts', + ) + if (fs.existsSync(pathContactsSdl)) { + let content = fs.readFileSync(pathContactsSdl, 'utf-8') + content = content + .replace( + 'createContact(input: CreateContactInput!): Contact! @requireAuth', + `createContact(input: CreateContactInput!): Contact @skipAuth`, + ) + .replace( + 'deleteContact(id: Int!): Contact! @requireAuth', + 'deleteContact(id: Int!): Contact! @requireAuth(roles:["ADMIN"])', + ) + fs.writeFileSync(pathContactsSdl, content) + } + + const pathPostsSdl = path.join( + opts.outputPath, + 'api/src/graphql/posts.sdl.ts', + ) + if (fs.existsSync(pathPostsSdl)) { + let content = fs.readFileSync(pathPostsSdl, 'utf-8') + content = content.replace( + /posts: \[Post!\]! @requireAuth([^}]*)@requireAuth/, + `posts: [Post!]! @skipAuth\n post(id: Int!): Post @skipAuth`, + ) + fs.writeFileSync(pathPostsSdl, content) + } + + // Update src/lib/auth to return roles, so tsc doesn't complain + const libAuthPath = path.join(opts.outputPath, 'api/src/lib/auth.ts') + if (fs.existsSync(libAuthPath)) { + let content = fs.readFileSync(libAuthPath, 'utf-8') + content = content + .replace( + 'select: { id: true }', + 'select: { id: true, roles: true, email: true}', + ) + .replace( + 'const currentUserRoles = context.currentUser?.roles', + 'const currentUserRoles = context.currentUser?.roles as string | string[]', + ) + fs.writeFileSync(libAuthPath, content) + } + + // update requireAuth test + const pathRequireAuth = path.join( + opts.outputPath, + 'api/src/directives/requireAuth/requireAuth.test.ts', + ) + if (fs.existsSync(pathRequireAuth)) { + let content = fs.readFileSync(pathRequireAuth, 'utf-8') + content = content.replace( + /const mockExecution([^}]*){} }\)/, + `const mockExecution = mockRedwoodDirective(requireAuth, { + context: { currentUser: { id: 1, roles: 'ADMIN', email: 'b@zinga.com' } }, + })`, + ) + fs.writeFileSync(pathRequireAuth, content) + } + + // add fullName input to signup form + const pathSignupPageTs = path.join( + opts.outputPath, + 'web/src/pages/SignupPage/SignupPage.tsx', + ) + if (fs.existsSync(pathSignupPageTs)) { + let content = fs.readFileSync(pathSignupPageTs, 'utf-8') + const usernameFieldsMatch = content.match( + /\s*/, + ) + if (usernameFieldsMatch) { + const usernameFields = usernameFieldsMatch[0] + const fullNameFields = usernameFields + .replace(/\s*ref=\{usernameRef}/, '') + .replaceAll('username', 'full-name') + .replaceAll('Username', 'Full Name') + + content = content + .replace( + '', + '\n' + + fullNameFields, + ) + .replace( + 'password: data.password', + "password: data.password, 'full-name': data['full-name']", + ) + fs.writeFileSync(pathSignupPageTs, content) + } + } + + // set fullName when signing up + const pathAuthTs = path.join(opts.outputPath, 'api/src/functions/auth.ts') + if (fs.existsSync(pathAuthTs)) { + let content = fs.readFileSync(pathAuthTs, 'utf-8') + content = content + .replace('name: string', "'full-name': string") + .replace('userAttributes: _userAttributes', 'userAttributes') + .replace( + '// name: userAttributes.name', + "fullName: userAttributes['full-name']", + ) + fs.writeFileSync(pathAuthTs, content) + } + } + + return [ + { + title: 'Adding models to prisma', + task: async (opts) => { + const { post, user, contact } = await import('./codemods/models.mjs') + await addModel(opts.outputPath, post) + await addModel(opts.outputPath, user) + if (opts.isFixture) { + await addModel(opts.outputPath, contact) + return exec( + `yarn cedar prisma migrate dev --name create_models`, + [], + { ...getExecaOptions(opts.outputPath), stdio: opts.stdio }, + ) + } else { + return exec( + `yarn cedar prisma migrate dev --name create_post_user`, + [], + { ...getExecaOptions(opts.outputPath), stdio: opts.stdio }, + ) + } + }, + }, + { + title: 'Scaffolding post and contacts', + task: async (opts) => { + await createBuilder('yarn cedar g scaffold', '', opts)('post') + await applyCodemod( + 'scenarioValueSuffix.js', + fullPath('api/src/services/posts/posts.scenarios'), + ) + if (opts.isFixture) { + await createBuilder('yarn cedar g scaffold', '', opts)('contacts') + } + await exec(`yarn ${getCfwBin(opts.outputPath)} project:copy`, [], { + ...getExecaOptions(opts.outputPath), + stdio: opts.stdio, + }) + }, + }, + { + title: 'Adding seed script', + task: async () => { + await applyCodemod( + 'seed.js', + fullPath('scripts/seed.ts', { addExtension: false }), + ) + }, + }, + { + title: 'Adding contact model to prisma', + task: async (opts) => { + const { contact } = await import('./codemods/models.mjs') + await addModel(opts.outputPath, contact) + await exec(`yarn cedar prisma migrate dev --name create_contact`, [], { + ...getExecaOptions(opts.outputPath), + stdio: opts.stdio, + }) + await createBuilder('yarn cedar g scaffold', '', opts)('contacts') + + const contactsServicePath = fullPath( + 'api/src/services/contacts/contacts', + ) + if (fs.existsSync(contactsServicePath)) { + fs.writeFileSync( + contactsServicePath, + fs + .readFileSync(contactsServicePath, 'utf-8') + .replace( + "import { db } from 'src/lib/db'", + '// Testing aliased imports with extensions\n' + + "import { db } from 'src/lib/db.js'", + ), + ) + } + }, + enabled: (opts) => !!opts.isFixture, + }, + { + // This task renames the migration folders so that we don't have to deal with duplicates/conflicts when committing to the repo + title: 'Adjust dates within migration folder names', + task: (opts) => { + const migrationsFolderPath = path.join( + opts.outputPath, + 'api', + 'db', + 'migrations', + ) + if (!fs.existsSync(migrationsFolderPath)) { + return + } + + // Migration folders are folders which start with 14 digits because they have a yyyymmddhhmmss + const migrationFolders = fs + .readdirSync(migrationsFolderPath) + .filter((name) => { + return ( + name.match(/\d{14}.+/) && + fs.lstatSync(path.join(migrationsFolderPath, name)).isDirectory() + ) + }) + .sort() + const datetime = new Date('2022-01-01T12:00:00.000Z') + migrationFolders.forEach((name) => { + const datetimeInCorrectFormat = + datetime.getFullYear() + + ('0' + (datetime.getMonth() + 1)).slice(-2) + + ('0' + datetime.getDate()).slice(-2) + + '120000' // Time hardcoded to 12:00:00 to limit TZ issues + fs.renameSync( + path.join(migrationsFolderPath, name), + path.join( + migrationsFolderPath, + `${datetimeInCorrectFormat}${name.substring(14)}`, + ), + ) + datetime.setDate(datetime.getDate() + 1) + }) + }, + }, + { + title: 'Add users service', + task: async (opts) => { + await createBuilder('yarn cedar g sdl --no-crud', 'api', opts)('user') + await applyCodemod('usersSdl.js', fullPath('api/src/graphql/users.sdl')) + await applyCodemod( + 'usersService.js', + fullPath('api/src/services/users/users'), + ) + + const testPath = fullPath('api/src/services/users/users.test.ts', { + addExtension: false, + }) + if (fs.existsSync(testPath)) { + let content = fs.readFileSync(testPath, 'utf-8') + content = content.replace( + "import type { User } from '@prisma/client'", + '', + ) + fs.writeFileSync(testPath, content) + } + + await createBuilder('yarn cedar g types', '', opts)() + }, + enabled: (opts) => !!opts.isFixture, + }, + { + title: 'Add Prerender to Routes', + tasksGetter: (opts) => getPrerenderTasks(opts), + }, + { + title: 'Add context tests', + task: (opts) => { + const templatePath = path.join( + __dirname, + 'templates', + 'api', + 'context.test.ts.template', + ) + const projectPath = path.join( + opts.outputPath, + 'api', + 'src', + '__tests__', + 'context.test.ts', + ) + fs.mkdirSync(path.dirname(projectPath), { recursive: true }) + fs.writeFileSync(projectPath, fs.readFileSync(templatePath)) + }, + enabled: (opts) => !!opts.isFixture, + }, + { + title: 'Add describeScenario tests', + task: (opts) => { + // Copy contact.scenarios.ts, because scenario tests look for the same filename + fs.copyFileSync( + fullPath('api/src/services/contacts/contacts.scenarios'), + fullPath('api/src/services/contacts/describeContacts.scenarios'), + ) + + // Create describeContacts.test.ts + const describeScenarioFixture = path.join( + __dirname, + 'templates', + 'api', + 'contacts.describeScenario.test.ts.template', + ) + + fs.copyFileSync( + describeScenarioFixture, + fullPath('api/src/services/contacts/describeContacts.test'), + ) + }, + enabled: (opts) => !!opts.isFixture, + }, + { + title: 'Add vitest db import tracking tests for ESM test project', + task: (opts) => { + const templatesDir = path.join(__dirname, 'templates', 'api') + const templatePath1 = path.join(templatesDir, '1-db-import.test.ts') + const templatePath2 = path.join(templatesDir, '2-db-import.test.ts') + const templatePath3 = path.join(templatesDir, '3-db-import.test.ts') + + const testsDir = path.join(opts.outputPath, 'api', 'src', '__tests__') + const testFilePath1 = path.join(testsDir, '1-db-import.test.ts') + const testFilePath2 = path.join(testsDir, '2-db-import.test.ts') + const testFilePath3 = path.join(testsDir, '3-db-import.test.ts') + + fs.mkdirSync(testsDir, { recursive: true }) + fs.copyFileSync(templatePath1, testFilePath1) + fs.copyFileSync(templatePath2, testFilePath2) + fs.copyFileSync(templatePath3, testFilePath3) + + // I opted to add an additional vitest config file rather than modifying + // the existing one because I wanted to keep one looking exactly the + // same as it'll look in user's projects. + fs.copyFileSync( + path.join(templatesDir, 'vitest-sort.config.ts'), + path.join(opts.outputPath, 'api', 'vitest-sort.config.ts'), + ) + }, + enabled: (opts) => !!opts.esmProject, + }, + ] +} + +export const getCreatePagesTasks = ( + options: CommonTaskOptions, +): ListrTask[] => { + const createPage = (opts: CommonTaskOptions) => + opts.isFixture + ? createBuilder('yarn cedar g page', 'web', opts) + : createBuilder('yarn cedar g page', '', opts) + + return [ + { + title: 'Creating home page', + task: async (opts) => { + await createPage(opts)('home /') + return applyCodemod( + 'homePage.js', + fullPath('web/src/pages/HomePage/HomePage'), + ) + }, + }, + { + title: 'Creating about page', + task: async (opts) => { + await createPage(opts)('about') + return applyCodemod( + 'aboutPage.js', + fullPath('web/src/pages/AboutPage/AboutPage'), + ) + }, + }, + { + title: 'Creating contact page', + task: async (opts) => { + await createPage(opts)('contactUs /contact') + return applyCodemod( + 'contactUsPage.js', + fullPath('web/src/pages/ContactUsPage/ContactUsPage'), + ) + }, + }, + { + title: 'Creating blog post page', + task: async (opts) => { + await createPage(opts)('blogPost /blog-post/{id:Int}') + await applyCodemod( + 'blogPostPage.js', + fullPath('web/src/pages/BlogPostPage/BlogPostPage'), + ) + + if (opts.isFixture) { + await applyCodemod( + 'updateBlogPostPageStories.js', + fullPath('web/src/pages/BlogPostPage/BlogPostPage.stories'), + ) + } + }, + }, + { + title: 'Creating profile page', + task: async (opts) => { + await createPage(opts)('profile /profile') + + const testFileContent = `import { render, waitFor, screen } from '@cedarjs/testing/web' +import ProfilePage from './ProfilePage' + +describe('ProfilePage', () => { + it('renders successfully', async () => { + mockCurrentUser({ + email: 'danny@bazinga.com', + id: 84849020, + roles: 'BAZINGA', + }) + + await waitFor(async () => { + expect(() => { + render() + }).not.toThrow() + }) + + expect(await screen.findByText('danny@bazinga.com')).toBeInTheDocument() + }) +}) +` + + fs.writeFileSync( + fullPath('web/src/pages/ProfilePage/ProfilePage.test'), + testFileContent, + ) + + return applyCodemod( + 'profilePage.js', + fullPath('web/src/pages/ProfilePage/ProfilePage'), + ) + }, + }, + { + title: 'Creating MDX Storybook stories', + task: () => { + const cedarMdxStoryContent = fs.readFileSync( + `${path.resolve(__dirname, 'codemods', 'CedarJS.mdx')}`, + ) + fs.writeFileSync( + fullPath('web/src/CedarJS.mdx', { addExtension: false }), + cedarMdxStoryContent, + ) + }, + }, + { + title: 'Creating nested cells test page', + task: async (opts) => { + await createPage(opts)('waterfall {id:Int}') + await applyCodemod( + 'waterfallPage.js', + fullPath('web/src/pages/WaterfallPage/WaterfallPage'), + ) + + if (opts.isFixture) { + await applyCodemod( + 'updateWaterfallPageStories.js', + fullPath('web/src/pages/WaterfallPage/WaterfallPage.stories'), + ) + } + }, + }, + ] +} + +export const getCreateLayoutTasks = ( + _options: CommonTaskOptions, +): ListrTask[] => { + return [ + { + title: 'Creating layout', + task: async (opts) => { + await createBuilder('yarn cedar g layout', '', opts)('blog') + return applyCodemod( + 'blogLayout.js', + fullPath('web/src/layouts/BlogLayout/BlogLayout'), + ) + }, + }, + ] +} + +export const getCreateComponentsTasks = ( + options: CommonTaskOptions, +): ListrTask[] => { + const tasks: ListrTask[] = [ + { + title: 'Creating components', + task: async (opts) => { + const createComponent = createBuilder( + 'yarn cedar g component', + '', + opts, + ) + await createComponent('blogPost') + await applyCodemod( + 'blogPost.js', + fullPath('web/src/components/BlogPost/BlogPost'), + ) + + await createComponent('author') + await applyCodemod( + 'author.js', + fullPath('web/src/components/Author/Author'), + ) + await applyCodemod( + 'updateAuthorStories.js', + fullPath('web/src/components/Author/Author.stories'), + ) + await applyCodemod( + 'updateAuthorTest.js', + fullPath('web/src/components/Author/Author.test'), + ) + + if (opts.isFixture) { + await createComponent('classWithClassField') + await applyCodemod( + 'classWithClassField.ts', + fullPath( + 'web/src/components/ClassWithClassField/ClassWithClassField', + ), + ) + } + }, + }, + ] + return tasks +} + +export const getCreateCellsTasks = ( + _options: CommonTaskOptions, +): ListrTask[] => { + return [ + { + title: 'Creating cells', + task: async (opts) => { + const createCell = createBuilder('yarn cedar g cell', '', opts) + await createCell('blogPosts') + await applyCodemod( + 'blogPostsCell.js', + fullPath('web/src/components/BlogPostsCell/BlogPostsCell'), + ) + + await createCell('blogPost') + await applyCodemod( + 'blogPostCell.js', + fullPath('web/src/components/BlogPostCell/BlogPostCell'), + ) + + await createCell('author') + await applyCodemod( + 'authorCell.js', + fullPath('web/src/components/AuthorCell/AuthorCell'), + ) + + await createCell('waterfallBlogPost') + return applyCodemod( + 'waterfallBlogPostCell.js', + fullPath( + 'web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell', + ), + ) + }, + }, + ] +} + +export const getUpdateCellMocksTasks = ( + _options: CommonTaskOptions, +): ListrTask[] => { + return [ + { + title: 'Updating cell mocks', + task: async () => { + await applyCodemod( + 'updateBlogPostMocks.js', + fullPath('web/src/components/BlogPostCell/BlogPostCell.mock.ts', { + addExtension: false, + }), + ) + await applyCodemod( + 'updateBlogPostMocks.js', + fullPath('web/src/components/BlogPostsCell/BlogPostsCell.mock.ts', { + addExtension: false, + }), + ) + await applyCodemod( + 'updateAuthorCellMock.js', + fullPath('web/src/components/AuthorCell/AuthorCell.mock.ts', { + addExtension: false, + }), + ) + return applyCodemod( + 'updateWaterfallBlogPostMocks.js', + fullPath( + 'web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.mock.ts', + { addExtension: false }, + ), + ) + }, + }, + ] +} + +export const getPrerenderTasks = (options: CommonTaskOptions): ListrTask[] => { + return [ + { + title: 'Creating double rendering test page', + task: async (opts) => { + const createPageBuilder = createBuilder('yarn cedar g page', '', opts) + await createPageBuilder('double') + + const doublePageContent = `import { Metadata } from '@cedarjs/web' +import test from './test.png' + +const DoublePage = () => { + return ( + <> + +

DoublePage

+

This page exists to make sure we don't regress on RW#7757 and #317

+ Test + + ) +} +export default DoublePage` + + fs.writeFileSync( + fullPath('web/src/pages/DoublePage/DoublePage'), + doublePageContent, + ) + fs.copyFileSync( + fullPath('web/public/favicon.png', { addExtension: false }), + fullPath('web/src/pages/DoublePage/test.png', { + addExtension: false, + }), + ) + }, + }, + { + title: 'Update Routes.tsx', + task: (opts) => { + const pathRoutes = fullPath('web/src/Routes.tsx', { + addExtension: false, + }) + let content = fs.readFileSync(pathRoutes, 'utf-8') + content = content + .replace(/name="about"/, 'name="about" prerender') + .replace(/name="home"/, 'name="home" prerender') + .replace(/name="blogPost"/, 'name="blogPost" prerender') + .replace(/page={NotFoundPage}/, 'page={NotFoundPage} prerender') + .replace(/page={WaterfallPage}/, 'page={WaterfallPage} prerender') + .replace('name="double"', 'name="double" prerender') + .replace('name="newContact"', 'name="newContact" prerender') + fs.writeFileSync(pathRoutes, content) + + const blogPostRouteHooks = `import { db } from '$api/src/lib/db.js' +export async function routeParameters() { + return (await db.post.findMany({ take: 7 })).map((post) => ({ id: post.id })) +}` + fs.writeFileSync( + fullPath('web/src/pages/BlogPostPage/BlogPostPage.routeHooks.ts', { + addExtension: false, + }), + blogPostRouteHooks, + ) + + const waterfallRouteHooks = `export async function routeParameters() { return [{ id: 2 }] }` + fs.writeFileSync( + fullPath('web/src/pages/WaterfallPage/WaterfallPage.routeHooks.ts', { + addExtension: false, + }), + waterfallRouteHooks, + ) + }, + }, + ] +} diff --git a/tasks/test-project/codemods/models.ts b/tasks/test-project/codemods/models.mts similarity index 100% rename from tasks/test-project/codemods/models.ts rename to tasks/test-project/codemods/models.mts diff --git a/tasks/test-project/frameworkLinking.js b/tasks/test-project/frameworkLinking.js deleted file mode 100644 index 4e4beded5d..0000000000 --- a/tasks/test-project/frameworkLinking.js +++ /dev/null @@ -1,31 +0,0 @@ -/* eslint-env node, es6*/ -const execa = require('execa') - -const addFrameworkDepsToProject = (frameworkPath, projectPath, stdio) => { - return execa('yarn project:deps', { - cwd: frameworkPath, - shell: true, - stdio: stdio ? stdio : 'inherit', - env: { - CFW_PATH: frameworkPath, - RWJS_CWD: projectPath, - }, - }) -} - -const copyFrameworkPackages = (frameworkPath, projectPath, stdio) => { - return execa('yarn project:copy', { - cwd: frameworkPath, - shell: true, - stdio: stdio ? stdio : 'inherit', - env: { - CFW_PATH: frameworkPath, - RWJS_CWD: projectPath, - }, - }) -} - -module.exports = { - copyFrameworkPackages, - addFrameworkDepsToProject, -} diff --git a/tasks/test-project/frameworkLinking.mts b/tasks/test-project/frameworkLinking.mts new file mode 100644 index 0000000000..5480306e06 --- /dev/null +++ b/tasks/test-project/frameworkLinking.mts @@ -0,0 +1,38 @@ +import execa from 'execa' +import type { StdioOption, Options as ExecaOptions } from 'execa' + +export const addFrameworkDepsToProject = ( + frameworkPath: string, + projectPath: string, + stdio?: StdioOption, +) => { + const options: ExecaOptions = { + cwd: frameworkPath, + shell: true, + stdio: (stdio ?? 'inherit') as any, + env: { + CFW_PATH: frameworkPath, + RWJS_CWD: projectPath, + }, + } + + return execa('yarn', ['project:deps'], options) +} + +export const copyFrameworkPackages = ( + frameworkPath: string, + projectPath: string, + stdio?: StdioOption, +) => { + const options: ExecaOptions = { + cwd: frameworkPath, + shell: true, + stdio: (stdio ?? 'inherit') as any, + env: { + CFW_PATH: frameworkPath, + RWJS_CWD: projectPath, + }, + } + + return execa('yarn', ['project:copy'], options) +} diff --git a/tasks/test-project/rebuild-test-project-fixture-esm.ts b/tasks/test-project/rebuild-test-project-fixture-esm.mts similarity index 95% rename from tasks/test-project/rebuild-test-project-fixture-esm.ts rename to tasks/test-project/rebuild-test-project-fixture-esm.mts index f3e5bf76b5..f812526f8c 100755 --- a/tasks/test-project/rebuild-test-project-fixture-esm.ts +++ b/tasks/test-project/rebuild-test-project-fixture-esm.mts @@ -1,7 +1,9 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import { fileURLToPath } from 'node:url' +import ansis from 'ansis' import { rimraf } from 'rimraf' import semver from 'semver' import { hideBin } from 'yargs/helpers' @@ -12,19 +14,21 @@ import { RedwoodTUI, ReactiveTUIContent, RedwoodStyling } from '@cedarjs/tui' import { addFrameworkDepsToProject, copyFrameworkPackages, -} from './frameworkLinking.js' -import { webTasks, apiTasks } from './tui-tasks.js' -import { isAwaitable, isTuiError } from './typing.js' -import type { TuiTaskDef } from './typing.js' +} from './frameworkLinking.mjs' +import { webTasks, apiTasks } from './tui-tasks.mjs' +import { isAwaitable, isTuiError } from './typing.mjs' +import type { TuiTaskDef } from './typing.mjs' import { - getExecaOptions as utilGetExecaOptions, + getExecaOptions, updatePkgJsonScripts, + setVerbose, ExecaError, exec, getCfwBin, -} from './util.js' +} from './util.mjs' -const ansis = require('ansis') +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) function recommendedNodeVersion() { const templatePackageJsonPath = path.join( @@ -80,6 +84,8 @@ const args = yargs(hideBin(process.argv)) const { verbose, resume, resumePath, resumeStep } = args +setVerbose(verbose) + const RW_FRAMEWORK_PATH = path.join(__dirname, '../../') const OUTPUT_PROJECT_PATH = resumePath ? /* path.resolve(String(resumePath)) */ resumePath @@ -110,10 +116,6 @@ if (!startStep) { const tui = new RedwoodTUI() -function getExecaOptions(cwd: string) { - return { ...utilGetExecaOptions(cwd), stdio: 'pipe' } -} - function beginStep(step: string) { fs.mkdirSync(OUTPUT_PROJECT_PATH, { recursive: true }) fs.writeFileSync(path.join(OUTPUT_PROJECT_PATH, 'step.txt'), '' + step) @@ -160,7 +162,7 @@ async function tuiTask({ step, title, content, task, parent }: TuiTaskDef) { try { promise = task() - } catch (e) { + } catch (e: unknown) { // This code handles errors from synchronous tasks tui.stopReactive(true) @@ -184,7 +186,7 @@ async function tuiTask({ step, title, content, task, parent }: TuiTaskDef) { } if (isAwaitable(promise)) { - const result = await promise.catch((e) => { + const result = await promise.catch((e: any) => { // This code handles errors from asynchronous tasks tui.stopReactive(true) @@ -201,7 +203,7 @@ async function tuiTask({ step, title, content, task, parent }: TuiTaskDef) { ) } - process.exit(e.exitCode) + process.exit(e.exitCode ?? 1) }) if (Array.isArray(result)) { @@ -337,7 +339,7 @@ async function runCommand() { return addFrameworkDepsToProject( RW_FRAMEWORK_PATH, OUTPUT_PROJECT_PATH, - 'pipe', // TODO: Remove this when everything is using @rwjs/tui + 'pipe', ) }, }) @@ -348,11 +350,13 @@ async function runCommand() { content: 'yarn install', task: async () => { // TODO: See if this is needed now with tarsync - await exec('yarn install', getExecaOptions(OUTPUT_PROJECT_PATH)) + await exec('yarn install', [], getExecaOptions(OUTPUT_PROJECT_PATH)) // TODO: Now that I've added this, I wonder what other steps I can remove + const CFW_BIN = getCfwBin(OUTPUT_PROJECT_PATH) return exec( - `yarn ${getCfwBin(OUTPUT_PROJECT_PATH)} project:tarsync`, + `yarn ${CFW_BIN} project:tarsync`, + [], getExecaOptions(OUTPUT_PROJECT_PATH), ) }, @@ -401,15 +405,15 @@ async function runCommand() { step: 6, title: '[link] Add cfw project:copy postinstall', task: () => { + const CFW_BIN = getCfwBin(OUTPUT_PROJECT_PATH) return updatePkgJsonScripts({ projectPath: OUTPUT_PROJECT_PATH, scripts: { - postinstall: `yarn ${getCfwBin(OUTPUT_PROJECT_PATH)} project:copy`, + postinstall: `yarn ${CFW_BIN} project:copy`, }, }) }, }) - await tuiTask({ step: 7, title: 'Apply web codemods', diff --git a/tasks/test-project/rebuild-test-project-fixture.ts b/tasks/test-project/rebuild-test-project-fixture.mts similarity index 94% rename from tasks/test-project/rebuild-test-project-fixture.ts rename to tasks/test-project/rebuild-test-project-fixture.mts index 0610a10db8..40c345e2fd 100755 --- a/tasks/test-project/rebuild-test-project-fixture.ts +++ b/tasks/test-project/rebuild-test-project-fixture.mts @@ -1,7 +1,9 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import { fileURLToPath } from 'node:url' +import ansis from 'ansis' import { rimraf } from 'rimraf' import semver from 'semver' import { hideBin } from 'yargs/helpers' @@ -12,19 +14,21 @@ import { RedwoodTUI, ReactiveTUIContent, RedwoodStyling } from '@cedarjs/tui' import { addFrameworkDepsToProject, copyFrameworkPackages, -} from './frameworkLinking' -import { webTasks, apiTasks } from './tui-tasks' -import { isAwaitable, isTuiError } from './typing' -import type { TuiTaskDef } from './typing' +} from './frameworkLinking.mjs' +import { webTasks, apiTasks } from './tui-tasks.mjs' +import { isAwaitable, isTuiError } from './typing.mjs' +import type { TuiTaskDef } from './typing.mjs' import { - getExecaOptions as utilGetExecaOptions, + getExecaOptions, updatePkgJsonScripts, + setVerbose, ExecaError, exec, getCfwBin, -} from './util' +} from './util.mjs' -const ansis = require('ansis') +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) function recommendedNodeVersion() { const templatePackageJsonPath = path.join( @@ -80,6 +84,8 @@ const args = yargs(hideBin(process.argv)) const { verbose, resume, resumePath, resumeStep } = args +setVerbose(verbose) + const RW_FRAMEWORK_PATH = path.join(__dirname, '../../') const OUTPUT_PROJECT_PATH = resumePath ? /* path.resolve(String(resumePath)) */ resumePath @@ -110,10 +116,6 @@ if (!startStep) { const tui = new RedwoodTUI() -function getExecaOptions(cwd: string) { - return { ...utilGetExecaOptions(cwd), stdio: 'pipe' } -} - function beginStep(step: string) { fs.mkdirSync(OUTPUT_PROJECT_PATH, { recursive: true }) fs.writeFileSync(path.join(OUTPUT_PROJECT_PATH, 'step.txt'), '' + step) @@ -160,7 +162,7 @@ async function tuiTask({ step, title, content, task, parent }: TuiTaskDef) { try { promise = task() - } catch (e) { + } catch (e: unknown) { // This code handles errors from synchronous tasks tui.stopReactive(true) @@ -184,7 +186,7 @@ async function tuiTask({ step, title, content, task, parent }: TuiTaskDef) { } if (isAwaitable(promise)) { - const result = await promise.catch((e) => { + const result = await promise.catch((e: any) => { // This code handles errors from asynchronous tasks tui.stopReactive(true) @@ -201,7 +203,7 @@ async function tuiTask({ step, title, content, task, parent }: TuiTaskDef) { ) } - process.exit(e.exitCode) + process.exit(e.exitCode ?? 1) }) if (Array.isArray(result)) { @@ -334,7 +336,7 @@ async function runCommand() { return addFrameworkDepsToProject( RW_FRAMEWORK_PATH, OUTPUT_PROJECT_PATH, - 'pipe', // TODO: Remove this when everything is using @rwjs/tui + 'pipe', ) }, }) @@ -345,11 +347,13 @@ async function runCommand() { content: 'yarn install', task: async () => { // TODO: See if this is needed now with tarsync - await exec('yarn install', getExecaOptions(OUTPUT_PROJECT_PATH)) + await exec('yarn install', [], getExecaOptions(OUTPUT_PROJECT_PATH)) // TODO: Now that I've added this, I wonder what other steps I can remove + const CFW_BIN = getCfwBin(OUTPUT_PROJECT_PATH) return exec( - `yarn ${getCfwBin(OUTPUT_PROJECT_PATH)} project:tarsync`, + `yarn ${CFW_BIN} project:tarsync`, + [], getExecaOptions(OUTPUT_PROJECT_PATH), ) }, @@ -398,10 +402,11 @@ async function runCommand() { step: 6, title: '[link] Add cfw project:copy postinstall', task: () => { + const CFW_BIN = getCfwBin(OUTPUT_PROJECT_PATH) return updatePkgJsonScripts({ projectPath: OUTPUT_PROJECT_PATH, scripts: { - postinstall: `yarn ${getCfwBin(OUTPUT_PROJECT_PATH)} project:copy`, + postinstall: `yarn ${CFW_BIN} project:copy`, }, }) }, @@ -590,7 +595,7 @@ async function runCommand() { // removes existing Fixture and replaces with newly built project, // then removes new Project temp directory - await copyProject() + // await copyProject() }, }) diff --git a/tasks/test-project/set-up-trusted-documents.ts b/tasks/test-project/set-up-trusted-documents.mts similarity index 95% rename from tasks/test-project/set-up-trusted-documents.ts rename to tasks/test-project/set-up-trusted-documents.mts index 81e5ee127c..4484143400 100644 --- a/tasks/test-project/set-up-trusted-documents.ts +++ b/tasks/test-project/set-up-trusted-documents.mts @@ -2,12 +2,13 @@ import * as fs from 'node:fs' import * as path from 'node:path' +import type { Options as ExecaOptions } from 'execa' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -import { exec, getExecaOptions as utilGetExecaOptions } from './util' +import { exec, getExecaOptions as utilGetExecaOptions } from './util.mjs' -function getExecaOptions(cwd: string) { +function getExecaOptions(cwd: string): ExecaOptions { return { ...utilGetExecaOptions(cwd), stdio: 'pipe' } } diff --git a/tasks/test-project/tasks.js b/tasks/test-project/tasks.js deleted file mode 100644 index 2388b2f6be..0000000000 --- a/tasks/test-project/tasks.js +++ /dev/null @@ -1,958 +0,0 @@ -/* eslint-env node, es6*/ -const fs = require('node:fs') -const path = require('path') - -const execa = require('execa') -const Listr = require('listr2').Listr - -const { - getExecaOptions, - applyCodemod, - updatePkgJsonScripts, - exec, - getCfwBin, -} = require('./util') - -// This variable gets used in other functions -// and is set when webTasks or apiTasks are called -let OUTPUT_PATH - -function fullPath(name, { addExtension } = { addExtension: true }) { - if (addExtension) { - if (name.startsWith('api')) { - name += '.ts' - } else if (name.startsWith('web')) { - name += '.tsx' - } - } - - return path.join(OUTPUT_PATH, name) -} - -const createBuilder = (cmd) => { - return async function createItem(positionals) { - await execa( - cmd, - Array.isArray(positionals) ? positionals : [positionals], - getExecaOptions(OUTPUT_PATH), - ) - } -} - -const createPage = createBuilder('yarn cedar g page') - -async function webTasks(outputPath, { linkWithLatestFwBuild, verbose }) { - OUTPUT_PATH = outputPath - - const createPages = async () => { - return new Listr([ - { - title: 'Creating home page', - task: async () => { - await createPage('home /') - - return applyCodemod( - 'homePage.js', - fullPath('web/src/pages/HomePage/HomePage'), - ) - }, - }, - { - title: 'Creating about page', - task: async () => { - await createPage('about') - - return applyCodemod( - 'aboutPage.js', - fullPath('web/src/pages/AboutPage/AboutPage'), - ) - }, - }, - { - title: 'Creating contact page', - task: async () => { - await createPage('contactUs /contact') - - return applyCodemod( - 'contactUsPage.js', - fullPath('web/src/pages/ContactUsPage/ContactUsPage'), - ) - }, - }, - { - title: 'Creating blog post page', - task: async () => { - await createPage('blogPost /blog-post/{id:Int}') - - return applyCodemod( - 'blogPostPage.js', - fullPath('web/src/pages/BlogPostPage/BlogPostPage'), - ) - }, - }, - { - title: 'Creating profile page', - task: async () => { - await createPage('profile /profile') - - // Update the profile page test - const testFileContent = `import { render, waitFor, screen } from '@cedarjs/testing/web' - - import ProfilePage from './ProfilePage' - - describe('ProfilePage', () => { - it('renders successfully', async () => { - mockCurrentUser({ - email: 'danny@bazinga.com', - id: 84849020, - roles: 'BAZINGA', - }) - - await waitFor(async () => { - expect(() => { - render() - }).not.toThrow() - }) - - expect(await screen.findByText('danny@bazinga.com')).toBeInTheDocument() - }) - }) - ` - - fs.writeFileSync( - fullPath('web/src/pages/ProfilePage/ProfilePage.test'), - testFileContent, - ) - - return applyCodemod( - 'profilePage.js', - fullPath('web/src/pages/ProfilePage/ProfilePage'), - ) - }, - }, - { - title: 'Creating MDX Storybook stories', - task: () => { - const cedarMdxStoryContent = fs.readFileSync( - `${path.resolve(__dirname, 'codemods', 'CedarJS.mdx')}`, - ) - - fs.writeFileSync( - fullPath('web/src/CedarJS.mdx', { addExtension: false }), - cedarMdxStoryContent, - ) - - return - }, - }, - { - title: 'Creating nested cells test page', - task: async () => { - await createPage('waterfall {id:Int}') - - await applyCodemod( - 'waterfallPage.js', - fullPath('web/src/pages/WaterfallPage/WaterfallPage'), - ) - }, - }, - ]) - } - - const createLayout = async () => { - const createLayout = createBuilder('yarn cedar g layout') - - await createLayout('blog') - - return applyCodemod( - 'blogLayout.js', - fullPath('web/src/layouts/BlogLayout/BlogLayout'), - ) - } - - const createComponents = async () => { - const createComponent = createBuilder('yarn cedar g component') - - await createComponent('blogPost') - - await applyCodemod( - 'blogPost.js', - fullPath('web/src/components/BlogPost/BlogPost'), - ) - - await createComponent('author') - - await applyCodemod( - 'author.js', - fullPath('web/src/components/Author/Author'), - ) - - await applyCodemod( - 'updateAuthorStories.js', - fullPath('web/src/components/Author/Author.stories'), - ) - - await applyCodemod( - 'updateAuthorTest.js', - fullPath('web/src/components/Author/Author.test'), - ) - } - - const createCells = async () => { - const createCell = createBuilder('yarn cedar g cell') - - await createCell('blogPosts') - - await applyCodemod( - 'blogPostsCell.js', - fullPath('web/src/components/BlogPostsCell/BlogPostsCell'), - ) - - await createCell('blogPost') - - await applyCodemod( - 'blogPostCell.js', - fullPath('web/src/components/BlogPostCell/BlogPostCell'), - ) - - await createCell('author') - - await applyCodemod( - 'authorCell.js', - fullPath('web/src/components/AuthorCell/AuthorCell'), - ) - - await createCell('waterfallBlogPost') - - return applyCodemod( - 'waterfallBlogPostCell.js', - fullPath( - 'web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell', - ), - ) - } - - const updateCellMocks = async () => { - await applyCodemod( - 'updateBlogPostMocks.js', - fullPath('web/src/components/BlogPostCell/BlogPostCell.mock.ts', { - addExtension: false, - }), - ) - - await applyCodemod( - 'updateBlogPostMocks.js', - fullPath('web/src/components/BlogPostsCell/BlogPostsCell.mock.ts', { - addExtension: false, - }), - ) - - await applyCodemod( - 'updateAuthorCellMock.js', - fullPath('web/src/components/AuthorCell/AuthorCell.mock.ts', { - addExtension: false, - }), - ) - - return applyCodemod( - 'updateWaterfallBlogPostMocks.js', - fullPath( - 'web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.mock.ts', - { - addExtension: false, - }, - ), - ) - } - - return new Listr( - [ - { - title: 'Creating pages', - task: () => createPages(), - }, - { - title: 'Creating layout', - task: () => createLayout(), - }, - { - title: 'Creating components', - task: () => createComponents(), - }, - { - title: 'Creating cells', - task: () => createCells(), - }, - { - title: 'Updating cell mocks', - task: () => updateCellMocks(), - }, - { - title: 'Changing routes', - task: () => applyCodemod('routes.js', fullPath('web/src/Routes')), - }, - - // ====== NOTE: rufus needs this workaround for tailwind ======= - // Setup tailwind in a linked project, due to cfw we install deps manually - { - title: 'Install tailwind dependencies', - // @NOTE: use cfw, because calling the copy function doesn't seem to work here - task: () => - execa( - 'yarn workspace web add -D postcss postcss-loader tailwindcss autoprefixer prettier-plugin-tailwindcss@^0.5.12', - [], - getExecaOptions(outputPath), - ), - enabled: () => linkWithLatestFwBuild, - }, - { - title: '[link] Copy local framework files again', - // @NOTE: use cfw, because calling the copy function doesn't seem to work here - task: () => - execa( - `yarn ${getCfwBin(outputPath)} project:copy`, - [], - getExecaOptions(outputPath), - ), - enabled: () => linkWithLatestFwBuild, - }, - // ========= - { - title: 'Adding Tailwind', - task: () => { - return execa( - 'yarn cedar setup ui tailwindcss', - ['--force', linkWithLatestFwBuild && '--no-install'].filter( - Boolean, - ), - getExecaOptions(outputPath), - ) - }, - }, - ], - { - exitOnError: true, - renderer: verbose && 'verbose', - }, - ) -} - -async function addModel(schema) { - const path = `${OUTPUT_PATH}/api/db/schema.prisma` - - const current = fs.readFileSync(path) - - fs.writeFileSync(path, `${current}\n\n${schema}`) -} - -async function apiTasks(outputPath, { verbose, linkWithLatestFwBuild }) { - OUTPUT_PATH = outputPath - - const addDbAuth = async () => { - // Temporarily disable postinstall script - updatePkgJsonScripts({ - projectPath: outputPath, - scripts: { - postinstall: '', - }, - }) - - const dbAuthSetupPath = path.join( - outputPath, - 'node_modules', - '@cedarjs', - 'auth-dbauth-setup', - ) - - // At an earlier step we run `yarn cfw project:copy` which gives us - // auth-dbauth-setup@3.2.0 currently. We need that version to be a canary - // version for auth-dbauth-api and auth-dbauth-web package installations - // to work. So we remove the current version and add a canary version - // instead. - - fs.rmSync(dbAuthSetupPath, { recursive: true, force: true }) - - await execa( - 'yarn cedar setup auth dbAuth --force --no-webauthn', - [], - getExecaOptions(outputPath), - ) - - // Restore postinstall script - updatePkgJsonScripts({ - projectPath: outputPath, - scripts: { - postinstall: `yarn ${getCfwBin(outputPath)} project:copy`, - }, - }) - - if (linkWithLatestFwBuild) { - await execa( - `yarn ${getCfwBin(outputPath)} project:copy`, - [], - getExecaOptions(outputPath), - ) - } - - await execa( - 'yarn cedar g dbAuth --no-webauthn --username-label=username --password-label=password', - [], - ) - - // update directive in contacts.sdl.ts - const pathContactsSdl = `${OUTPUT_PATH}/api/src/graphql/contacts.sdl.ts` - const contentContactsSdl = fs.readFileSync(pathContactsSdl, 'utf-8') - const resultsContactsSdl = contentContactsSdl - .replace( - 'createContact(input: CreateContactInput!): Contact! @requireAuth', - `createContact(input: CreateContactInput!): Contact @skipAuth`, - ) - .replace( - 'deleteContact(id: Int!): Contact! @requireAuth', - 'deleteContact(id: Int!): Contact! @requireAuth(roles:["ADMIN"])', - ) // make deleting contacts admin only - fs.writeFileSync(pathContactsSdl, resultsContactsSdl) - - // update directive in posts.sdl.ts - const pathPostsSdl = `${OUTPUT_PATH}/api/src/graphql/posts.sdl.ts` - const contentPostsSdl = fs.readFileSync(pathPostsSdl, 'utf-8') - const resultsPostsSdl = contentPostsSdl.replace( - /posts: \[Post!\]! @requireAuth([^}]*)@requireAuth/, - `posts: [Post!]! @skipAuth - post(id: Int!): Post @skipAuth`, - ) // make posts accessible to all - - fs.writeFileSync(pathPostsSdl, resultsPostsSdl) - - // Update src/lib/auth to return roles, so tsc doesn't complain - const libAuthPath = `${OUTPUT_PATH}/api/src/lib/auth.ts` - const libAuthContent = fs.readFileSync(libAuthPath, 'utf-8') - - const newLibAuthContent = libAuthContent - .replace( - 'select: { id: true }', - 'select: { id: true, roles: true, email: true}', - ) - .replace( - 'const currentUserRoles = context.currentUser?.roles', - 'const currentUserRoles = context.currentUser?.roles as string | string[]', - ) - fs.writeFileSync(libAuthPath, newLibAuthContent) - - // update requireAuth test - const pathRequireAuth = `${OUTPUT_PATH}/api/src/directives/requireAuth/requireAuth.test.ts` - const contentRequireAuth = fs.readFileSync(pathRequireAuth).toString() - const resultsRequireAuth = contentRequireAuth.replace( - /const mockExecution([^}]*){} }\)/, - `const mockExecution = mockRedwoodDirective(requireAuth, { - context: { currentUser: { id: 1, roles: 'ADMIN', email: 'b@zinga.com' } }, - })`, - ) - fs.writeFileSync(pathRequireAuth, resultsRequireAuth) - - // add fullName input to signup form - const pathSignupPageTs = `${OUTPUT_PATH}/web/src/pages/SignupPage/SignupPage.tsx` - const contentSignupPageTs = fs.readFileSync(pathSignupPageTs, 'utf-8') - const usernameFields = contentSignupPageTs.match( - /\s*/, - )[0] - const fullNameFields = usernameFields - .replace(/\s*ref=\{usernameRef}/, '') - .replaceAll('username', 'full-name') - .replaceAll('Username', 'Full Name') - - const newContentSignupPageTs = contentSignupPageTs - .replace( - '', - '\n' + - fullNameFields, - ) - // include full-name in the data we pass to `signUp()` - .replace( - 'password: data.password', - "password: data.password, 'full-name': data['full-name']", - ) - - fs.writeFileSync(pathSignupPageTs, newContentSignupPageTs) - - // set fullName when signing up - const pathAuthTs = `${OUTPUT_PATH}/api/src/functions/auth.ts` - const contentAuthTs = fs.readFileSync(pathAuthTs).toString() - const resultsAuthTs = contentAuthTs - .replace('name: string', "'full-name': string") - .replace('userAttributes: _userAttributes', 'userAttributes') - .replace( - '// name: userAttributes.name', - "fullName: userAttributes['full-name']", - ) - - fs.writeFileSync(pathAuthTs, resultsAuthTs) - } - - // add prerender to some routes - const addPrerender = async () => { - return new Listr([ - { - // We need to do this here, and not where we create the other pages, to - // keep it outside of BlogLayout - title: 'Creating double rendering test page', - task: async () => { - const createPage = createBuilder('yarn cedar g page') - await createPage('double') - - const doublePageContent = `import { Metadata } from '@cedarjs/web' - - import test from './test.png' - - const DoublePage = () => { - return ( - <> - - -

DoublePage

-

- This page exists to make sure we don't regress on{' '} - - #7757 - -

-

For RW#7757 it needs to be a page that is not wrapped in a Set

-

- We also use this page to make sure we don't regress on{' '} - - #317 - -

- Test - - ) - } - - export default DoublePage` - - fs.writeFileSync( - fullPath('web/src/pages/DoublePage/DoublePage'), - doublePageContent, - ) - fs.copyFileSync( - fullPath('web/public/favicon.png'), - fullPath('web/src/pages/DoublePage/test.png', { - addExtension: false, - }), - ) - }, - }, - { - title: 'Update Routes.tsx', - task: () => { - const pathRoutes = `${OUTPUT_PATH}/web/src/Routes.tsx` - const contentRoutes = fs.readFileSync(pathRoutes).toString() - const resultsRoutesAbout = contentRoutes.replace( - /name="about"/, - `name="about" prerender`, - ) - const resultsRoutesHome = resultsRoutesAbout.replace( - /name="home"/, - `name="home" prerender`, - ) - const resultsRoutesBlogPost = resultsRoutesHome.replace( - /name="blogPost"/, - `name="blogPost" prerender`, - ) - const resultsRoutesNotFound = resultsRoutesBlogPost.replace( - /page={NotFoundPage}/, - `page={NotFoundPage} prerender`, - ) - const resultsRoutesWaterfall = resultsRoutesNotFound.replace( - /page={WaterfallPage}/, - `page={WaterfallPage} prerender`, - ) - const resultsRoutesDouble = resultsRoutesWaterfall.replace( - 'name="double"', - 'name="double" prerender', - ) - const resultsRoutesNewContact = resultsRoutesDouble.replace( - 'name="newContact"', - 'name="newContact" prerender', - ) - fs.writeFileSync(pathRoutes, resultsRoutesNewContact) - - const blogPostRouteHooks = `import { db } from '$api/src/lib/db.js' - - export async function routeParameters() { - return (await db.post.findMany({ take: 7 })).map((post) => ({ id: post.id })) - }` - const blogPostRouteHooksPath = `${OUTPUT_PATH}/web/src/pages/BlogPostPage/BlogPostPage.routeHooks.ts` - fs.writeFileSync(blogPostRouteHooksPath, blogPostRouteHooks) - - const waterfallRouteHooks = `export async function routeParameters() { - return [{ id: 2 }] - }` - const waterfallRouteHooksPath = `${OUTPUT_PATH}/web/src/pages/WaterfallPage/WaterfallPage.routeHooks.ts` - fs.writeFileSync(waterfallRouteHooksPath, waterfallRouteHooks) - }, - }, - ]) - } - - const generateScaffold = createBuilder('yarn cedar g scaffold') - - return new Listr( - [ - { - title: 'Adding post model to prisma', - task: async () => { - // Need both here since they have a relation - const { post, user } = await import('./codemods/models.js') - - addModel(post) - addModel(user) - - return execa( - `yarn cedar prisma migrate dev --name create_post_user`, - [], - getExecaOptions(outputPath), - ) - }, - }, - { - title: 'Scaffolding post', - task: async () => { - await generateScaffold('post') - - // Replace the random numbers in the scenario with consistent values - await applyCodemod( - 'scenarioValueSuffix.js', - fullPath('api/src/services/posts/posts.scenarios'), - ) - - await execa( - `yarn ${getCfwBin(outputPath)} project:copy`, - [], - getExecaOptions(outputPath), - ) - }, - }, - { - title: 'Adding seed script', - task: async () => { - await applyCodemod( - 'seed.js', - fullPath('scripts/seed.ts', { addExtension: false }), - ) - }, - }, - { - title: 'Adding contact model to prisma', - task: async () => { - const { contact } = await import('./codemods/models.js') - - addModel(contact) - - await execa( - `yarn cedar prisma migrate dev --name create_contact`, - [], - getExecaOptions(outputPath), - ) - - await generateScaffold('contacts') - }, - }, - { - // This task renames the migration folders so that we don't have to deal with duplicates/conflicts when committing to the repo - title: 'Adjust dates within migration folder names', - task: () => { - const migrationsFolderPath = path.join( - OUTPUT_PATH, - 'api', - 'db', - 'migrations', - ) - // Migration folders are folders which start with 14 digits because they have a yyyymmddhhmmss - const migrationFolders = fs - .readdirSync(migrationsFolderPath) - .filter((name) => { - return ( - name.match(/\d{14}.+/) && - fs - .lstatSync(path.join(migrationsFolderPath, name)) - .isDirectory() - ) - }) - .sort() - const datetime = new Date('2022-01-01T12:00:00.000Z') - migrationFolders.forEach((name) => { - const datetimeInCorrectFormat = - datetime.getFullYear() + - ('0' + (datetime.getMonth() + 1)).slice(-2) + - ('0' + datetime.getDate()).slice(-2) + - ('0' + datetime.getHours()).slice(-2) + - ('0' + datetime.getMinutes()).slice(-2) + - ('0' + datetime.getSeconds()).slice(-2) - fs.renameSync( - path.join(migrationsFolderPath, name), - path.join( - migrationsFolderPath, - `${datetimeInCorrectFormat}${name.substring(14)}`, - ), - ) - datetime.setDate(datetime.getDate() + 1) - }) - }, - }, - { - title: 'Add dbAuth', - task: async () => addDbAuth(), - }, - { - title: 'Add users service', - task: async () => { - const generateSdl = createBuilder('yarn cedar g sdl --no-crud') - - await generateSdl('user') - - await applyCodemod( - 'usersSdl.js', - fullPath('api/src/graphql/users.sdl'), - ) - - await applyCodemod( - 'usersService.js', - fullPath('api/src/services/users/users'), - ) - - // Replace the random numbers in the scenario with consistent values - await applyCodemod( - 'scenarioValueSuffix.js', - fullPath('api/src/services/users/users.scenarios'), - ) - - const test = `import { user } from './users.js' - import type { StandardScenario } from './users.scenarios.js' - - describe('users', () => { - scenario('returns a single user', async (scenario: StandardScenario) => { - const result = await user({ id: scenario.user.one.id }) - - expect(result).toEqual(scenario.user.one) - }) - })`.replaceAll(/ {12}/g, '') - - fs.writeFileSync(fullPath('api/src/services/users/users.test'), test) - - return createBuilder('yarn cedar g types')() - }, - }, - { - title: 'Add describeScenario tests', - task: async () => { - // Copy contact.scenarios.ts, because scenario tests look for the same filename - fs.copyFileSync( - fullPath('api/src/services/contacts/contacts.scenarios'), - fullPath('api/src/services/contacts/describeContacts.scenarios'), - ) - - // Create describeContacts.test.ts - const describeScenarioFixture = path.join( - __dirname, - 'templates', - 'api', - 'contacts.describeScenario.test.ts.template', - ) - - fs.copyFileSync( - describeScenarioFixture, - fullPath('api/src/services/contacts/describeContacts.test'), - ) - }, - }, - { - // This is probably more of a web side task really, but the scaffolded - // pages aren't generated until we get here to the api side tasks. So - // instead of doing some up in the web side tasks, and then the rest - // here I decided to move all of them here - title: 'Add Prerender to Routes', - task: () => addPrerender(), - }, - ], - { - exitOnError: true, - renderer: verbose && 'verbose', - renderOptions: { collapseSubtasks: false }, - }, - ) -} - -/** - * Separates the streaming-ssr related steps. These are all web tasks, - * if we choose to move them later - * @param {string} outputPath - */ -async function streamingTasks(outputPath, { verbose }) { - OUTPUT_PATH = outputPath - - const tasks = [ - { - title: 'Creating Delayed suspense delayed page', - task: async () => { - await createPage('delayed') - - await applyCodemod( - 'delayedPage.js', - fullPath('web/src/pages/DelayedPage/DelayedPage'), - ) - }, - }, - { - title: 'Enable streaming-ssr experiment', - task: async () => { - const setupExperiment = createBuilder( - 'yarn cedar experimental setup-streaming-ssr', - ) - await setupExperiment('--force') - }, - }, - ] - - return new Listr(tasks, { - exitOnError: true, - renderer: verbose && 'verbose', - renderOptions: { collapseSubtasks: false }, - }) -} - -/** - * Tasks to add GraphQL Fragments support to the test-project, and some queries - * to test fragments - */ -async function fragmentsTasks(outputPath, { verbose }) { - OUTPUT_PATH = outputPath - - const tasks = [ - { - title: 'Enable fragments', - task: async () => { - const redwoodTomlPath = path.join(outputPath, 'redwood.toml') - const redwoodToml = fs.readFileSync(redwoodTomlPath).toString() - const newRedwoodToml = redwoodToml + '\n[graphql]\n fragments = true\n' - fs.writeFileSync(redwoodTomlPath, newRedwoodToml) - }, - }, - { - title: 'Adding produce and stall models to prisma', - task: async () => { - // Need both here since they have a relation - const models = await import('./codemods/models.js') - - addModel((models.default || models).produce) - addModel((models.default || models).stall) - - return exec( - 'yarn cedar prisma migrate dev --name create_produce_stall', - [], - getExecaOptions(outputPath), - ) - }, - }, - { - title: 'Seed fragments data', - task: async () => { - await applyCodemod( - 'seedFragments.ts', - fullPath('scripts/seed.ts', { addExtension: false }), - ) - - await exec('yarn cedar prisma db seed', [], getExecaOptions(outputPath)) - }, - }, - { - title: 'Generate SDLs for produce and stall', - task: async () => { - const generateSdl = createBuilder('yarn cedar g sdl') - - await generateSdl('stall') - await generateSdl('produce') - - await applyCodemod( - 'producesSdl.ts', - fullPath('api/src/graphql/produces.sdl'), - ) - }, - }, - { - title: 'Copy components from templates', - task: () => { - const templatesPath = path.join(__dirname, 'templates', 'web') - const componentsPath = path.join( - OUTPUT_PATH, - 'web', - 'src', - 'components', - ) - - for (const fileName of [ - 'Card.tsx', - 'FruitInfo.tsx', - 'ProduceInfo.tsx', - 'StallInfo.tsx', - 'VegetableInfo.tsx', - ]) { - const templatePath = path.join(templatesPath, fileName) - const componentPath = path.join(componentsPath, fileName) - - fs.writeFileSync(componentPath, fs.readFileSync(templatePath)) - } - }, - }, - { - title: 'Copy sdl and service for groceries from templates', - task: () => { - const templatesPath = path.join(__dirname, 'templates', 'api') - const graphqlPath = path.join(OUTPUT_PATH, 'api', 'src', 'graphql') - const servicesPath = path.join(OUTPUT_PATH, 'api', 'src', 'services') - - const sdlTemplatePath = path.join(templatesPath, 'groceries.sdl.ts') - const sdlPath = path.join(graphqlPath, 'groceries.sdl.ts') - const serviceTemplatePath = path.join(templatesPath, 'groceries.ts') - const servicePath = path.join(servicesPath, 'groceries.ts') - - fs.writeFileSync(sdlPath, fs.readFileSync(sdlTemplatePath)) - fs.writeFileSync(servicePath, fs.readFileSync(serviceTemplatePath)) - }, - }, - { - title: 'Creating Groceries page', - task: async () => { - await createPage('groceries') - - await applyCodemod( - 'groceriesPage.ts', - fullPath('web/src/pages/GroceriesPage/GroceriesPage'), - ) - }, - }, - ] - - return new Listr(tasks, { - exitOnError: true, - renderer: verbose && 'verbose', - renderOptions: { collapseSubtasks: false }, - }) -} - -module.exports = { - apiTasks, - webTasks, - streamingTasks, - fragmentsTasks, -} diff --git a/tasks/test-project/tasks.mts b/tasks/test-project/tasks.mts new file mode 100644 index 0000000000..055d6d5a1f --- /dev/null +++ b/tasks/test-project/tasks.mts @@ -0,0 +1,161 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { Listr } from 'listr2' +import type { ListrTask } from 'listr2' + +import { + getWebTasks, + getApiTasks, + addModel, + type HighLevelTask, + type CommonTaskOptions, +} from './base-tasks.mjs' +import { + applyCodemod, + fullPath, + getExecaOptions, + setOutputPath, + exec, + createBuilder, +} from './util.mjs' + +interface WebTasksOptions { + linkWithLatestFwBuild?: boolean + verbose?: boolean +} + +function mapToListrTask( + t: HighLevelTask, + options: CommonTaskOptions, +): ListrTask { + const enabled = + typeof t.enabled === 'function' ? t.enabled(options) : t.enabled + + return { + title: t.title, + task: async (_ctx, task) => { + if (t.tasksGetter) { + const subtasks = await t.tasksGetter(options) + return task.newListr(subtasks) + } + + if (t.task) { + return t.task(options) + } + + throw new Error('Unexpected task') + }, + enabled, + } +} + +export async function webTasks( + outputPath: string, + { linkWithLatestFwBuild, verbose }: WebTasksOptions, +) { + setOutputPath(outputPath) + const options: CommonTaskOptions = { outputPath, linkWithLatestFwBuild } + + const tasks = getWebTasks(options) + return new Listr( + tasks.map((t) => mapToListrTask(t, options)), + { + exitOnError: true, + renderer: verbose ? 'verbose' : 'default', + }, + ) +} + +interface ApiTasksOptions { + linkWithLatestFwBuild?: boolean + verbose?: boolean +} + +export async function apiTasks( + outputPath: string, + { linkWithLatestFwBuild, verbose }: ApiTasksOptions, +) { + setOutputPath(outputPath) + const options: CommonTaskOptions = { outputPath, linkWithLatestFwBuild } + + const tasks = getApiTasks(options) + return new Listr( + tasks.map((t) => mapToListrTask(t, options)), + { + exitOnError: true, + renderer: verbose ? 'verbose' : 'default', + rendererOptions: { collapseSubtasks: false }, + }, + ) +} + +export async function streamingTasks( + outputPath: string, + { verbose }: { verbose?: boolean }, +) { + const tasks: ListrTask[] = [ + { + title: 'Creating Delayed suspense delayed page', + task: async () => { + await createBuilder('yarn cedar g page')('delayed') + return applyCodemod( + 'delayedPage.js', + fullPath('web/src/pages/DelayedPage/DelayedPage'), + ) + }, + }, + { + title: 'Enable streaming-ssr experiment', + task: async () => { + await createBuilder('yarn cedar experimental setup-streaming-ssr')( + '--force', + ) + }, + }, + ] + + return new Listr(tasks, { + exitOnError: true, + renderer: verbose ? 'verbose' : 'default', + rendererOptions: { collapseSubtasks: false }, + }) +} + +export async function fragmentsTasks( + outputPath: string, + { verbose }: { verbose?: boolean }, +) { + const options: CommonTaskOptions = { outputPath } + const tasks: ListrTask[] = [ + { + title: 'Enable fragments', + task: async () => { + const tomlPath = path.join(outputPath, 'redwood.toml') + const content = fs.readFileSync(tomlPath, 'utf-8') + fs.writeFileSync( + tomlPath, + content + '\n[graphql]\n fragments = true\n', + ) + }, + }, + { + title: 'Adding produce and stall models', + task: async () => { + const { produce, stall } = await import('./codemods/models.mjs') + await addModel(outputPath, produce) + await addModel(outputPath, stall) + return exec( + 'yarn cedar prisma migrate dev --name create_produce_stall', + [], + getExecaOptions(outputPath), + ) + }, + }, + ] + + return new Listr(tasks, { + exitOnError: true, + renderer: verbose ? 'verbose' : 'default', + }) +} diff --git a/tasks/test-project/test-project.mts b/tasks/test-project/test-project.mts index 928afe6494..40b8f8eddb 100644 --- a/tasks/test-project/test-project.mts +++ b/tasks/test-project/test-project.mts @@ -10,8 +10,8 @@ import { rimraf } from 'rimraf' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -import { apiTasks, streamingTasks, webTasks } from './tasks.js' -import { confirmNoFixtureNoLink, getExecaOptions, getCfwBin } from './util.js' +import { apiTasks, streamingTasks, webTasks } from './tasks.mjs' +import { confirmNoFixtureNoLink, getExecaOptions, getCfwBin } from './util.mjs' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) diff --git a/tasks/test-project/tui-tasks.mts b/tasks/test-project/tui-tasks.mts new file mode 100644 index 0000000000..f15a83314b --- /dev/null +++ b/tasks/test-project/tui-tasks.mts @@ -0,0 +1,71 @@ +import type { ListrTask } from 'listr2' + +import { + getWebTasks, + getApiTasks, + type HighLevelTask, + type CommonTaskOptions, +} from './base-tasks.mjs' +import { setOutputPath } from './util.mjs' + +interface WebTasksOptions { + linkWithLatestFwBuild?: boolean +} + +function mapToTuiTask(t: HighLevelTask, options: CommonTaskOptions): ListrTask { + const enabled = + typeof t.enabled === 'function' ? t.enabled(options) : t.enabled + + return { + title: t.title, + task: async () => { + if (t.tasksGetter) { + return t.tasksGetter(options) + } + + if (t.task) { + return t.task(options) + } + + throw new Error('Unexpected task') + }, + enabled, + } +} + +export async function webTasks( + outputPath: string, + _options?: WebTasksOptions, +): Promise { + setOutputPath(outputPath) + const options: CommonTaskOptions = { + outputPath, + isFixture: true, + stdio: 'pipe', + } + + const tasks = getWebTasks(options) + return tasks.map((t) => mapToTuiTask(t, options)) +} + +interface ApiTasksOptions { + linkWithLatestFwBuild?: boolean + esmProject?: boolean +} + +export async function apiTasks( + outputPath: string, + { linkWithLatestFwBuild = false, esmProject = false }: ApiTasksOptions = {}, +): Promise { + setOutputPath(outputPath) + const options: CommonTaskOptions = { + outputPath, + isFixture: true, + linkWithLatestFwBuild, + esmProject, + stdio: 'pipe', + } + + const tasks = getApiTasks(options) + return tasks.map((t) => mapToTuiTask(t, options)) +} diff --git a/tasks/test-project/tui-tasks.ts b/tasks/test-project/tui-tasks.ts deleted file mode 100644 index ed8b3a587f..0000000000 --- a/tasks/test-project/tui-tasks.ts +++ /dev/null @@ -1,1079 +0,0 @@ -/* eslint-env node, es2021*/ - -import fs from 'node:fs' -import path from 'node:path' - -import type { Options as ExecaOptions } from 'execa' - -import type { TuiTaskList } from './typing.js' -import { - getExecaOptions as utilGetExecaOptions, - updatePkgJsonScripts, - exec, - getCfwBin, -} from './util.js' - -function getExecaOptions(cwd: string): ExecaOptions { - return { ...utilGetExecaOptions(cwd), stdio: 'pipe' } -} - -// This variable gets used in other functions -// and is set when webTasks or apiTasks are called -let OUTPUT_PATH: string - -const RW_FRAMEWORK_PATH = path.join(__dirname, '../../') - -function fullPath(name: string, { addExtension } = { addExtension: true }) { - if (addExtension) { - if (name.startsWith('api')) { - name += '.ts' - } else if (name.startsWith('web')) { - name += '.tsx' - } - } - - return path.join(OUTPUT_PATH, name) -} - -// TODO: Import from ./util.js when everything is using @rwjs/tui -async function applyCodemod(codemod: string, target: string) { - const args = [ - '--fail-on-error', - '-t', - `${path.resolve(__dirname, 'codemods', codemod)} ${target}`, - '--parser', - 'tsx', - '--verbose=2', - ] - - args.push() - - const subprocess = exec( - 'yarn jscodeshift', - args, - getExecaOptions(path.resolve(__dirname)), - ) - - return subprocess -} - -/** - * @param cmd The command to run - */ -function createBuilder(cmd: string, dir = '') { - const execaOptions = getExecaOptions(path.join(OUTPUT_PATH, dir)) - - return function (positionalArguments?: string | string[]) { - const subprocess = exec( - cmd, - Array.isArray(positionalArguments) - ? positionalArguments - : [positionalArguments], - execaOptions, - ) - - return subprocess - } -} - -export async function webTasks(outputPath: string) { - OUTPUT_PATH = outputPath - - const execaOptions = getExecaOptions(outputPath) - - const createPages = async () => { - // Passing 'web' here to test executing 'yarn redwood' in the /web directory - // to make sure it works as expected. We do the same for the /api directory - // further down in this file. - const createPage = createBuilder('yarn cedar g page', 'web') - - const tuiTaskList: TuiTaskList = [ - { - title: 'Creating home page', - task: async () => { - await createPage('home /') - - await applyCodemod( - 'homePage.js', - fullPath('web/src/pages/HomePage/HomePage'), - ) - }, - }, - { - title: 'Creating about page', - task: async () => { - await createPage('about') - - await applyCodemod( - 'aboutPage.js', - fullPath('web/src/pages/AboutPage/AboutPage'), - ) - }, - }, - { - title: 'Creating contact page', - task: async () => { - await createPage('contactUs /contact') - - await applyCodemod( - 'contactUsPage.js', - fullPath('web/src/pages/ContactUsPage/ContactUsPage'), - ) - }, - }, - { - title: 'Creating blog post page', - task: async () => { - await createPage('blogPost /blog-post/{id:Int}') - - await applyCodemod( - 'blogPostPage.js', - fullPath('web/src/pages/BlogPostPage/BlogPostPage'), - ) - - return applyCodemod( - 'updateBlogPostPageStories.js', - fullPath('web/src/pages/BlogPostPage/BlogPostPage.stories'), - ) - }, - }, - { - title: 'Creating profile page', - task: async () => { - await createPage('profile /profile') - - // Update the profile page test - const testFileContent = `import { render, waitFor, screen } from '@cedarjs/testing/web' - - import ProfilePage from './ProfilePage' - - describe('ProfilePage', () => { - it('renders successfully', async () => { - mockCurrentUser({ - email: 'danny@bazinga.com', - id: 84849020, - roles: 'BAZINGA', - }) - - await waitFor(async () => { - expect(() => { - render() - }).not.toThrow() - }) - - expect(await screen.findByText('danny@bazinga.com')).toBeInTheDocument() - }) - }) - ` - - fs.writeFileSync( - fullPath('web/src/pages/ProfilePage/ProfilePage.test'), - testFileContent, - ) - - return applyCodemod( - 'profilePage.js', - fullPath('web/src/pages/ProfilePage/ProfilePage'), - ) - }, - }, - { - title: 'Creating MDX Storybook stories', - task: () => { - const cedarMdxStoryContent = fs.readFileSync( - `${path.resolve(__dirname, 'codemods', 'CedarJS.mdx')}`, - ) - - fs.writeFileSync( - fullPath('web/src/CedarJS.mdx', { addExtension: false }), - cedarMdxStoryContent, - ) - - return - }, - }, - { - title: 'Creating nested cells test page', - task: async () => { - await createPage('waterfall {id:Int}') - - await applyCodemod( - 'waterfallPage.js', - fullPath('web/src/pages/WaterfallPage/WaterfallPage'), - ) - - await applyCodemod( - 'updateWaterfallPageStories.js', - fullPath('web/src/pages/WaterfallPage/WaterfallPage.stories'), - ) - }, - }, - ] - - return tuiTaskList - } - - const createLayout = async () => { - const createLayout = createBuilder('yarn cedar g layout') - - await createLayout('blog') - - return applyCodemod( - 'blogLayout.js', - fullPath('web/src/layouts/BlogLayout/BlogLayout'), - ) - } - - const createComponents = async () => { - const createComponent = createBuilder('yarn cedar g component') - - await createComponent('blogPost') - - await applyCodemod( - 'blogPost.js', - fullPath('web/src/components/BlogPost/BlogPost'), - ) - - await createComponent('author') - - await applyCodemod( - 'author.js', - fullPath('web/src/components/Author/Author'), - ) - - await applyCodemod( - 'updateAuthorStories.js', - fullPath('web/src/components/Author/Author.stories'), - ) - - await applyCodemod( - 'updateAuthorTest.js', - fullPath('web/src/components/Author/Author.test'), - ) - - await createComponent('classWithClassField') - - await applyCodemod( - 'classWithClassField.ts', - fullPath('web/src/components/ClassWithClassField/ClassWithClassField'), - ) - } - - const createCells = async () => { - const createCell = createBuilder('yarn cedar g cell') - - await createCell('blogPosts') - - await applyCodemod( - 'blogPostsCell.js', - fullPath('web/src/components/BlogPostsCell/BlogPostsCell'), - ) - - await createCell('blogPost') - - await applyCodemod( - 'blogPostCell.js', - fullPath('web/src/components/BlogPostCell/BlogPostCell'), - ) - - await createCell('author') - - await applyCodemod( - 'authorCell.js', - fullPath('web/src/components/AuthorCell/AuthorCell'), - ) - - await createCell('waterfallBlogPost') - - return applyCodemod( - 'waterfallBlogPostCell.js', - fullPath( - 'web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell', - ), - ) - } - - const updateCellMocks = async () => { - await applyCodemod( - 'updateBlogPostMocks.js', - fullPath('web/src/components/BlogPostCell/BlogPostCell.mock.ts', { - addExtension: false, - }), - ) - - await applyCodemod( - 'updateBlogPostMocks.js', - fullPath('web/src/components/BlogPostsCell/BlogPostsCell.mock.ts', { - addExtension: false, - }), - ) - - await applyCodemod( - 'updateAuthorCellMock.js', - fullPath('web/src/components/AuthorCell/AuthorCell.mock.ts', { - addExtension: false, - }), - ) - - return applyCodemod( - 'updateWaterfallBlogPostMocks.js', - fullPath( - 'web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.mock.ts', - { - addExtension: false, - }, - ), - ) - } - - const tuiTaskList: TuiTaskList = [ - { - title: 'Creating pages', - task: () => createPages(), - }, - { - title: 'Creating layout', - task: () => createLayout(), - }, - { - title: 'Creating components', - task: () => createComponents(), - }, - { - title: 'Creating cells', - task: () => createCells(), - }, - { - title: 'Updating cell mocks', - task: () => updateCellMocks(), - }, - { - title: 'Changing routes', - task: () => applyCodemod('routes.js', fullPath('web/src/Routes')), - }, - { - title: 'Adding Tailwind', - task: async () => { - await exec('yarn cedar setup ui tailwindcss', ['--force'], execaOptions) - }, - }, - ] //, - // TODO: Figure out what to do with this. It's from Listr, but TUI doesn't - // have anything like it (yet?) - // { - // exitOnError: true, - // renderer: verbose && 'verbose', - // } - - return tuiTaskList -} - -async function addModel(schema: string) { - const path = `${OUTPUT_PATH}/api/db/schema.prisma` - - const current = fs.readFileSync(path, 'utf-8') - - fs.writeFileSync(path, `${current.trim()}\n\n${schema}\n`) -} - -interface ApiTasksOptions { - linkWithLatestFwBuild: boolean - esmProject: boolean -} - -export async function apiTasks( - outputPath: string, - { linkWithLatestFwBuild, esmProject }: ApiTasksOptions, -) { - OUTPUT_PATH = outputPath - - const execaOptions = getExecaOptions(outputPath) - - const addDbAuth = async () => { - // Temporarily disable postinstall script - updatePkgJsonScripts({ - projectPath: outputPath, - scripts: { - postinstall: '', - }, - }) - - // We want to use the latest version of the auth-dbauth-{setup,api,web} - // packages. But they're not published yet. So let's package them up as - // tarballs and install them using that by setting yarn resolutions - - const setupPkg = path.join( - RW_FRAMEWORK_PATH, - 'packages', - 'auth-providers', - 'dbAuth', - 'setup', - ) - const apiPkg = path.join( - RW_FRAMEWORK_PATH, - 'packages', - 'auth-providers', - 'dbAuth', - 'api', - ) - const webPkg = path.join( - RW_FRAMEWORK_PATH, - 'packages', - 'auth-providers', - 'dbAuth', - 'web', - ) - - await exec('yarn build:pack', [], getExecaOptions(setupPkg)) - await exec('yarn build:pack', [], getExecaOptions(apiPkg)) - await exec('yarn build:pack', [], getExecaOptions(webPkg)) - - const setupTgz = path.join(setupPkg, 'cedarjs-auth-dbauth-setup.tgz') - const apiTgz = path.join(apiPkg, 'cedarjs-auth-dbauth-api.tgz') - const webTgz = path.join(webPkg, 'cedarjs-auth-dbauth-web.tgz') - - const setupTgzDest = path.join(outputPath, 'cedarjs-auth-dbauth-setup.tgz') - const apiTgzDest = path.join(outputPath, 'cedarjs-auth-dbauth-api.tgz') - const webTgzDest = path.join(outputPath, 'cedarjs-auth-dbauth-web.tgz') - - fs.copyFileSync(setupTgz, setupTgzDest) - fs.copyFileSync(apiTgz, apiTgzDest) - fs.copyFileSync(webTgz, webTgzDest) - - const projectPackageJsonPath = path.join(outputPath, 'package.json') - const projectPackageJson = JSON.parse( - fs.readFileSync(projectPackageJsonPath, 'utf-8'), - ) - - const existingResolutions = projectPackageJson.resolutions - ? { ...projectPackageJson.resolutions } - : undefined - - projectPackageJson.resolutions ??= {} - projectPackageJson.resolutions = { - ...projectPackageJson.resolutions, - '@cedarjs/auth-dbauth-setup': './cedarjs-auth-dbauth-setup.tgz', - '@cedarjs/auth-dbauth-api': './cedarjs-auth-dbauth-api.tgz', - '@cedarjs/auth-dbauth-web': './cedarjs-auth-dbauth-web.tgz', - } - - fs.writeFileSync( - projectPackageJsonPath, - JSON.stringify(projectPackageJson, null, 2), - ) - - // Run `yarn install` to have the resolutions take effect and install the - // tarballs we copied over - await exec('yarn install', [], execaOptions) - - await exec( - 'yarn cedar setup auth dbAuth --force --no-webauthn --no-createUserModel --no-generateAuthPages', - [], - execaOptions, - ) - - // Restore old resolutions - if (existingResolutions) { - projectPackageJson.resolutions = existingResolutions - } - - fs.writeFileSync( - projectPackageJsonPath, - JSON.stringify(projectPackageJson, null, 2), - ) - - // Remove tarballs - fs.unlinkSync(setupTgzDest) - fs.unlinkSync(apiTgzDest) - fs.unlinkSync(webTgzDest) - - // Restore postinstall script - updatePkgJsonScripts({ - projectPath: outputPath, - scripts: { - postinstall: `yarn ${getCfwBin(outputPath)} project:copy`, - }, - }) - - if (linkWithLatestFwBuild) { - await exec(`yarn ${getCfwBin(outputPath)} project:copy`, [], execaOptions) - } - - await exec( - 'yarn cedar g dbAuth --no-webauthn --username-label=username --password-label=password', - [], - execaOptions, - ) - - // update directive in contacts.sdl.ts - const pathContactsSdl = `${OUTPUT_PATH}/api/src/graphql/contacts.sdl.ts` - const contentContactsSdl = fs.readFileSync(pathContactsSdl, 'utf-8') - const resultsContactsSdl = contentContactsSdl - .replace( - 'createContact(input: CreateContactInput!): Contact! @requireAuth', - `createContact(input: CreateContactInput!): Contact @skipAuth`, - ) - .replace( - /deleteContact\(id: Int!\): Contact! @requireAuth(?=\s)/, - 'deleteContact(id: Int!): Contact! @requireAuth(roles:["ADMIN"])', - ) // make deleting contacts admin only - fs.writeFileSync(pathContactsSdl, resultsContactsSdl) - - // update directive in posts.sdl.ts - const pathPostsSdl = `${OUTPUT_PATH}/api/src/graphql/posts.sdl.ts` - const contentPostsSdl = fs.readFileSync(pathPostsSdl, 'utf-8') - const resultsPostsSdl = contentPostsSdl.replace( - /posts: \[Post!\]! @requireAuth([^}]*)@requireAuth/, - `posts: [Post!]! @skipAuth - post(id: Int!): Post @skipAuth`, - ) // make posts accessible to all - - fs.writeFileSync(pathPostsSdl, resultsPostsSdl) - - // Update src/lib/auth to return roles, so tsc doesn't complain - const libAuthPath = `${OUTPUT_PATH}/api/src/lib/auth.ts` - const libAuthContent = fs.readFileSync(libAuthPath, 'utf-8') - - const newLibAuthContent = libAuthContent - .replace( - 'select: { id: true }', - 'select: { id: true, roles: true, email: true}', - ) - .replace( - 'const currentUserRoles = context.currentUser?.roles', - 'const currentUserRoles = context.currentUser?.roles as string | string[]', - ) - fs.writeFileSync(libAuthPath, newLibAuthContent) - - // update requireAuth test - const pathRequireAuth = `${OUTPUT_PATH}/api/src/directives/requireAuth/requireAuth.test.ts` - const contentRequireAuth = fs.readFileSync(pathRequireAuth).toString() - const resultsRequireAuth = contentRequireAuth.replace( - /const mockExecution([^}]*){} }\)/, - `const mockExecution = mockRedwoodDirective(requireAuth, { - context: { currentUser: { id: 1, roles: 'ADMIN', email: 'b@zinga.com' } }, - })`, - ) - fs.writeFileSync(pathRequireAuth, resultsRequireAuth) - - // add fullName input to signup form - const pathSignupPageTs = `${OUTPUT_PATH}/web/src/pages/SignupPage/SignupPage.tsx` - const contentSignupPageTs = fs.readFileSync(pathSignupPageTs, 'utf-8') - const usernameFields = contentSignupPageTs.match( - /\s*/, - )?.[0] - const fullNameFields = usernameFields - ?.replace(/\s*ref=\{usernameRef}/, '') - ?.replaceAll('username', 'full-name') - ?.replaceAll('Username', 'Full Name') - - const newContentSignupPageTs = contentSignupPageTs - .replace( - '', - '\n' + - fullNameFields, - ) - // include full-name in the data we pass to `signUp()` - .replace( - 'password: data.password', - "password: data.password, 'full-name': data['full-name']", - ) - - fs.writeFileSync(pathSignupPageTs, newContentSignupPageTs) - - // set fullName when signing up - const pathAuthTs = `${OUTPUT_PATH}/api/src/functions/auth.ts` - const contentAuthTs = fs.readFileSync(pathAuthTs).toString() - const resultsAuthTs = contentAuthTs - .replace('name: string', "'full-name': string") - .replace('userAttributes: _userAttributes', 'userAttributes') - .replace( - '// name: userAttributes.name', - "fullName: userAttributes['full-name']", - ) - - fs.writeFileSync(pathAuthTs, resultsAuthTs) - } - - // add prerender to some routes - const addPrerender = async () => { - const tuiTaskList: TuiTaskList = [ - { - // We need to do this here, and not where we create the other pages, to - // keep it outside of BlogLayout - title: 'Creating double rendering test page', - task: async () => { - const createPage = createBuilder('yarn cedar g page') - await createPage('double') - - const doublePageContent = `import { Metadata } from '@cedarjs/web' - - import test from './test.png' - - const DoublePage = () => { - return ( - <> - - -

DoublePage

-

- This page exists to make sure we don't regress on{' '} - - #7757 - -

-

For RW#7757 it needs to be a page that is not wrapped in a Set

-

- We also use this page to make sure we don't regress on{' '} - - #317 - -

- Test - - ) - } - - export default DoublePage` - - fs.writeFileSync( - fullPath('web/src/pages/DoublePage/DoublePage'), - doublePageContent, - ) - fs.copyFileSync( - fullPath('web/public/favicon.png', { addExtension: false }), - fullPath('web/src/pages/DoublePage/test.png', { - addExtension: false, - }), - ) - }, - }, - { - title: 'Update Routes.tsx', - task: () => { - const pathRoutes = `${OUTPUT_PATH}/web/src/Routes.tsx` - const contentRoutes = fs.readFileSync(pathRoutes).toString() - const resultsRoutesAbout = contentRoutes.replace( - /name="about"/, - `name="about" prerender`, - ) - const resultsRoutesHome = resultsRoutesAbout.replace( - /name="home"/, - `name="home" prerender`, - ) - const resultsRoutesBlogPost = resultsRoutesHome.replace( - /name="blogPost"/, - `name="blogPost" prerender`, - ) - const resultsRoutesNotFound = resultsRoutesBlogPost.replace( - /page={NotFoundPage}/, - `page={NotFoundPage} prerender`, - ) - const resultsRoutesWaterfall = resultsRoutesNotFound.replace( - /page={WaterfallPage}/, - `page={WaterfallPage} prerender`, - ) - const resultsRoutesDouble = resultsRoutesWaterfall.replace( - 'name="double"', - 'name="double" prerender', - ) - const resultsRoutesNewContact = resultsRoutesDouble.replace( - 'name="newContact"', - 'name="newContact" prerender', - ) - fs.writeFileSync(pathRoutes, resultsRoutesNewContact) - - const blogPostRouteHooks = `import { db } from '$api/src/lib/db.js' - - export async function routeParameters() { - return (await db.post.findMany({ take: 7 })).map((post) => ({ id: post.id })) - }` - const blogPostRouteHooksPath = `${OUTPUT_PATH}/web/src/pages/BlogPostPage/BlogPostPage.routeHooks.ts` - fs.writeFileSync(blogPostRouteHooksPath, blogPostRouteHooks) - - const waterfallRouteHooks = `export async function routeParameters() { - return [{ id: 2 }] - }` - const waterfallRouteHooksPath = `${OUTPUT_PATH}/web/src/pages/WaterfallPage/WaterfallPage.routeHooks.ts` - fs.writeFileSync(waterfallRouteHooksPath, waterfallRouteHooks) - }, - }, - ] - - return tuiTaskList - } - - const generateScaffold = createBuilder('yarn cedar g scaffold') - - const tuiTaskList: TuiTaskList = [ - { - title: 'Adding post and user model to prisma', - task: async () => { - // Need both here since they have a relation - const { post, user } = await import('./codemods/models.js') - - addModel(post) - addModel(user) - - return exec( - `yarn cedar prisma migrate dev --name create_post_user`, - [], - execaOptions, - ) - }, - }, - { - title: 'Scaffolding post', - task: async () => { - await generateScaffold('post') - - // Replace the random numbers in the scenario with consistent values - await applyCodemod( - 'scenarioValueSuffix.js', - fullPath('api/src/services/posts/posts.scenarios'), - ) - - await exec( - `yarn ${getCfwBin(OUTPUT_PATH)} project:copy`, - [], - execaOptions, - ) - }, - }, - { - title: 'Adding seed script', - task: async () => { - await applyCodemod( - 'seed.js', - fullPath('scripts/seed.ts', { addExtension: false }), - ) - }, - }, - { - title: 'Adding contact model to prisma', - task: async () => { - const { contact } = await import('./codemods/models.js') - - addModel(contact) - - await exec( - `yarn cedar prisma migrate dev --name create_contact`, - [], - execaOptions, - ) - - await generateScaffold('contacts') - - const contactsServicePath = fullPath( - 'api/src/services/contacts/contacts', - ) - fs.writeFileSync( - contactsServicePath, - fs - .readFileSync(contactsServicePath, 'utf-8') - .replace( - "import { db } from 'src/lib/db'", - '// Testing aliased imports with extensions\n' + - "import { db } from 'src/lib/db.js'", - ), - ) - }, - }, - { - // This task renames the migration folders so that we don't have to deal with duplicates/conflicts when committing to the repo - title: 'Adjust dates within migration folder names', - task: () => { - const migrationsFolderPath = path.join( - OUTPUT_PATH, - 'api', - 'db', - 'migrations', - ) - // Migration folders are folders which start with 14 digits because they have a yyyymmddhhmmss - const migrationFolders = fs - .readdirSync(migrationsFolderPath) - .filter((name) => { - return ( - name.match(/\d{14}.+/) && - fs.lstatSync(path.join(migrationsFolderPath, name)).isDirectory() - ) - }) - .sort() - const datetime = new Date('2022-01-01T12:00:00.000Z') - migrationFolders.forEach((name) => { - const datetimeInCorrectFormat = - datetime.getFullYear() + - ('0' + (datetime.getMonth() + 1)).slice(-2) + - ('0' + datetime.getDate()).slice(-2) + - '120000' // Time hardcoded to 12:00:00 to limit TZ issues - fs.renameSync( - path.join(migrationsFolderPath, name), - path.join( - migrationsFolderPath, - `${datetimeInCorrectFormat}${name.substring(14)}`, - ), - ) - datetime.setDate(datetime.getDate() + 1) - }) - }, - }, - { - title: 'Add users service', - task: async () => { - const generateSdl = createBuilder('yarn cedar g sdl --no-crud', 'api') - - await generateSdl('user') - - await applyCodemod('usersSdl.js', fullPath('api/src/graphql/users.sdl')) - - await applyCodemod( - 'usersService.js', - fullPath('api/src/services/users/users'), - ) - - // Replace the random numbers in the scenario with consistent values - await applyCodemod( - 'scenarioValueSuffix.js', - fullPath('api/src/services/users/users.scenarios'), - ) - - const test = `import { user } from './users.js' - import type { StandardScenario } from './users.scenarios.js' - - describe('users', () => { - scenario('returns a single user', async (scenario: StandardScenario) => { - const result = await user({ id: scenario.user.one.id }) - - expect(result).toEqual(scenario.user.one) - }) - })`.replaceAll(/ {12}/g, '') - - fs.writeFileSync(fullPath('api/src/services/users/users.test'), test) - - return createBuilder('yarn cedar g types')() - }, - }, - { - title: 'Add dbAuth', - task: async () => addDbAuth(), - }, - { - title: 'Add describeScenario tests', - task: () => { - // Copy contact.scenarios.ts, because scenario tests look for the same filename - fs.copyFileSync( - fullPath('api/src/services/contacts/contacts.scenarios'), - fullPath('api/src/services/contacts/describeContacts.scenarios'), - ) - - // Create describeContacts.test.ts - const describeScenarioFixture = path.join( - __dirname, - 'templates', - 'api', - 'contacts.describeScenario.test.ts.template', - ) - - fs.copyFileSync( - describeScenarioFixture, - fullPath('api/src/services/contacts/describeContacts.test'), - ) - }, - }, - { - // This is probably more of a web side task really, but the scaffolded - // pages aren't generated until we get here to the api side tasks. So - // instead of doing some up in the web side tasks, and then the rest - // here I decided to move all of them here - title: 'Add Prerender to Routes', - task: () => addPrerender(), - }, - { - title: 'Add context tests', - task: () => { - const templatePath = path.join( - __dirname, - 'templates', - 'api', - 'context.test.ts.template', - ) - const projectPath = path.join( - OUTPUT_PATH, - 'api', - 'src', - '__tests__', - 'context.test.ts', - ) - - fs.mkdirSync(path.dirname(projectPath), { recursive: true }) - fs.writeFileSync(projectPath, fs.readFileSync(templatePath)) - }, - }, - { - title: 'Add vitest db import tracking tests for ESM test project', - task: () => { - if (!esmProject) { - return - } - - const templatesDir = path.join(__dirname, 'templates', 'api') - const templatePath1 = path.join(templatesDir, '1-db-import.test.ts') - const templatePath2 = path.join(templatesDir, '2-db-import.test.ts') - const templatePath3 = path.join(templatesDir, '3-db-import.test.ts') - - const testsDir = path.join(OUTPUT_PATH, 'api', 'src', '__tests__') - const testFilePath1 = path.join(testsDir, '1-db-import.test.ts') - const testFilePath2 = path.join(testsDir, '2-db-import.test.ts') - const testFilePath3 = path.join(testsDir, '3-db-import.test.ts') - - fs.mkdirSync(testsDir, { recursive: true }) - fs.copyFileSync(templatePath1, testFilePath1) - fs.copyFileSync(templatePath2, testFilePath2) - fs.copyFileSync(templatePath3, testFilePath3) - - // I opted to add an additional vitest config file rather than modifying - // the existing one because I wanted to keep one looking exactly the - // same as it'll look in user's projects. - fs.copyFileSync( - path.join(templatesDir, 'vitest-sort.config.ts'), - path.join(OUTPUT_PATH, 'api', 'vitest-sort.config.ts'), - ) - }, - }, - ] - // ], - // TODO: Figure out what to do with this. It's from Listr, but TUI doesn't - // have anything like it (yet?) - // { - // exitOnError: true, - // renderer: verbose && 'verbose', - // renderOptions: { collapseSubtasks: false }, - // } - return tuiTaskList -} - -/** - * Tasks to add GraphQL Fragments support to the test-project, and some queries - * to test fragments - */ -export async function fragmentsTasks(outputPath: string) { - OUTPUT_PATH = outputPath - - const tuiTaskList: TuiTaskList = [ - { - title: 'Enable fragments', - task: async () => { - const redwoodTomlPath = path.join(outputPath, 'redwood.toml') - const redwoodToml = fs.readFileSync(redwoodTomlPath).toString() - const newRedwoodToml = redwoodToml + '\n[graphql]\n fragments = true\n' - fs.writeFileSync(redwoodTomlPath, newRedwoodToml) - }, - }, - { - title: 'Adding produce and stall models to prisma', - task: async () => { - // Need both here since they have a relation - const { produce, stall } = await import('./codemods/models.js') - - addModel(produce) - addModel(stall) - - return exec( - 'yarn cedar prisma migrate dev --name create_produce_stall', - [], - getExecaOptions(outputPath), - ) - }, - }, - { - title: 'Seed fragments data', - task: async () => { - await applyCodemod( - 'seedFragments.ts', - fullPath('scripts/seed.ts', { addExtension: false }), - ) - - await exec('yarn cedar prisma db seed', [], getExecaOptions(outputPath)) - }, - }, - { - title: 'Generate SDLs for produce and stall', - task: async () => { - const generateSdl = createBuilder('yarn cedar g sdl') - - await generateSdl('stall') - await generateSdl('produce') - - await applyCodemod( - 'producesSdl.ts', - fullPath('api/src/graphql/produces.sdl'), - ) - }, - }, - { - title: 'Copy components from templates', - task: () => { - const templatesPath = path.join(__dirname, 'templates', 'web') - const componentsPath = path.join( - OUTPUT_PATH, - 'web', - 'src', - 'components', - ) - - for (const fileName of [ - 'Card.tsx', - 'FruitInfo.tsx', - 'ProduceInfo.tsx', - 'StallInfo.tsx', - 'VegetableInfo.tsx', - ]) { - const templatePath = path.join(templatesPath, fileName) - const componentPath = path.join(componentsPath, fileName) - - fs.writeFileSync(componentPath, fs.readFileSync(templatePath)) - } - }, - }, - { - title: 'Copy sdl and service for groceries from templates', - task: () => { - const templatesPath = path.join(__dirname, 'templates', 'api') - const graphqlPath = path.join(OUTPUT_PATH, 'api', 'src', 'graphql') - const servicesPath = path.join(OUTPUT_PATH, 'api', 'src', 'services') - - const sdlTemplatePath = path.join(templatesPath, 'groceries.sdl.ts') - const sdlPath = path.join(graphqlPath, 'groceries.sdl.ts') - const serviceTemplatePath = path.join(templatesPath, 'groceries.ts') - const servicePath = path.join(servicesPath, 'groceries.ts') - - fs.writeFileSync(sdlPath, fs.readFileSync(sdlTemplatePath)) - fs.writeFileSync(servicePath, fs.readFileSync(serviceTemplatePath)) - }, - }, - { - title: 'Creating Groceries page', - task: async () => { - const createPage = createBuilder('yarn cedar g page') - await createPage('groceries') - - await applyCodemod( - 'groceriesPage.ts', - fullPath('web/src/pages/GroceriesPage/GroceriesPage'), - ) - }, - }, - ] - - return tuiTaskList -} diff --git a/tasks/test-project/typing.ts b/tasks/test-project/typing.mts similarity index 100% rename from tasks/test-project/typing.ts rename to tasks/test-project/typing.mts diff --git a/tasks/test-project/util.js b/tasks/test-project/util.mts similarity index 51% rename from tasks/test-project/util.js rename to tasks/test-project/util.mts index 52fbd09c49..d1b207ffb5 100644 --- a/tasks/test-project/util.js +++ b/tasks/test-project/util.mts @@ -1,13 +1,49 @@ -/* eslint-env node, es6*/ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' -const fs = require('node:fs') -const path = require('path') -const stream = require('stream') +import execa from 'execa' +import type { Options as ExecaOptions } from 'execa' +import prompts from 'prompts' -const execa = require('execa') -const prompts = require('prompts') +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) -async function applyCodemod(codemod, target) { +let OUTPUT_PATH: string +let VERBOSE = false + +export function setOutputPath(path: string) { + OUTPUT_PATH = path +} + +export function getOutputPath() { + return OUTPUT_PATH +} + +export function setVerbose(verbose: boolean) { + VERBOSE = verbose +} + +export function getVerbose() { + return VERBOSE +} + +export function fullPath( + name: string, + { addExtension } = { addExtension: true }, +) { + if (addExtension) { + if (name.startsWith('api')) { + name += '.ts' + } else if (name.startsWith('web')) { + name += '.tsx' + } + } + + return path.join(OUTPUT_PATH, name) +} + +export async function applyCodemod(codemod: string, target: string) { const args = [ '--fail-on-error', '-t', @@ -17,15 +53,12 @@ async function applyCodemod(codemod, target) { '--verbose=2', ] - args.push() - await exec('yarn jscodeshift', args, getExecaOptions(path.resolve(__dirname))) } -/** @type {(string) => import('execa').Options} */ -const getExecaOptions = (cwd) => ({ +export const getExecaOptions = (cwd: string): ExecaOptions => ({ shell: true, - stdio: 'inherit', + stdio: VERBOSE ? 'inherit' : 'pipe', cleanup: true, cwd, env: { @@ -35,7 +68,13 @@ const getExecaOptions = (cwd) => ({ }, }) -const updatePkgJsonScripts = ({ projectPath, scripts }) => { +export const updatePkgJsonScripts = ({ + projectPath, + scripts, +}: { + projectPath: string + scripts: Record +}) => { const projectPackageJsonPath = path.join(projectPath, 'package.json') const projectPackageJson = JSON.parse( fs.readFileSync(projectPackageJsonPath, 'utf-8'), @@ -51,7 +90,10 @@ const updatePkgJsonScripts = ({ projectPath, scripts }) => { } // Confirmation prompt when using --no-copyFromFixture --no-link' -async function confirmNoFixtureNoLink(copyFromFixtureOption, linkOption) { +export async function confirmNoFixtureNoLink( + copyFromFixtureOption: boolean, + linkOption: boolean, +) { if (!copyFromFixtureOption && !linkOption) { const { checkNoLink } = await prompts( { @@ -74,13 +116,20 @@ async function confirmNoFixtureNoLink(copyFromFixtureOption, linkOption) { } } -const nullStream = new stream.Writable() -nullStream._write = (_chunk, _encoding, next) => { - next() -} +export class ExecaError extends Error { + stdout: string + stderr: string + exitCode: number -class ExecaError extends Error { - constructor({ stdout, stderr, exitCode }) { + constructor({ + stdout, + stderr, + exitCode, + }: { + stdout: string + stderr: string + exitCode: number + }) { super(`execa failed with exit code ${exitCode}`) this.stdout = stdout this.stderr = stderr @@ -88,8 +137,12 @@ class ExecaError extends Error { } } -async function exec(...args) { - return execa(...args) +export async function exec( + file: string, + args?: string[], + options?: ExecaOptions, +) { + return execa(file, args ?? [], options) .then(({ stdout, stderr, exitCode }) => { if (exitCode !== 0) { throw new ExecaError({ stdout, stderr, exitCode }) @@ -102,27 +155,32 @@ async function exec(...args) { // Rethrow ExecaError throw error } else { - const { stdout, stderr, exitCode } = error + const { stdout = '', stderr = '', exitCode = 1 } = error throw new ExecaError({ stdout, stderr, exitCode }) } }) } +export function createBuilder(cmd: string, dir = '') { + return function (positionalArguments?: string | string[]) { + const execaOptions = getExecaOptions(path.join(OUTPUT_PATH, dir)) + + const args = Array.isArray(positionalArguments) + ? positionalArguments + : positionalArguments + ? [positionalArguments] + : [] + + const subprocess = exec(cmd, args, execaOptions) + + return subprocess + } +} + // TODO: Remove this as soon as cfw is part of a stable Cedar release, and then // instead just use `cfw` directly everywhere -function getCfwBin(projectPath) { +export function getCfwBin(projectPath: string) { return fs.existsSync(path.join(projectPath, 'node_modules/.bin/cfw')) ? 'cfw' : 'rwfw' } - -module.exports = { - getExecaOptions, - applyCodemod, - updatePkgJsonScripts, - confirmNoFixtureNoLink, - nullStream, - ExecaError, - exec, - getCfwBin, -}