From 446442c77525a26d98aa252a244f3fbf4ff08f06 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Mon, 8 Jan 2024 15:27:31 +0100 Subject: [PATCH 1/7] yarn rw setup graphql fragments --- docs/docs/cli-commands.md | 41 ++++ docs/docs/graphql/fragments.md | 28 ++- .../setup/graphql/features/fragments.ts | 22 ++ .../graphql/features/fragmentsHandler.ts | 217 ++++++++++++++++++ .../cli/src/commands/setup/graphql/graphql.ts | 17 ++ 5 files changed, 315 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments.ts create mode 100644 packages/cli/src/commands/setup/graphql/features/fragmentsHandler.ts create mode 100644 packages/cli/src/commands/setup/graphql/graphql.ts diff --git a/docs/docs/cli-commands.md b/docs/docs/cli-commands.md index 7e85cddf3516..bcdfda5f5391 100644 --- a/docs/docs/cli-commands.md +++ b/docs/docs/cli-commands.md @@ -1999,6 +1999,47 @@ We perform a simple compatibility check in an attempt to make you aware of poten It's the author of the npm package's responsibility to specify the correct compatibility range, so **you should always research the packages you use with this command**. Especially since they will be executing code on your machine! +### setup graphql + +This command creates the necessary files to support GraphQL features like trusted documents. + +#### Usage + +Run `yarn rw setup graphql ` + +#### setup graphql fragments + +This command creates the necessary configuration to start using [GraphQL Fragments](./graphql/fragments.md). + +``` +yarn redwood setup graphql fragments +``` + +| Arguments & Options | Description | +| :------------------ | :--------------------------------------- | +| `--force, -f` | Overwrite existing files and skip checks | + +#### Usage + +Run `yarn rw setup graphql fragments` + +#### Example + +```bash +~/redwood-app$ yarn rw setup graphql fragments +✔ Update Redwood Project Configuration to enable GraphQL fragments... +✔ Generating Trusted Documents store ... +✔ Configuring the GraphQL Handler to use a Trusted Documents store ... +``` + +If you have not setup the RedwoodJS server file, it will be setup: + +```bash +✔ Adding the experimental server file... +✔ Adding config to redwood.toml... +✔ Adding required api packages... +``` + ### setup realtime This command creates the necessary files, installs the required packages, and provides examples to setup RedwoodJS Realtime from GraphQL live queries and subscriptions. See the Realtime docs for more information. diff --git a/docs/docs/graphql/fragments.md b/docs/docs/graphql/fragments.md index e66041550247..71f693423fa4 100644 --- a/docs/docs/graphql/fragments.md +++ b/docs/docs/graphql/fragments.md @@ -83,6 +83,12 @@ With `registerFragment`, you can register a fragment with the registry and get b which can then be used to work with the registered fragment. +### Setup + +`yarn rw setup graphql fragments` + +See more in [cli commands - setup graphql fragments](../cli-commands.md#setup-graphql-fragments). + ### registerFragment To register a fragment, you can simply register it with `registerFragment`. @@ -200,7 +206,7 @@ the `getCacheKey` is a function where `getCacheKey(42)` would return `Book:42`. import { registerFragment } from '@redwoodjs/web/apollo' const { useRegisteredFragment } = registerFragment( -... + // ... ) ``` @@ -281,17 +287,19 @@ To make this easier to maintain, RedwoodJS GraphQL CodeGen automatically generat ```ts +// web/src/App.tsx + import possibleTypes from 'src/graphql/possibleTypes' -... -/// web/src/App.tsx - +// ... + +const graphQLClientConfig = { + cacheConfig: { + ...possibleTypes, + }, +} + + ``` To generate the `src/graphql/possibleTypes` file, enable fragments in `redwood.toml`: diff --git a/packages/cli/src/commands/setup/graphql/features/fragments.ts b/packages/cli/src/commands/setup/graphql/features/fragments.ts new file mode 100644 index 000000000000..a473168e6f94 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments.ts @@ -0,0 +1,22 @@ +import type { Argv } from 'yargs' + +export const command = 'fragments' +export const description = 'Set up Fragments for GraphQL' + +export function builder(yargs: Argv) { + return yargs.option('force', { + alias: 'f', + default: false, + description: 'Overwrite existing configuration', + type: 'boolean', + }) +} + +export interface Args { + force: boolean +} + +export async function handler({ force }: Args) { + const { handler } = await import('./fragmentsHandler.js') + return handler({ force }) +} diff --git a/packages/cli/src/commands/setup/graphql/features/fragmentsHandler.ts b/packages/cli/src/commands/setup/graphql/features/fragmentsHandler.ts new file mode 100644 index 000000000000..642747d35b0b --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragmentsHandler.ts @@ -0,0 +1,217 @@ +import fs from 'fs' + +import execa from 'execa' +import { Listr } from 'listr2' +import { format } from 'prettier' +import { Project, SyntaxKind } from 'ts-morph' + +import { + recordTelemetryAttributes, + prettierOptions, +} from '@redwoodjs/cli-helpers' +import { colors } from '@redwoodjs/cli-helpers' +import { getConfigPath, getPaths } from '@redwoodjs/project-config' + +import type { Args } from './fragments' + +export const command = 'fragments' +export const description = 'Set up Fragments for GraphQL' + +function addPossibleTypesImportToApp() { + return { + title: + 'Configuring the GraphQL Handler to use a Trusted Documents store ...', + task: async () => { + const project = new Project() + const appPath = getPaths().web.app + const appSourceFile = project.addSourceFileAtPathIfExists(appPath) + let appSourceFileChanged = false + + if (!appSourceFile) { + console.warn( + colors.warning( + `Unable to find the GraphQL Handler source file: ${appPath}` + ) + ) + return + } + + const imports = appSourceFile.getImportDeclarations() + + if ( + !imports.some( + (i) => i.getModuleSpecifierValue() === '../graphql/possibleTypes' + ) + ) { + appSourceFile.addImportDeclaration({ + moduleSpecifier: '../graphql/possibleTypes', + defaultImport: 'possibleTypes', + }) + + appSourceFileChanged = true + } + + if (appSourceFileChanged) { + await project.save() + const updatedHandler = fs.readFileSync(appPath, 'utf-8') + const prettifiedHandler = format(updatedHandler, { + ...prettierOptions(), + parser: 'babel-ts', + }) + fs.writeFileSync(getPaths().web.app, prettifiedHandler, 'utf-8') + } + }, + } +} + +function updateGraphQlCacheConfig() { + return { + title: + 'Configuring the GraphQL Handler to use a Trusted Documents store ...', + task: async () => { + const project = new Project() + const appPath = getPaths().web.app + const appSourceFile = project.addSourceFileAtPathIfExists(appPath) + const appSourceFileChanged = false + + if (!appSourceFile) { + console.warn( + colors.warning( + `Unable to find the GraphQL Handler source file: ${appPath}` + ) + ) + + return + } + + // Find the RedwoodApolloProvider component + const redwoodApolloProvider = appSourceFile + .getDescendantsOfKind(SyntaxKind.JsxOpeningElement) + .find((element) => element.getText() === '') + + if (!redwoodApolloProvider) { + console.warn( + colors.warning(`Unable to find in ${appPath}`) + ) + + return + } + + // Find the graphQLClientConfig prop + const graphQLClientConfigProp = redwoodApolloProvider + .getChildSyntaxList() + ?.getChildrenOfKind(SyntaxKind.JsxAttribute) + .find((attr) => attr.getName() === 'graphQLClientConfig') + + if (!graphQLClientConfigProp) { + console.warn( + colors.warning( + `Unable to find the graphQLClientConfig prop on in ${appPath}` + ) + ) + + return + } + + // Find the cacheConfig prop + const cacheConfig = graphQLClientConfigProp + .getChildSyntaxList() + ?.getChildrenOfKind(SyntaxKind.JsxAttribute) + .find((attr) => attr.getName() === 'cacheConfig') + + // Add possibleTypes to cacheConfig + const possibleTypesExpression = `possibleTypes,` + + if (cacheConfig) { + cacheConfig.replaceWithText((writer) => { + writer.write('cacheConfig: {') + writer.indent(() => { + writer.write('fragments: [],') + writer.write(possibleTypesExpression) + }) + writer.write('},') + }) + } + + // Save the changes + project.save() + + if (appSourceFileChanged) { + await project.save() + const updatedHandler = fs.readFileSync(appPath, 'utf-8') + const prettifiedHandler = format(updatedHandler, { + ...prettierOptions(), + parser: 'babel-ts', + }) + fs.writeFileSync(appPath, prettifiedHandler, 'utf-8') + } + }, + } +} + +export async function handler({ force }: Args) { + recordTelemetryAttributes({ + command: 'setup graphql fragments', + force, + }) + + const tasks = new Listr( + [ + { + title: + 'Update Redwood Project Configuration to enable GraphQL Fragments...', + skip: () => { + const redwoodTomlPath = getConfigPath() + + if (force) { + // Never skip when --force is used + return false + } + + const redwoodTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') + if (/\bfragments\s*=\s*true/.test(redwoodTomlContent)) { + return 'GraphQL Fragments are already enabled.' + } + + return false + }, + task: () => { + const redwoodTomlPath = getConfigPath() + const originalTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') + + const tomlToAppend = `[graphql]\n fragments = true` + + const newConfig = originalTomlContent + '\n' + tomlToAppend + + fs.writeFileSync(redwoodTomlPath, newConfig, 'utf-8') + }, + }, + { + title: 'Generate possibleTypes.ts...', + task: () => { + execa.commandSync('yarn redwood generate types', { stdio: 'ignore' }) + }, + }, + { + title: 'Import possibleTypes in App.tsx...', + task: () => { + return addPossibleTypesImportToApp() + }, + }, + { + title: 'Add possibleTypes to the GraphQL cache config', + task: () => { + return updateGraphQlCacheConfig() + }, + }, + ], + { rendererOptions: { collapseSubtasks: false } } + ) + + try { + await tasks.run() + } catch (e: any) { + console.error(colors.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/setup/graphql/graphql.ts b/packages/cli/src/commands/setup/graphql/graphql.ts new file mode 100644 index 000000000000..11ef381b3a29 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/graphql.ts @@ -0,0 +1,17 @@ +import terminalLink from 'terminal-link' +import type { Argv } from 'yargs' + +import * as fragmentsCommand from './features/fragments' + +export const command = 'graphql ' +export const description = 'Set up GraphQL feature support' +export function builder(yargs: Argv) { + return yargs + .command(fragmentsCommand) + .epilogue( + `Also see the ${terminalLink( + 'Redwood CLI Reference', + 'https://redwoodjs.com/docs/cli-commands#setup-graphql' + )}` + ) +} From c7015d06dc4ec92f659b1173ec20240e6d9228be Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Mon, 8 Jan 2024 21:14:22 +0100 Subject: [PATCH 2/7] Rewrite usign jscodeshift and add tests --- packages/cli-helpers/src/lib/index.ts | 12 +- packages/cli/jest.config.js | 9 - packages/cli/jest.config.ts | 39 +++ .../appGqlConfigTransform.test.ts | 43 +++ .../appImportTransform.test.ts | 13 + .../config-simple/input/App.tsx | 22 ++ .../config-simple/output/App.tsx | 28 ++ .../existingImport/input/App.tsx | 22 ++ .../existingImport/output/App.tsx | 22 ++ .../existingPropInline/input/App.tsx | 35 +++ .../existingPropInline/output/App.tsx | 38 +++ .../existingPropVariable/input/App.tsx | 37 +++ .../existingPropVariable/output/App.tsx | 38 +++ .../input/App.tsx | 30 ++ .../output/App.tsx | 34 +++ .../input/App.tsx | 33 +++ .../output/App.tsx | 37 +++ .../import-simple/input/App.tsx | 20 ++ .../import-simple/output/App.tsx | 22 ++ .../fragments/appGqlConfigTransform.ts | 211 ++++++++++++++ .../features/fragments/appImportTransform.ts | 28 ++ .../features/{ => fragments}/fragments.ts | 0 .../features/fragments/fragmentsHandler.ts | 113 ++++++++ .../features/fragments/runTransform.ts | 108 ++++++++ .../graphql/features/fragmentsHandler.ts | 217 --------------- .../cli/src/commands/setup/graphql/graphql.ts | 2 +- packages/cli/src/jest.codemods.setup.ts | 55 ++++ packages/cli/src/testLib/cells.ts | 258 ++++++++++++++++++ .../cli/src/testLib/fetchFileFromTemplate.ts | 11 + .../cli/src/testLib/getFilesWithPattern.ts | 33 +++ .../cli/src/testLib/getRootPackageJSON.ts | 16 ++ packages/cli/src/testLib/isTSProject.ts | 10 + packages/cli/src/testLib/prettify.ts | 24 ++ packages/cli/src/testLib/runTransform.ts | 68 +++++ packages/cli/src/testLib/ts2js.ts | 30 ++ packages/cli/src/testUtils/index.ts | 21 ++ .../cli/src/testUtils/matchFolderTransform.ts | 126 +++++++++ .../testUtils/matchInlineTransformSnapshot.ts | 46 ++++ .../src/testUtils/matchTransformSnapshot.ts | 60 ++++ packages/cli/testUtils.d.ts | 86 ++++++ packages/cli/tsconfig.json | 11 + packages/codemods/README.md | 42 +++ 42 files changed, 1882 insertions(+), 228 deletions(-) delete mode 100644 packages/cli/jest.config.js create mode 100644 packages/cli/jest.config.ts create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appImportTransform.test.ts create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/input/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/output/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/input/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/output/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/input/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/output/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/input/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/output/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/input/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/output/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/input/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/output/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/input/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/output/App.tsx create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/appGqlConfigTransform.ts create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/appImportTransform.ts rename packages/cli/src/commands/setup/graphql/features/{ => fragments}/fragments.ts (100%) create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/runTransform.ts delete mode 100644 packages/cli/src/commands/setup/graphql/features/fragmentsHandler.ts create mode 100644 packages/cli/src/jest.codemods.setup.ts create mode 100644 packages/cli/src/testLib/cells.ts create mode 100644 packages/cli/src/testLib/fetchFileFromTemplate.ts create mode 100644 packages/cli/src/testLib/getFilesWithPattern.ts create mode 100644 packages/cli/src/testLib/getRootPackageJSON.ts create mode 100644 packages/cli/src/testLib/isTSProject.ts create mode 100644 packages/cli/src/testLib/prettify.ts create mode 100644 packages/cli/src/testLib/runTransform.ts create mode 100644 packages/cli/src/testLib/ts2js.ts create mode 100644 packages/cli/src/testUtils/index.ts create mode 100644 packages/cli/src/testUtils/matchFolderTransform.ts create mode 100644 packages/cli/src/testUtils/matchInlineTransformSnapshot.ts create mode 100644 packages/cli/src/testUtils/matchTransformSnapshot.ts create mode 100644 packages/cli/testUtils.d.ts create mode 100644 packages/cli/tsconfig.json diff --git a/packages/cli-helpers/src/lib/index.ts b/packages/cli-helpers/src/lib/index.ts index 30861f18062c..d55f3fa08123 100644 --- a/packages/cli-helpers/src/lib/index.ts +++ b/packages/cli-helpers/src/lib/index.ts @@ -54,7 +54,17 @@ export const transformTSToJS = (filename: string, content: string) => { */ export const prettierOptions = () => { try { - return require(path.join(getPaths().base, 'prettier.config.js')) + const options = require(path.join(getPaths().base, 'prettier.config.js')) + + if (options.tailwindConfig?.startsWith('.')) { + // Make this work with --cwd + options.tailwindConfig = path.join( + process.env.RWJS_CWD ?? process.cwd(), + options.tailwindConfig + ) + } + + return options } catch (e) { return undefined } diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js deleted file mode 100644 index a1ed78aa66e1..000000000000 --- a/packages/cli/jest.config.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/*.test.[jt]s?(x)'], - testPathIgnorePatterns: ['fixtures', 'dist'], - moduleNameMapper: { - '^src/(.*)': '/src/$1', - }, - testTimeout: 15000, - setupFilesAfterEnv: ['./jest.setup.js'], -} diff --git a/packages/cli/jest.config.ts b/packages/cli/jest.config.ts new file mode 100644 index 000000000000..92eb1ec9cce9 --- /dev/null +++ b/packages/cli/jest.config.ts @@ -0,0 +1,39 @@ +import type { Config } from 'jest' + +const config: Config = { + projects: [ + { + displayName: 'root', + testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/*.test.[jt]s?(x)'], + testPathIgnorePatterns: [ + '__fixtures__', + '__testfixtures__', + '__codemod_tests__', + '__tests__/utils/*', + '__tests__/fixtures/*', + '.d.ts', + 'dist', + ], + moduleNameMapper: { + '^src/(.*)': '/src/$1', + }, + setupFilesAfterEnv: ['./jest.setup.js'], + }, + { + displayName: 'setup codemods', + testMatch: ['**/commands/setup/**/__codemod_tests__/*.ts'], + testPathIgnorePatterns: [ + '__fixtures__', + '__testfixtures__', + '__tests__/utils/*', + '__tests__/fixtures/*', + '.d.ts', + 'dist', + ], + setupFilesAfterEnv: ['./src/jest.codemods.setup.ts'], + }, + ], + testTimeout: 20_000, +} + +export default config diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts new file mode 100644 index 000000000000..2fbb67acd59e --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts @@ -0,0 +1,43 @@ +describe('fragments graphQLClientConfig', () => { + test('Default App.tsx', async () => { + await matchFolderTransform('appGqlConfigTransform', 'config-simple', { + useJsCodeshift: true, + }) + }) + + test('App.tsx with existing inline graphQLClientConfig', async () => { + await matchFolderTransform('appGqlConfigTransform', 'existingPropInline', { + useJsCodeshift: true, + }) + }) + + test('App.tsx with existing graphQLClientConfig in separate variable', async () => { + await matchFolderTransform( + 'appGqlConfigTransform', + 'existingPropVariable', + { + useJsCodeshift: true, + } + ) + }) + + test('App.tsx with existing graphQLClientConfig in separate variable, without cacheConfig property', async () => { + await matchFolderTransform( + 'appGqlConfigTransform', + 'existingPropVariableNoCacheConfig', + { + useJsCodeshift: true, + } + ) + }) + + test('App.tsx with existing graphQLClientConfig in separate variable with non-standard name', async () => { + await matchFolderTransform( + 'appGqlConfigTransform', + 'existingPropVariableCustomName', + { + useJsCodeshift: true, + } + ) + }) +}) diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appImportTransform.test.ts b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appImportTransform.test.ts new file mode 100644 index 000000000000..d5cff2bb93bb --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appImportTransform.test.ts @@ -0,0 +1,13 @@ +describe('fragments possibleTypes import', () => { + test('Default App.tsx', async () => { + await matchFolderTransform('appImportTransform', 'import-simple', { + useJsCodeshift: true, + }) + }) + + test('App.tsx with existing import', async () => { + await matchFolderTransform('appImportTransform', 'existingImport', { + useJsCodeshift: true, + }) + }) +}) diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/input/App.tsx new file mode 100644 index 000000000000..1b1ca2d977e9 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/input/App.tsx @@ -0,0 +1,22 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/output/App.tsx new file mode 100644 index 000000000000..f48cd4dd3b94 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/output/App.tsx @@ -0,0 +1,28 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + cacheConfig: { + possibleTypes: possibleTypes.possibleTypes, + }, +} + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/input/App.tsx new file mode 100644 index 000000000000..1b1ca2d977e9 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/input/App.tsx @@ -0,0 +1,22 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/output/App.tsx new file mode 100644 index 000000000000..1b1ca2d977e9 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/output/App.tsx @@ -0,0 +1,22 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/input/App.tsx new file mode 100644 index 000000000000..d8555cf11797 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/input/App.tsx @@ -0,0 +1,35 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/output/App.tsx new file mode 100644 index 000000000000..7f4f8110a38b --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/output/App.tsx @@ -0,0 +1,38 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + uri: '/graphql', + cacheConfig: { + resultCaching: true, + resultCacheMaxSize: 1024, + possibleTypes: possibleTypes.possibleTypes, + }, +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/input/App.tsx new file mode 100644 index 000000000000..98a008ac1ee6 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/input/App.tsx @@ -0,0 +1,37 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + uri: '/graphql', + cacheConfig: { + resultCaching: true, + resultCacheMaxSize: 1024, + }, +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/output/App.tsx new file mode 100644 index 000000000000..7f4f8110a38b --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/output/App.tsx @@ -0,0 +1,38 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + uri: '/graphql', + cacheConfig: { + resultCaching: true, + resultCacheMaxSize: 1024, + possibleTypes: possibleTypes.possibleTypes, + }, +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/input/App.tsx new file mode 100644 index 000000000000..10f479bfe1d2 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/input/App.tsx @@ -0,0 +1,30 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const config = { + uri: '/graphql', +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/output/App.tsx new file mode 100644 index 000000000000..7be341895a09 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/output/App.tsx @@ -0,0 +1,34 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const config = { + uri: '/graphql', + + cacheConfig: { + possibleTypes: possibleTypes.possibleTypes, + }, +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/input/App.tsx new file mode 100644 index 000000000000..df01f546ba6d --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/input/App.tsx @@ -0,0 +1,33 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + uri: '/graphql', +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/output/App.tsx new file mode 100644 index 000000000000..e80646b99fea --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/output/App.tsx @@ -0,0 +1,37 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + uri: '/graphql', + + cacheConfig: { + possibleTypes: possibleTypes.possibleTypes, + }, +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/input/App.tsx new file mode 100644 index 000000000000..5e7beac76c02 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/input/App.tsx @@ -0,0 +1,20 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/output/App.tsx new file mode 100644 index 000000000000..1b1ca2d977e9 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/output/App.tsx @@ -0,0 +1,22 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/appGqlConfigTransform.ts b/packages/cli/src/commands/setup/graphql/features/fragments/appGqlConfigTransform.ts new file mode 100644 index 000000000000..10ae99afc799 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/appGqlConfigTransform.ts @@ -0,0 +1,211 @@ +import type { + FileInfo, + API, + JSXExpressionContainer, + ObjectExpression, + ObjectProperty, + Identifier, +} from 'jscodeshift' + +function isJsxExpressionContainer(node: any): node is JSXExpressionContainer { + return node.type === 'JSXExpressionContainer' +} + +function isObjectExpression(node: any): node is ObjectExpression { + return node.type === 'ObjectExpression' +} + +function isObjectProperty(node: any): node is ObjectProperty { + return node.type === 'ObjectProperty' +} + +function isIdentifier(node: any): node is Identifier { + return node.type === 'Identifier' +} + +function isPropertyWithName(node: any, name: string) { + return ( + isObjectProperty(node) && + node.key.type === 'Identifier' && + node.key.name === name + ) +} + +export default function transform(file: FileInfo, api: API) { + const j = api.jscodeshift + const root = j(file.source) + + // Find the RedwoodApolloProvider component + const redwoodApolloProvider = root.findJSXElements('RedwoodApolloProvider') + + // Find the graphQLClientConfig prop + const graphQLClientConfigCollection = redwoodApolloProvider.find( + j.JSXAttribute, + { + name: { name: 'graphQLClientConfig' }, + } + ) + + let graphQLClientConfig: ReturnType + + if (graphQLClientConfigCollection.length === 0) { + // No pre-existing graphQLClientConfig prop found + // Creating `graphQLClientConfig={{}}` + graphQLClientConfig = j.jsxAttribute( + j.jsxIdentifier('graphQLClientConfig'), + j.jsxExpressionContainer(j.objectExpression([])) + ) + } else { + graphQLClientConfig = graphQLClientConfigCollection.get(0).node + } + + // We now have a graphQLClientConfig prop. Either one the user already had, + // or one we just created. + // Now we want to grab the value of that prop. The value can either be an + // object, like + // graphQLClientConfig={{ cacheConfig: { resultCaching: true } }} + // or it can be a variable, like + // graphQLClientConfig={graphQLClientConfig} + + const graphQLClientConfigExpression = isJsxExpressionContainer( + graphQLClientConfig.value + ) + ? graphQLClientConfig.value.expression + : j.jsxEmptyExpression() + + let graphQLClientConfigVariableName = '' + + if (isIdentifier(graphQLClientConfigExpression)) { + // graphQLClientConfig is already something like + // + // Get the variable name + graphQLClientConfigVariableName = graphQLClientConfigExpression.name + } + + if ( + !graphQLClientConfigVariableName && + !isObjectExpression(graphQLClientConfigExpression) + ) { + throw new Error( + "Error configuring possibleTypes. You'll have to do it manually. " + + "(Could not find a graphQLClientConfigExpression of the correct type, it's a " + + graphQLClientConfigExpression.type + + ')' + ) + } + + if (isObjectExpression(graphQLClientConfigExpression)) { + // graphQLClientConfig is something like + // + + // Find + // `const App = () => { ... }` + // and insert + // `const graphQLClientConfig = { cacheConfig: { resultCaching: true } }` + // before it + graphQLClientConfigVariableName = 'graphQLClientConfig' + root + .find(j.VariableDeclaration, { + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'App' }, + }, + ], + }) + .insertBefore( + j.variableDeclaration('const', [ + j.variableDeclarator( + j.identifier(graphQLClientConfigVariableName), + graphQLClientConfigExpression + ), + ]) + ) + } + + // Find `const graphQLClientConfig = { ... }`. It's going to either be the + // one we just created above, or the one the user already had, with the name + // we found in the `graphQLClientConfig prop expression. + const configVariableDeclarators = root.findVariableDeclarators( + graphQLClientConfigVariableName + ) + + const configExpression = configVariableDeclarators.get(0)?.node.init + + if (!isObjectExpression(configExpression)) { + throw new Error( + "Error configuring possibleTypes. You'll have to do it manually. " + + '(Could not find a graphQLClientConfig variable ObjectExpression)' + ) + } + + // Now we have the value of the graphQLClientConfig const. And we know it's + // an object. Let's see if the object has a `cacheConfig` property. + + let cacheConfig = configExpression.properties.find((prop) => + isPropertyWithName(prop, 'cacheConfig') + ) + + if (!cacheConfig) { + // No `cacheConfig` property. Let's insert one! + cacheConfig = j.objectProperty( + j.identifier('cacheConfig'), + j.objectExpression([]) + ) + configExpression.properties.push(cacheConfig) + } + + if (!isObjectProperty(cacheConfig)) { + throw new Error( + "Error configuring possibleTypes. You'll have to do it manually. " + + '(cacheConfig is not an ObjectProperty)' + ) + } + + const cacheConfigValue = cacheConfig.value + + if (!isObjectExpression(cacheConfigValue)) { + throw new Error( + "Error configuring possibleTypes. You'll have to do it manually. " + + '(cacheConfigValue is not an ObjectExpression)' + ) + } + + // Now we know we have a `graphQLClientConfig` object, and that it has a + // `cacheConfig` property. Let's check if it has a `possibleTypes` property. + // If it doesn't we'll insert one, with the correct value + + const possibleTypes = cacheConfigValue.properties.find((prop) => + isPropertyWithName(prop, 'possibleTypes') + ) + + if (!possibleTypes) { + const property = j.property( + 'init', + j.identifier('possibleTypes'), + j.identifier('possibleTypes.possibleTypes') + ) + // property.shorthand = true + cacheConfigValue.properties.push(property) + } + + // Now we have a proper graphQLClientConfig object stored in a const. Now we + // just need to tell about it by setting the + // `graphQLClientConfig` prop + + // Remove existing graphQLClientConfig prop (if there is one) and then add a + // new one for the variable we created or updated + graphQLClientConfigCollection.remove() + redwoodApolloProvider + .get(0) + .node.openingElement.attributes.push( + j.jsxAttribute( + j.jsxIdentifier('graphQLClientConfig'), + j.jsxExpressionContainer(j.identifier(graphQLClientConfigVariableName)) + ) + ) + + return root.toSource() +} diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/appImportTransform.ts b/packages/cli/src/commands/setup/graphql/features/fragments/appImportTransform.ts new file mode 100644 index 000000000000..8cec36fc41de --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/appImportTransform.ts @@ -0,0 +1,28 @@ +import type { FileInfo, API } from 'jscodeshift' + +export default function transform(file: FileInfo, api: API) { + const j = api.jscodeshift + const root = j(file.source) + + const possibleTypesImports = root.find(j.ImportDeclaration) + + const hasPossibleTypesImport = possibleTypesImports.some((i) => { + return ( + i.get('source').value.value === 'src/graphql/possibleTypes' || + i.get('source').value.value === './graphql/possibleTypes' + ) + }) + + if (!hasPossibleTypesImport) { + possibleTypesImports + .at(1) + .insertAfter( + j.importDeclaration( + [j.importDefaultSpecifier(j.identifier('possibleTypes'))], + j.literal('src/graphql/possibleTypes') + ) + ) + } + + return root.toSource() +} diff --git a/packages/cli/src/commands/setup/graphql/features/fragments.ts b/packages/cli/src/commands/setup/graphql/features/fragments/fragments.ts similarity index 100% rename from packages/cli/src/commands/setup/graphql/features/fragments.ts rename to packages/cli/src/commands/setup/graphql/features/fragments/fragments.ts diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts b/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts new file mode 100644 index 000000000000..8075dfed2c4f --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts @@ -0,0 +1,113 @@ +import fs from 'node:fs' +import path from 'node:path' + +import execa from 'execa' +import { Listr } from 'listr2' +import { format } from 'prettier' + +import { + colors, + recordTelemetryAttributes, + prettierOptions, +} from '@redwoodjs/cli-helpers' +import { getConfigPath, getPaths } from '@redwoodjs/project-config' + +import type { Args } from './fragments' +import { runTransform } from './runTransform' + +export const command = 'fragments' +export const description = 'Set up Fragments for GraphQL' + +async function updateGraphQlCacheConfig() { + const result = await runTransform({ + transformPath: path.join(__dirname, 'appGqlConfigTransform.js'), + targetPaths: [getPaths().web.app], + }) + + if (result.error) { + throw new Error(result.error) + } + + const appPath = getPaths().web.app + const source = fs.readFileSync(appPath, 'utf-8') + + const prettifiedApp = format(source, { + ...prettierOptions(), + parser: 'babel-ts', + }) + + fs.writeFileSync(getPaths().web.app, prettifiedApp, 'utf-8') +} + +export async function handler({ force }: Args) { + recordTelemetryAttributes({ + command: 'setup graphql fragments', + force, + }) + + const tasks = new Listr( + [ + { + title: + 'Update Redwood Project Configuration to enable GraphQL Fragments...', + skip: () => { + if (Math.random() < 5) { + return true + } + const redwoodTomlPath = getConfigPath() + + if (force) { + // Never skip when --force is used + return false + } + + const redwoodTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') + if (/\bfragments\s*=\s*true/.test(redwoodTomlContent)) { + return 'GraphQL Fragments are already enabled.' + } + + return false + }, + task: () => { + const redwoodTomlPath = getConfigPath() + const originalTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') + + const tomlToAppend = `[graphql]\n fragments = true` + + const newConfig = originalTomlContent + '\n' + tomlToAppend + + fs.writeFileSync(redwoodTomlPath, newConfig, 'utf-8') + }, + }, + { + title: 'Generate possibleTypes.ts...', + task: () => { + execa.commandSync('yarn redwood generate types', { stdio: 'ignore' }) + }, + }, + { + title: 'Import possibleTypes in App.tsx...', + task: () => { + return runTransform({ + transformPath: path.join(__dirname, 'appImportTransform.js'), + targetPaths: [getPaths().web.app], + }) + }, + }, + { + title: 'Add possibleTypes to the GraphQL cache config', + task: () => { + return updateGraphQlCacheConfig() + }, + }, + ], + { rendererOptions: { collapseSubtasks: false } } + ) + + try { + await tasks.run() + } catch (e: any) { + console.error(colors.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/runTransform.ts b/packages/cli/src/commands/setup/graphql/features/fragments/runTransform.ts new file mode 100644 index 000000000000..40fb8d78dec3 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/runTransform.ts @@ -0,0 +1,108 @@ +/** + * A simple wrapper around jscodeshift. + * + * @see jscodeshift JS usage {@link https://github.com/facebook/jscodeshift#usage-js} + * @see prisma/codemods {@link https://github.com/prisma/codemods/blob/main/utils/runner.ts} + * @see react-codemod {@link https://github.com/reactjs/react-codemod/blob/master/bin/cli.js} + */ +import * as jscodeshift from 'jscodeshift/src/Runner' + +// jscodeshift has an `Options` type export we could use here, but currently +// it's just a map of anys, so not really useful. In our case, leaving that +// type out is actually better and leads to stronger typings for `runTransform` +const defaultJscodeshiftOpts = { + // 0, 1 or 2 + verbose: 0, + dry: false, + // Doesn't do anything when running programmatically + print: false, + babel: true, + extensions: 'js,ts,jsx,tsx', + ignorePattern: '**/node_modules/**', + ignoreConfig: [], + runInBand: false, + silent: true, + parser: 'babel', + parserConfig: {}, + // `silent` has to be `false` for this option to do anything + failOnError: false, + stdin: false, +} + +type OptionKeys = keyof typeof defaultJscodeshiftOpts + +export interface RunTransform { + /** Path to the transform */ + transformPath: string + /** Path(s) to the file(s) to transform. Can also be a directory */ + targetPaths: string[] + parser?: 'babel' | 'ts' | 'tsx' + /** jscodeshift options and transform options */ + options?: Partial> +} + +export const runTransform = async ({ + transformPath, + targetPaths, + parser = 'tsx', + options = {}, +}: RunTransform) => { + // We have to do this here for the tests, because jscodeshift.run actually + // spawns a different process. If we use getPaths() in the transform, it + // would not find redwood.toml + if (process.env.NODE_ENV === 'test' && process.env.RWJS_CWD) { + process.chdir(process.env.RWJS_CWD) + } + + // Unfortunately this seems to be the only way to capture output from + // jscodeshift + const { output, stdoutWrite } = patchStdoutWrite() + + const result = await jscodeshift.run(transformPath, targetPaths, { + ...defaultJscodeshiftOpts, + parser, + babel: process.env.NODE_ENV === 'test', + ...options, // Putting options here lets users override all the defaults. + }) + + restoreStdoutWrite(stdoutWrite) + + let error: string | undefined + + if (result.error) { + // If there is an error it's going to be the first line that starts with + // "Error: " + error = output.value + .split('\n') + .find((line) => line.startsWith('Error: ')) + ?.slice('Error: '.length) + } + + return { + ...result, + error, + output: output.value, + } +} + +function patchStdoutWrite() { + const stdoutWrite = process.stdout.write + + const output = { + value: '', + } + + process.stdout.write = (chunk) => { + if (typeof chunk === 'string') { + output.value += chunk + } + + return true + } + + return { output, stdoutWrite } +} + +function restoreStdoutWrite(stdoutWrite: typeof process.stdout.write) { + process.stdout.write = stdoutWrite +} diff --git a/packages/cli/src/commands/setup/graphql/features/fragmentsHandler.ts b/packages/cli/src/commands/setup/graphql/features/fragmentsHandler.ts deleted file mode 100644 index 642747d35b0b..000000000000 --- a/packages/cli/src/commands/setup/graphql/features/fragmentsHandler.ts +++ /dev/null @@ -1,217 +0,0 @@ -import fs from 'fs' - -import execa from 'execa' -import { Listr } from 'listr2' -import { format } from 'prettier' -import { Project, SyntaxKind } from 'ts-morph' - -import { - recordTelemetryAttributes, - prettierOptions, -} from '@redwoodjs/cli-helpers' -import { colors } from '@redwoodjs/cli-helpers' -import { getConfigPath, getPaths } from '@redwoodjs/project-config' - -import type { Args } from './fragments' - -export const command = 'fragments' -export const description = 'Set up Fragments for GraphQL' - -function addPossibleTypesImportToApp() { - return { - title: - 'Configuring the GraphQL Handler to use a Trusted Documents store ...', - task: async () => { - const project = new Project() - const appPath = getPaths().web.app - const appSourceFile = project.addSourceFileAtPathIfExists(appPath) - let appSourceFileChanged = false - - if (!appSourceFile) { - console.warn( - colors.warning( - `Unable to find the GraphQL Handler source file: ${appPath}` - ) - ) - return - } - - const imports = appSourceFile.getImportDeclarations() - - if ( - !imports.some( - (i) => i.getModuleSpecifierValue() === '../graphql/possibleTypes' - ) - ) { - appSourceFile.addImportDeclaration({ - moduleSpecifier: '../graphql/possibleTypes', - defaultImport: 'possibleTypes', - }) - - appSourceFileChanged = true - } - - if (appSourceFileChanged) { - await project.save() - const updatedHandler = fs.readFileSync(appPath, 'utf-8') - const prettifiedHandler = format(updatedHandler, { - ...prettierOptions(), - parser: 'babel-ts', - }) - fs.writeFileSync(getPaths().web.app, prettifiedHandler, 'utf-8') - } - }, - } -} - -function updateGraphQlCacheConfig() { - return { - title: - 'Configuring the GraphQL Handler to use a Trusted Documents store ...', - task: async () => { - const project = new Project() - const appPath = getPaths().web.app - const appSourceFile = project.addSourceFileAtPathIfExists(appPath) - const appSourceFileChanged = false - - if (!appSourceFile) { - console.warn( - colors.warning( - `Unable to find the GraphQL Handler source file: ${appPath}` - ) - ) - - return - } - - // Find the RedwoodApolloProvider component - const redwoodApolloProvider = appSourceFile - .getDescendantsOfKind(SyntaxKind.JsxOpeningElement) - .find((element) => element.getText() === '') - - if (!redwoodApolloProvider) { - console.warn( - colors.warning(`Unable to find in ${appPath}`) - ) - - return - } - - // Find the graphQLClientConfig prop - const graphQLClientConfigProp = redwoodApolloProvider - .getChildSyntaxList() - ?.getChildrenOfKind(SyntaxKind.JsxAttribute) - .find((attr) => attr.getName() === 'graphQLClientConfig') - - if (!graphQLClientConfigProp) { - console.warn( - colors.warning( - `Unable to find the graphQLClientConfig prop on in ${appPath}` - ) - ) - - return - } - - // Find the cacheConfig prop - const cacheConfig = graphQLClientConfigProp - .getChildSyntaxList() - ?.getChildrenOfKind(SyntaxKind.JsxAttribute) - .find((attr) => attr.getName() === 'cacheConfig') - - // Add possibleTypes to cacheConfig - const possibleTypesExpression = `possibleTypes,` - - if (cacheConfig) { - cacheConfig.replaceWithText((writer) => { - writer.write('cacheConfig: {') - writer.indent(() => { - writer.write('fragments: [],') - writer.write(possibleTypesExpression) - }) - writer.write('},') - }) - } - - // Save the changes - project.save() - - if (appSourceFileChanged) { - await project.save() - const updatedHandler = fs.readFileSync(appPath, 'utf-8') - const prettifiedHandler = format(updatedHandler, { - ...prettierOptions(), - parser: 'babel-ts', - }) - fs.writeFileSync(appPath, prettifiedHandler, 'utf-8') - } - }, - } -} - -export async function handler({ force }: Args) { - recordTelemetryAttributes({ - command: 'setup graphql fragments', - force, - }) - - const tasks = new Listr( - [ - { - title: - 'Update Redwood Project Configuration to enable GraphQL Fragments...', - skip: () => { - const redwoodTomlPath = getConfigPath() - - if (force) { - // Never skip when --force is used - return false - } - - const redwoodTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') - if (/\bfragments\s*=\s*true/.test(redwoodTomlContent)) { - return 'GraphQL Fragments are already enabled.' - } - - return false - }, - task: () => { - const redwoodTomlPath = getConfigPath() - const originalTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') - - const tomlToAppend = `[graphql]\n fragments = true` - - const newConfig = originalTomlContent + '\n' + tomlToAppend - - fs.writeFileSync(redwoodTomlPath, newConfig, 'utf-8') - }, - }, - { - title: 'Generate possibleTypes.ts...', - task: () => { - execa.commandSync('yarn redwood generate types', { stdio: 'ignore' }) - }, - }, - { - title: 'Import possibleTypes in App.tsx...', - task: () => { - return addPossibleTypesImportToApp() - }, - }, - { - title: 'Add possibleTypes to the GraphQL cache config', - task: () => { - return updateGraphQlCacheConfig() - }, - }, - ], - { rendererOptions: { collapseSubtasks: false } } - ) - - try { - await tasks.run() - } catch (e: any) { - console.error(colors.error(e.message)) - process.exit(e?.exitCode || 1) - } -} diff --git a/packages/cli/src/commands/setup/graphql/graphql.ts b/packages/cli/src/commands/setup/graphql/graphql.ts index 11ef381b3a29..aca51785336d 100644 --- a/packages/cli/src/commands/setup/graphql/graphql.ts +++ b/packages/cli/src/commands/setup/graphql/graphql.ts @@ -1,7 +1,7 @@ import terminalLink from 'terminal-link' import type { Argv } from 'yargs' -import * as fragmentsCommand from './features/fragments' +import * as fragmentsCommand from './features/fragments/fragments' export const command = 'graphql ' export const description = 'Set up GraphQL feature support' diff --git a/packages/cli/src/jest.codemods.setup.ts b/packages/cli/src/jest.codemods.setup.ts new file mode 100644 index 000000000000..9a77e6be7996 --- /dev/null +++ b/packages/cli/src/jest.codemods.setup.ts @@ -0,0 +1,55 @@ +/* eslint-env node, jest */ + +import { formatCode } from './testUtils' + +// Disable telemetry within framework tests +process.env.REDWOOD_DISABLE_TELEMETRY = 1 + +const fs = require('fs') +const path = require('path') + +globalThis.matchTransformSnapshot = + require('./testUtils/matchTransformSnapshot').matchTransformSnapshot +globalThis.matchInlineTransformSnapshot = + require('./testUtils/matchInlineTransformSnapshot').matchInlineTransformSnapshot +globalThis.matchFolderTransform = + require('./testUtils/matchFolderTransform').matchFolderTransform + +// Custom matcher for checking fixtures using paths +// e.g. expect(transformedPath).toMatchFileContents(expectedPath) +// Mainly so we throw more helpful errors +expect.extend({ + toMatchFileContents( + receivedPath, + expectedPath, + { removeWhitespace } = { removeWhitespace: false } + ) { + let pass = true + let message = '' + try { + let actualOutput = fs.readFileSync(receivedPath, 'utf-8') + let expectedOutput = fs.readFileSync(expectedPath, 'utf-8') + + if (removeWhitespace) { + actualOutput = actualOutput.replace(/\s/g, '') + expectedOutput = expectedOutput.replace(/\s/g, '') + } + + expect(formatCode(actualOutput)).toEqual(formatCode(expectedOutput)) + } catch (e) { + const relativePath = path.relative( + path.join(__dirname, 'src/commands/setup'), + expectedPath + ) + pass = false + message = `${e}\nFile contents do not match for fixture at: \n ${relativePath}` + } + + return { + pass, + message: () => message, + expected: expectedPath, + received: receivedPath, + } + }, +}) diff --git a/packages/cli/src/testLib/cells.ts b/packages/cli/src/testLib/cells.ts new file mode 100644 index 000000000000..6b261601b8d8 --- /dev/null +++ b/packages/cli/src/testLib/cells.ts @@ -0,0 +1,258 @@ +import fs from 'fs' +import path from 'path' + +import { types } from '@babel/core' +import type { ParserPlugin } from '@babel/parser' +import { parse as babelParse } from '@babel/parser' +import traverse from '@babel/traverse' +import fg from 'fast-glob' +import type { + DocumentNode, + FieldNode, + InlineFragmentNode, + OperationDefinitionNode, + OperationTypeNode, +} from 'graphql' +import { parse, visit } from 'graphql' + +import { getPaths } from '@redwoodjs/project-config' + +export const findCells = (cwd: string = getPaths().web.src) => { + const modules = fg.sync('**/*Cell.{js,jsx,ts,tsx}', { + cwd, + absolute: true, + ignore: ['node_modules'], + }) + return modules.filter(isCellFile) +} + +export const isCellFile = (p: string) => { + const { dir, name } = path.parse(p) + + // If the path isn't on the web side it cannot be a cell + if (!isFileInsideFolder(p, getPaths().web.src)) { + return false + } + + // A Cell must be a directory named module. + if (!dir.endsWith(name)) { + return false + } + + const ast = fileToAst(p) + + // A Cell should not have a default export. + if (hasDefaultExport(ast)) { + return false + } + + // A Cell must export QUERY and Success. + const exports = getNamedExports(ast) + const exportedQUERY = exports.findIndex((v) => v.name === 'QUERY') !== -1 + const exportedSuccess = exports.findIndex((v) => v.name === 'Success') !== -1 + if (!exportedQUERY && !exportedSuccess) { + return false + } + + return true +} + +export const isFileInsideFolder = (filePath: string, folderPath: string) => { + const { dir } = path.parse(filePath) + const relativePathFromFolder = path.relative(folderPath, dir) + if ( + !relativePathFromFolder || + relativePathFromFolder.startsWith('..') || + path.isAbsolute(relativePathFromFolder) + ) { + return false + } else { + return true + } +} + +export const hasDefaultExport = (ast: types.Node): boolean => { + let exported = false + traverse(ast, { + ExportDefaultDeclaration() { + exported = true + return + }, + }) + return exported +} + +interface NamedExports { + name: string + type: 're-export' | 'variable' | 'function' | 'class' +} + +export const getNamedExports = (ast: types.Node): NamedExports[] => { + const namedExports: NamedExports[] = [] + traverse(ast, { + ExportNamedDeclaration(path) { + // Re-exports from other modules + // Eg: export { a, b } from './module' + const specifiers = path.node?.specifiers + if (specifiers.length) { + for (const s of specifiers) { + const id = s.exported as types.Identifier + namedExports.push({ + name: id.name, + type: 're-export', + }) + } + return + } + + const declaration = path.node.declaration + if (!declaration) { + return + } + + if (declaration.type === 'VariableDeclaration') { + const id = declaration.declarations[0].id as types.Identifier + namedExports.push({ + name: id.name as string, + type: 'variable', + }) + } else if (declaration.type === 'FunctionDeclaration') { + namedExports.push({ + name: declaration?.id?.name as string, + type: 'function', + }) + } else if (declaration.type === 'ClassDeclaration') { + namedExports.push({ + name: declaration?.id?.name, + type: 'class', + }) + } + }, + }) + + return namedExports +} + +export const fileToAst = (filePath: string): types.Node => { + const code = fs.readFileSync(filePath, 'utf-8') + + // use jsx plugin for web files, because in JS, the .jsx extension is not used + const isJsxFile = + path.extname(filePath).match(/[jt]sx$/) || + isFileInsideFolder(filePath, getPaths().web.base) + + const plugins = [ + 'typescript', + 'nullishCoalescingOperator', + 'objectRestSpread', + isJsxFile && 'jsx', + ].filter(Boolean) as ParserPlugin[] + + try { + return babelParse(code, { + sourceType: 'module', + plugins, + }) + } catch (e: any) { + // console.error(chalk.red(`Error parsing: ${filePath}`)) + console.error(e) + throw new Error(e?.message) // we throw, so typescript doesn't complain about returning + } +} + +export const getCellGqlQuery = (ast: types.Node) => { + let cellQuery: string | undefined = undefined + traverse(ast, { + ExportNamedDeclaration({ node }) { + if ( + node.exportKind === 'value' && + types.isVariableDeclaration(node.declaration) + ) { + const exportedQueryNode = node.declaration.declarations.find((d) => { + return ( + types.isIdentifier(d.id) && + d.id.name === 'QUERY' && + types.isTaggedTemplateExpression(d.init) + ) + }) + + if (exportedQueryNode) { + const templateExpression = + exportedQueryNode.init as types.TaggedTemplateExpression + + cellQuery = templateExpression.quasi.quasis[0].value.raw + } + } + return + }, + }) + + return cellQuery +} + +export const parseGqlQueryToAst = (gqlQuery: string) => { + const ast = parse(gqlQuery) + return parseDocumentAST(ast) +} + +export const parseDocumentAST = (document: DocumentNode) => { + const operations: Array = [] + + visit(document, { + OperationDefinition(node: OperationDefinitionNode) { + const fields: any[] = [] + + node.selectionSet.selections.forEach((field) => { + fields.push(getFields(field as FieldNode)) + }) + + operations.push({ + operation: node.operation, + name: node.name?.value, + fields, + }) + }, + }) + + return operations +} + +interface Operation { + operation: OperationTypeNode + name: string | undefined + fields: Array +} + +interface Field { + string: Array +} + +const getFields = (field: FieldNode): any => { + // base + if (!field.selectionSet) { + return field.name.value + } else { + const obj: Record = { + [field.name.value]: [], + } + + const lookAtFieldNode = (node: FieldNode | InlineFragmentNode): void => { + node.selectionSet?.selections.forEach((subField) => { + switch (subField.kind) { + case 'Field': + obj[field.name.value].push(getFields(subField as FieldNode)) + break + case 'FragmentSpread': + // TODO: Maybe this will also be needed, right now it's accounted for to not crash in the tests + break + case 'InlineFragment': + lookAtFieldNode(subField) + } + }) + } + + lookAtFieldNode(field) + + return obj + } +} diff --git a/packages/cli/src/testLib/fetchFileFromTemplate.ts b/packages/cli/src/testLib/fetchFileFromTemplate.ts new file mode 100644 index 000000000000..156881e53165 --- /dev/null +++ b/packages/cli/src/testLib/fetchFileFromTemplate.ts @@ -0,0 +1,11 @@ +import { fetch } from '@whatwg-node/fetch' + +/** + * @param tag should be something like 'v0.42.1' + * @param file should be something like 'prettier.config.js', 'api/src/index.ts', 'web/src/index.ts' + */ +export default async function fetchFileFromTemplate(tag: string, file: string) { + const URL = `https://raw.githubusercontent.com/redwoodjs/redwood/${tag}/packages/create-redwood-app/template/${file}` + const res = await fetch(URL) + return res.text() +} diff --git a/packages/cli/src/testLib/getFilesWithPattern.ts b/packages/cli/src/testLib/getFilesWithPattern.ts new file mode 100644 index 000000000000..f0a37290679d --- /dev/null +++ b/packages/cli/src/testLib/getFilesWithPattern.ts @@ -0,0 +1,33 @@ +/** + * Uses ripgrep to search files for a pattern, + * returning the name of the files that contain the pattern. + * + * @see {@link https://github.com/burntsushi/ripgrep} + */ +import { rgPath } from '@vscode/ripgrep' +import execa from 'execa' + +const getFilesWithPattern = ({ + pattern, + filesToSearch, +}: { + pattern: string + filesToSearch: string[] +}) => { + try { + const { stdout } = execa.sync(rgPath, [ + '--files-with-matches', + pattern, + ...filesToSearch, + ]) + + /** + * Return an array of files that contain the pattern + */ + return stdout.toString().split('\n') + } catch (e) { + return [] + } +} + +export default getFilesWithPattern diff --git a/packages/cli/src/testLib/getRootPackageJSON.ts b/packages/cli/src/testLib/getRootPackageJSON.ts new file mode 100644 index 000000000000..f68c3a52a605 --- /dev/null +++ b/packages/cli/src/testLib/getRootPackageJSON.ts @@ -0,0 +1,16 @@ +import fs from 'fs' +import path from 'path' + +import { getPaths } from '@redwoodjs/project-config' + +const getRootPackageJSON = () => { + const rootPackageJSONPath = path.join(getPaths().base, 'package.json') + + const rootPackageJSON = JSON.parse( + fs.readFileSync(rootPackageJSONPath, 'utf8') + ) + + return [rootPackageJSON, rootPackageJSONPath] +} + +export default getRootPackageJSON diff --git a/packages/cli/src/testLib/isTSProject.ts b/packages/cli/src/testLib/isTSProject.ts new file mode 100644 index 000000000000..51fb3a1fa5a6 --- /dev/null +++ b/packages/cli/src/testLib/isTSProject.ts @@ -0,0 +1,10 @@ +import fg from 'fast-glob' + +import { getPaths } from '@redwoodjs/project-config' + +const isTSProject = + fg.sync(`${getPaths().base}/**/tsconfig.json`, { + ignore: ['**/node_modules/**'], + }).length > 0 + +export default isTSProject diff --git a/packages/cli/src/testLib/prettify.ts b/packages/cli/src/testLib/prettify.ts new file mode 100644 index 000000000000..7a2927f24e85 --- /dev/null +++ b/packages/cli/src/testLib/prettify.ts @@ -0,0 +1,24 @@ +import path from 'path' + +import { format } from 'prettier' + +import { getPaths } from '@redwoodjs/project-config' + +const getPrettierConfig = () => { + try { + return require(path.join(getPaths().base, 'prettier.config.js')) + } catch (e) { + return undefined + } +} + +const prettify = (code: string, options: Record = {}) => + format(code, { + singleQuote: true, + semi: false, + ...getPrettierConfig(), + parser: 'babel', + ...options, + }) + +export default prettify diff --git a/packages/cli/src/testLib/runTransform.ts b/packages/cli/src/testLib/runTransform.ts new file mode 100644 index 000000000000..96a52c2b8dba --- /dev/null +++ b/packages/cli/src/testLib/runTransform.ts @@ -0,0 +1,68 @@ +/** + * A simple wrapper around the jscodeshift. + * + * @see jscodeshift CLI's usage {@link https://github.com/facebook/jscodeshift#usage-cli} + * @see prisma/codemods {@link https://github.com/prisma/codemods/blob/main/utils/runner.ts} + * @see react-codemod {@link https://github.com/reactjs/react-codemod/blob/master/bin/cli.js} + */ +import * as jscodeshift from 'jscodeshift/src/Runner' + +const defaultJscodeshiftOpts = { + verbose: 0, + dry: false, + print: false, + babel: true, + extensions: 'js', + ignorePattern: '**/node_modules/**', + ignoreConfig: [], + runInBand: false, + silent: false, + parser: 'babel', + parserConfig: {}, + failOnError: false, + stdin: false, +} + +export interface RunTransform { + /** + * Path to the transform. + */ + transformPath: string + /** + * Path(s) to the file(s) to transform. Can also be a directory. + */ + targetPaths: string[] + parser?: 'babel' | 'ts' | 'tsx' + /** + * jscodeshift options and transform options. + */ + options?: Partial> +} + +export const runTransform = async ({ + transformPath, + targetPaths, + parser = 'tsx', + options = {}, +}: RunTransform) => { + try { + // We have to do this here for the tests, because jscodeshift.run actually spawns + // a different process. If we use getPaths() in the transform, it would not find redwood.toml + if (process.env.NODE_ENV === 'test' && process.env.RWJS_CWD) { + process.chdir(process.env.RWJS_CWD) + } + + await jscodeshift.run(transformPath, targetPaths, { + ...defaultJscodeshiftOpts, + parser, + babel: process.env.NODE_ENV === 'test', + ...options, // Putting options here lets them override all the defaults. + }) + } catch (e: any) { + console.error('Transform Error', e.message) + + throw new Error('Failed to invoke transform') + } +} + +export default runTransform diff --git a/packages/cli/src/testLib/ts2js.ts b/packages/cli/src/testLib/ts2js.ts new file mode 100644 index 000000000000..926f47fd1b19 --- /dev/null +++ b/packages/cli/src/testLib/ts2js.ts @@ -0,0 +1,30 @@ +import { transform } from '@babel/core' + +import { getPaths } from '@redwoodjs/project-config' + +import prettify from './prettify' + +const ts2js = (file: string) => { + const result = transform(file, { + cwd: getPaths().base, + configFile: false, + plugins: [ + [ + '@babel/plugin-transform-typescript', + { + isTSX: true, + allExtensions: true, + }, + ], + ], + retainLines: true, + }) + + if (result?.code) { + return prettify(result.code) + } + + return null +} + +export default ts2js diff --git a/packages/cli/src/testUtils/index.ts b/packages/cli/src/testUtils/index.ts new file mode 100644 index 000000000000..257868193ef3 --- /dev/null +++ b/packages/cli/src/testUtils/index.ts @@ -0,0 +1,21 @@ +import fs from 'fs' +import path from 'path' + +import { format } from 'prettier' +import parserBabel from 'prettier/parser-babel' +import tempy from 'tempy' + +export const formatCode = (code: string) => { + return format(code, { + parser: 'babel-ts', + plugins: [parserBabel], + }) +} + +export const createProjectMock = () => { + const tempDir = tempy.directory() + // add fake redwood.toml + fs.closeSync(fs.openSync(path.join(tempDir, 'redwood.toml'), 'w')) + + return tempDir +} diff --git a/packages/cli/src/testUtils/matchFolderTransform.ts b/packages/cli/src/testUtils/matchFolderTransform.ts new file mode 100644 index 000000000000..02861e8a14ca --- /dev/null +++ b/packages/cli/src/testUtils/matchFolderTransform.ts @@ -0,0 +1,126 @@ +import path from 'path' + +import fg from 'fast-glob' +import fse from 'fs-extra' + +import runTransform from '../testLib/runTransform' + +import { createProjectMock } from './index' + +type Options = { + removeWhitespace?: boolean + targetPathsGlob?: string + /** + * Use this option, when you want to run a codemod that uses jscodeshift + * as well as modifies file names. e.g. convertJsToJsx + */ + useJsCodeshift?: boolean +} + +type MatchFolderTransformFunction = ( + transformFunctionOrName: (() => any) | string, + fixtureName: string, + options?: Options +) => Promise + +export const matchFolderTransform: MatchFolderTransformFunction = async ( + transformFunctionOrName, + fixtureName, + { + removeWhitespace = false, + targetPathsGlob = '**/*', + useJsCodeshift = false, + } = {} +) => { + const tempDir = createProjectMock() + + // Override paths used in getPaths() utility func + process.env.RWJS_CWD = tempDir + + // Looks up the path of the caller + const testPath = expect.getState().testPath + + console.log('testPath', testPath) + + if (!testPath) { + throw new Error('Could not find test path') + } + + const fixtureFolder = path.join( + testPath, + '../../__testfixtures__', + fixtureName + ) + + const fixtureInputDir = path.join(fixtureFolder, 'input') + const fixtureOutputDir = path.join(fixtureFolder, 'output') + + // Step 1: Copy files recursively from fixture folder to temp + fse.copySync(fixtureInputDir, tempDir, { + overwrite: true, + }) + + const GLOB_CONFIG = { + absolute: false, + dot: true, + ignore: ['redwood.toml', '**/*.DS_Store'], // ignore the fake redwood.toml added for getPaths + } + + // Step 2: Run transform against temp dir + if (useJsCodeshift) { + if (typeof transformFunctionOrName !== 'string') { + throw new Error( + 'When running matchFolderTransform with useJsCodeshift, transformFunction must be a string (file name of jscodeshift transform)' + ) + } + const transformName = transformFunctionOrName + const transformPath = require.resolve( + path.join(testPath, '../../', transformName) + ) + + const targetPaths = fg.sync(targetPathsGlob, { + ...GLOB_CONFIG, + cwd: tempDir, + }) + + // So that the transform can use getPaths() utility func + // This is used inside the runTransform function + process.env.RWJS_CWD = tempDir + + await runTransform({ + transformPath, + targetPaths: targetPaths.map((p) => path.join(tempDir, p)), + }) + } else { + if (typeof transformFunctionOrName !== 'function') { + throw new Error( + 'transformFunction must be a function, if useJsCodeshift set to false' + ) + } + const transformFunction = transformFunctionOrName + await transformFunction() + } + + const transformedPaths = fg.sync(targetPathsGlob, { + ...GLOB_CONFIG, + cwd: tempDir, + }) + + const expectedPaths = fg.sync(targetPathsGlob, { + ...GLOB_CONFIG, + cwd: fixtureOutputDir, + }) + + // Step 3: Check output paths + expect(transformedPaths).toEqual(expectedPaths) + + // Step 4: Check contents of each file + transformedPaths.forEach((transformedFile) => { + const actualPath = path.join(tempDir, transformedFile) + const expectedPath = path.join(fixtureOutputDir, transformedFile) + + expect(actualPath).toMatchFileContents(expectedPath, { removeWhitespace }) + }) + + delete process.env.RWJS_CWD +} diff --git a/packages/cli/src/testUtils/matchInlineTransformSnapshot.ts b/packages/cli/src/testUtils/matchInlineTransformSnapshot.ts new file mode 100644 index 000000000000..fc302e947a05 --- /dev/null +++ b/packages/cli/src/testUtils/matchInlineTransformSnapshot.ts @@ -0,0 +1,46 @@ +import fs from 'fs' +import path from 'path' + +import tempy from 'tempy' + +import runTransform from '../testLib/runTransform' + +import { formatCode } from './index' + +export const matchInlineTransformSnapshot = async ( + transformName: string, + fixtureCode: string, + expectedCode: string, + parser: 'ts' | 'tsx' | 'babel' = 'tsx' +) => { + const tempFilePath = tempy.file() + + // Looks up the path of the caller + const testPath = expect.getState().testPath + + if (!testPath) { + throw new Error('Could not find test path') + } + + const transformPath = require.resolve( + path.join(testPath, '../../', transformName) + ) + + // Step 1: Write passed in code to a temp file + fs.writeFileSync(tempFilePath, fixtureCode) + + // Step 2: Run transform against temp file + await runTransform({ + transformPath, + targetPaths: [tempFilePath], + options: { + verbose: 1, + }, + parser, + }) + + // Step 3: Read modified file and snapshot + const transformedContent = fs.readFileSync(tempFilePath, 'utf-8') + + expect(formatCode(transformedContent)).toEqual(formatCode(expectedCode)) +} diff --git a/packages/cli/src/testUtils/matchTransformSnapshot.ts b/packages/cli/src/testUtils/matchTransformSnapshot.ts new file mode 100644 index 000000000000..a8384bea4411 --- /dev/null +++ b/packages/cli/src/testUtils/matchTransformSnapshot.ts @@ -0,0 +1,60 @@ +import fs from 'fs' +import path from 'path' + +import tempy from 'tempy' + +import runTransform from '../testLib/runTransform' + +import { formatCode } from './index' + +export interface MatchTransformSnapshotFunction { + (transformName: string, fixtureName?: string, parser?: 'ts' | 'tsx'): void +} + +export const matchTransformSnapshot: MatchTransformSnapshotFunction = async ( + transformName, + fixtureName, + parser +) => { + const tempFilePath = tempy.file() + + // Looks up the path of the caller + const testPath = expect.getState().testPath + + if (!testPath) { + throw new Error('Could not find test path') + } + + // Use require.resolve, so we can pass in ts/js/tsx/jsx without specifying + const fixturePath = require.resolve( + path.join(testPath, '../../__testfixtures__', `${fixtureName}.input`) + ) + + const transformPath = require.resolve( + path.join(testPath, '../../', transformName) + ) + + // Step 1: Copy fixture to temp file + fs.copyFileSync(fixturePath, tempFilePath, fs.constants.COPYFILE_FICLONE) + + // Step 2: Run transform against temp file + await runTransform({ + transformPath, + targetPaths: [tempFilePath], + parser, + options: { + verbose: 1, + print: true, + }, + }) + + // Step 3: Read modified file and snapshot + const transformedContent = fs.readFileSync(tempFilePath, 'utf-8') + + const expectedOutput = fs.readFileSync( + fixturePath.replace('.input.', '.output.'), + 'utf-8' + ) + + expect(formatCode(transformedContent)).toEqual(formatCode(expectedOutput)) +} diff --git a/packages/cli/testUtils.d.ts b/packages/cli/testUtils.d.ts new file mode 100644 index 000000000000..8ebdea3fcfa4 --- /dev/null +++ b/packages/cli/testUtils.d.ts @@ -0,0 +1,86 @@ +/* eslint-disable no-var */ +// For some reason, testutils types aren't exported.... I just dont... +// Partially copied from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/jscodeshift/src/testUtils.d.ts +declare module 'jscodeshift/dist/testUtils' { + import type { Transform, Options, Parser } from 'jscodeshift' + function defineTest( + dirName: string, + transformName: string, + options?: Options | null, + testFilePrefix?: string | null, + testOptions?: { + parser: 'ts' | 'tsx' | 'js' | 'jsx' | Parser + } + ): () => any + + function defineInlineTest( + module: Transform, + options: Options, + inputSource: string, + expectedOutputSource: string, + testName?: string + ): () => any + + function runInlineTest( + module: Transform, + options: Options, + input: { + path?: string + source: string + }, + expectedOutput: string, + testOptions?: TestOptions + ): string +} + +// @NOTE: Redefining types, because they get lost when importing from the testUtils file +type MatchTransformSnapshotFunction = ( + transformName: string, + fixtureName?: string, + parser?: 'ts' | 'tsx' +) => Promise + +type MatchFolderTransformFunction = ( + transformFunctionOrName: (() => any) | string, + fixtureName: string, + options?: { + removeWhitespace?: boolean + targetPathsGlob?: string + /** + * Use this option, when you want to run a codemod that uses jscodeshift + * as well as modifies file names. e.g. convertJsToJsx + */ + useJsCodeshift?: boolean + } +) => Promise + +type MatchInlineTransformSnapshotFunction = ( + transformName: string, + fixtureCode: string, + expectedCode: string, + parser: 'ts' | 'tsx' | 'babel' = 'tsx' +) => Promise + +// These files gets loaded in jest setup, so becomes available globally in tests +declare global { + var matchTransformSnapshot: MatchTransformSnapshotFunction + var matchInlineTransformSnapshot: MatchInlineTransformSnapshotFunction + var matchFolderTransform: MatchFolderTransformFunction + + namespace jest { + interface Matchers { + toMatchFileContents( + fixturePath: string, + { removeWhitespace }: { removeWhitespace: boolean } + ): R + } + } + + namespace NodeJS { + interface ProcessEnv { + REDWOOD_DISABLE_TELEMETRY: number + } + } +} + +export {} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 000000000000..a12c30f06606 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.compilerOption.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "emitDeclarationOnly": false, + "noEmit": true + }, + "include": ["src", "./testUtils.d.ts"], + "exclude": ["**/__testfixtures__"] +} diff --git a/packages/codemods/README.md b/packages/codemods/README.md index 5d692de07a41..83ad0b7d07e4 100644 --- a/packages/codemods/README.md +++ b/packages/codemods/README.md @@ -255,3 +255,45 @@ RWJS_CWD=/path/to/rw-project node "./packages/codemods/dist/codemods.js" {your-c > # Assuming in packages/codemods/ > watch -p "./src/**/*" -c "yarn build" > ``` + +4. Debugging + +If you have a node and want to see/confirm what you're working with you can +pass it to jscodeshift and then call `.toSource()` on it. + +** Example ** + +``` +const j = api.jscodeshift +const root = j(file.source) + +const graphQLClientConfig = j.jsxAttribute( + j.jsxIdentifier('graphQLClientConfig'), + j.jsxExpressionContainer(j.objectExpression([])) +) + +console.log('graphQLClientConfig prop', j(graphQLClientConfig).toSource()) +// Will log: +// graphQLClientConfig={{}} +``` + +If you have a collection of nodes you first need to get just one of the +collection items, and then get the node out of that. + +** Example ** + +``` +const j = api.jscodeshift +const root = j(file.source) + +const redwoodApolloProvider = root.findJSXElements('RedwoodApolloProvider') + +console.log( + '', + j(redwoodApolloProvider.get(0).node).toSource() +) +// Will log: +// +// +// +``` From 24d6f527f6d570dd93985f8c8acfa8c1d23c428f Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 10 Jan 2024 11:37:46 +0100 Subject: [PATCH 3/7] config updates --- .eslintrc.js | 1 + packages/cli/package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index af531e4f0404..7ce7a34be528 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,6 +27,7 @@ module.exports = { 'packages/babel-config/src/__tests__/__fixtures__/**/*', 'packages/core/**/__fixtures__/**/*', 'packages/codemods/**/__testfixtures__/**/*', + 'packages/cli/**/__testfixtures__/**/*', 'packages/core/config/storybook/**/*', 'packages/studio/dist-*/**/*', ], diff --git a/packages/cli/package.json b/packages/cli/package.json index 938e808b46ed..e97e99cbfc11 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "yarn build:js", "build:clean-dist": "rimraf 'dist/**/*/__tests__' --glob", - "build:js": "babel src -d dist --extensions \".js,.jsx,.ts,.tsx\" --copy-files --no-copy-ignored && yarn build:clean-dist", + "build:js": "babel src -d dist --extensions \".js,.jsx,.ts,.tsx\" --ignore \"src/**/__tests__/**\" --ignore \"src/**/__testfixtures__/**\" --copy-files --no-copy-ignored && yarn build:clean-dist", "build:pack": "yarn pack -o redwoodjs-cli.tgz", "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx,template\" --ignore dist --exec \"yarn build && yarn fix:permissions\"", "dev": "RWJS_CWD=../../__fixtures__/example-todo-main node dist/index.js", From d01a698307225cb91186c3879e8300b98a5f8315 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 10 Jan 2024 12:41:35 +0100 Subject: [PATCH 4/7] Update docs --- docs/docs/cli-commands.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/docs/cli-commands.md b/docs/docs/cli-commands.md index bcdfda5f5391..2337b3da8b1a 100644 --- a/docs/docs/cli-commands.md +++ b/docs/docs/cli-commands.md @@ -2001,7 +2001,7 @@ It's the author of the npm package's responsibility to specify the correct compa ### setup graphql -This command creates the necessary files to support GraphQL features like trusted documents. +This command creates the necessary files to support GraphQL features like fragments. #### Usage @@ -2027,24 +2027,16 @@ Run `yarn rw setup graphql fragments` ```bash ~/redwood-app$ yarn rw setup graphql fragments -✔ Update Redwood Project Configuration to enable GraphQL fragments... -✔ Generating Trusted Documents store ... -✔ Configuring the GraphQL Handler to use a Trusted Documents store ... -``` - -If you have not setup the RedwoodJS server file, it will be setup: - -```bash -✔ Adding the experimental server file... -✔ Adding config to redwood.toml... -✔ Adding required api packages... +✔ Update Redwood Project Configuration to enable GraphQL Fragments +✔ Generate possibleTypes.ts +✔ Import possibleTypes in App.tsx +✔ Add possibleTypes to the GraphQL cache config ``` ### setup realtime This command creates the necessary files, installs the required packages, and provides examples to setup RedwoodJS Realtime from GraphQL live queries and subscriptions. See the Realtime docs for more information. - ``` yarn redwood setup realtime ``` From 1a31922c92e503758eb8d8d6d88bcac1c6f112d7 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 10 Jan 2024 12:41:47 +0100 Subject: [PATCH 5/7] update handler --- .../setup/graphql/features/fragments/fragmentsHandler.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts b/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts index 8075dfed2c4f..39b6b7a26414 100644 --- a/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts +++ b/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts @@ -49,11 +49,8 @@ export async function handler({ force }: Args) { [ { title: - 'Update Redwood Project Configuration to enable GraphQL Fragments...', + 'Update Redwood Project Configuration to enable GraphQL Fragments', skip: () => { - if (Math.random() < 5) { - return true - } const redwoodTomlPath = getConfigPath() if (force) { @@ -80,13 +77,13 @@ export async function handler({ force }: Args) { }, }, { - title: 'Generate possibleTypes.ts...', + title: 'Generate possibleTypes.ts', task: () => { execa.commandSync('yarn redwood generate types', { stdio: 'ignore' }) }, }, { - title: 'Import possibleTypes in App.tsx...', + title: 'Import possibleTypes in App.tsx', task: () => { return runTransform({ transformPath: path.join(__dirname, 'appImportTransform.js'), From b2fcfce7a4a572c2d5c53d4a3b18342358b89b49 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 11 Jan 2024 10:05:12 +0100 Subject: [PATCH 6/7] More tests --- .../appGqlConfigTransform.test.ts | 60 ++++++- .../__tests__/fragmentsHandler.test.ts | 155 ++++++++++++++++++ .../features/fragments/fragmentsHandler.ts | 45 +++-- .../cli/src/testUtils/matchFolderTransform.ts | 11 +- 4 files changed, 242 insertions(+), 29 deletions(-) create mode 100644 packages/cli/src/commands/setup/graphql/features/fragments/__tests__/fragmentsHandler.test.ts diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts index 2fbb67acd59e..56397b430698 100644 --- a/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts @@ -1,5 +1,10 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { findUp } from '@redwoodjs/project-config' + describe('fragments graphQLClientConfig', () => { - test('Default App.tsx', async () => { + test('App.tsx with no graphQLClientConfig', async () => { await matchFolderTransform('appGqlConfigTransform', 'config-simple', { useJsCodeshift: true, }) @@ -40,4 +45,57 @@ describe('fragments graphQLClientConfig', () => { } ) }) + + test('test-project App.tsx', async () => { + const rootFwPath = path.dirname(findUp('lerna.json') || '') + const testProjectAppTsx = fs.readFileSync( + path.join( + rootFwPath, + '__fixtures__', + 'test-project', + 'web', + 'src', + 'App.tsx' + ), + 'utf-8' + ) + await matchInlineTransformSnapshot( + 'appGqlConfigTransform', + testProjectAppTsx, + `import { FatalErrorBoundary, RedwoodProvider } from \"@redwoodjs/web\"; + import { RedwoodApolloProvider } from \"@redwoodjs/web/apollo\"; + + import FatalErrorPage from \"src/pages/FatalErrorPage\"; + import Routes from \"src/Routes\"; + + import { AuthProvider, useAuth } from \"./auth\"; + + import \"./scaffold.css\"; + import \"./index.css\"; + + const graphQLClientConfig = { + cacheConfig: { + possibleTypes: possibleTypes.possibleTypes, + }, + }; + + const App = () => ( + + + + + + + + + + ); + + export default App; + ` + ) + }) }) diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__tests__/fragmentsHandler.test.ts b/packages/cli/src/commands/setup/graphql/features/fragments/__tests__/fragmentsHandler.test.ts new file mode 100644 index 000000000000..23567e6c307d --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__tests__/fragmentsHandler.test.ts @@ -0,0 +1,155 @@ +let mockExecutedTaskTitles: Array = [] +let mockSkippedTaskTitles: Array = [] + +console.log('mockity mock?') +jest.mock('fs', () => require('memfs').fs) +jest.mock('node:fs', () => require('memfs').fs) +jest.mock('execa') +// The jscodeshift parts are tested by another test +jest.mock('../runTransform', () => { + return { + runTransform: () => { + return {} + }, + } +}) + +jest.mock('listr2', () => { + return { + // Return a constructor function, since we're calling `new` on Listr + Listr: jest.fn().mockImplementation((tasks: Array) => { + return { + run: async () => { + mockExecutedTaskTitles = [] + mockSkippedTaskTitles = [] + + for (const task of tasks) { + const skip = + typeof task.skip === 'function' ? task.skip : () => task.skip + + if (skip()) { + mockSkippedTaskTitles.push(task.title) + } else { + mockExecutedTaskTitles.push(task.title) + await task.task() + } + } + }, + } + }), + } +}) + +import path from 'node:path' + +import { vol } from 'memfs' + +import { handler } from '../fragmentsHandler' + +// Suppress terminal logging. +// console.log = jest.fn() + +// Set up RWJS_CWD +let original_RWJS_CWD: string | undefined +const FIXTURE_PATH = '/redwood-app' + +let testProjectAppTsx: string + +beforeAll(() => { + original_RWJS_CWD = process.env.RWJS_CWD + process.env.RWJS_CWD = FIXTURE_PATH + + const actualFs = jest.requireActual('fs') + testProjectAppTsx = actualFs.readFileSync( + path.join( + __dirname, + '../../../../../../../../../__fixtures__/test-project/web/src/App.tsx' + ), + 'utf-8' + ) +}) + +beforeEach(() => { + mockExecutedTaskTitles = [] + mockSkippedTaskTitles = [] + + vol.reset() + vol.fromNestedJSON( + { + 'redwood.toml': '', + web: { + src: { + 'App.tsx': testProjectAppTsx, + }, + }, + }, + FIXTURE_PATH + ) +}) + +afterEach(() => { + mockExecutedTaskTitles = [] + mockSkippedTaskTitles = [] +}) + +afterAll(() => { + process.env.RWJS_CWD = original_RWJS_CWD + jest.resetAllMocks() + jest.resetModules() +}) + +test('`fragments = true` is added to redwood.toml', async () => { + await handler({ force: false }) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toMatch( + /fragments = true/ + ) +}) + +test('all tasks are being called', async () => { + await handler({ force: false }) + + expect(mockExecutedTaskTitles).toMatchInlineSnapshot(` + [ + "Update Redwood Project Configuration to enable GraphQL Fragments", + "Generate possibleTypes.ts", + "Import possibleTypes in App.tsx", + "Add possibleTypes to the GraphQL cache config", + ] + `) +}) + +test('redwood.toml update is skipped if fragments are already enabled', async () => { + vol.fromNestedJSON( + { + 'redwood.toml': ` + [graphql] + fragments = true + `, + web: { + src: { + 'App.tsx': testProjectAppTsx, + }, + }, + }, + FIXTURE_PATH + ) + + await handler({ force: false }) + + expect(mockExecutedTaskTitles).toMatchInlineSnapshot(` + [ + "Generate possibleTypes.ts", + "Import possibleTypes in App.tsx", + "Add possibleTypes to the GraphQL cache config", + ] + `) + + expect(mockSkippedTaskTitles).toMatchInlineSnapshot(` + [ + "Update Redwood Project Configuration to enable GraphQL Fragments", + ] + `) +}) + +// Add test that checks all steps being called diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts b/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts index 39b6b7a26414..f1db8ea4404d 100644 --- a/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts +++ b/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts @@ -18,27 +18,6 @@ import { runTransform } from './runTransform' export const command = 'fragments' export const description = 'Set up Fragments for GraphQL' -async function updateGraphQlCacheConfig() { - const result = await runTransform({ - transformPath: path.join(__dirname, 'appGqlConfigTransform.js'), - targetPaths: [getPaths().web.app], - }) - - if (result.error) { - throw new Error(result.error) - } - - const appPath = getPaths().web.app - const source = fs.readFileSync(appPath, 'utf-8') - - const prettifiedApp = format(source, { - ...prettierOptions(), - parser: 'babel-ts', - }) - - fs.writeFileSync(getPaths().web.app, prettifiedApp, 'utf-8') -} - export async function handler({ force }: Args) { recordTelemetryAttributes({ command: 'setup graphql fragments', @@ -51,13 +30,12 @@ export async function handler({ force }: Args) { title: 'Update Redwood Project Configuration to enable GraphQL Fragments', skip: () => { - const redwoodTomlPath = getConfigPath() - if (force) { // Never skip when --force is used return false } + const redwoodTomlPath = getConfigPath() const redwoodTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') if (/\bfragments\s*=\s*true/.test(redwoodTomlContent)) { return 'GraphQL Fragments are already enabled.' @@ -93,8 +71,25 @@ export async function handler({ force }: Args) { }, { title: 'Add possibleTypes to the GraphQL cache config', - task: () => { - return updateGraphQlCacheConfig() + task: async () => { + const result = await runTransform({ + transformPath: path.join(__dirname, 'appGqlConfigTransform.js'), + targetPaths: [getPaths().web.app], + }) + + if (result.error) { + throw new Error(result.error) + } + + const appPath = getPaths().web.app + const source = fs.readFileSync(appPath, 'utf-8') + + const prettifiedApp = format(source, { + ...prettierOptions(), + parser: 'babel-ts', + }) + + fs.writeFileSync(getPaths().web.app, prettifiedApp, 'utf-8') }, }, ], diff --git a/packages/cli/src/testUtils/matchFolderTransform.ts b/packages/cli/src/testUtils/matchFolderTransform.ts index 02861e8a14ca..178e6d721794 100644 --- a/packages/cli/src/testUtils/matchFolderTransform.ts +++ b/packages/cli/src/testUtils/matchFolderTransform.ts @@ -35,13 +35,13 @@ export const matchFolderTransform: MatchFolderTransformFunction = async ( const tempDir = createProjectMock() // Override paths used in getPaths() utility func + const original_RWJS_CWD = process.env.RWJS_CWD + const originalCwd = process.cwd() process.env.RWJS_CWD = tempDir // Looks up the path of the caller const testPath = expect.getState().testPath - console.log('testPath', testPath) - if (!testPath) { throw new Error('Could not find test path') } @@ -122,5 +122,10 @@ export const matchFolderTransform: MatchFolderTransformFunction = async ( expect(actualPath).toMatchFileContents(expectedPath, { removeWhitespace }) }) - delete process.env.RWJS_CWD + if (original_RWJS_CWD) { + process.env.RWJS_CWD = original_RWJS_CWD + } else { + delete process.env.RWJS_CWD + } + process.chdir(originalCwd) } From 82c93e7d59ef0e2f866930bf8b38efd36c9283dd Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 11 Jan 2024 12:26:30 +0100 Subject: [PATCH 7/7] More robust redwood.toml handling --- .../__tests__/fragmentsHandler.test.ts | 150 ++++++++++++------ .../features/fragments/fragmentsHandler.ts | 65 +++++++- 2 files changed, 156 insertions(+), 59 deletions(-) diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__tests__/fragmentsHandler.test.ts b/packages/cli/src/commands/setup/graphql/features/fragments/__tests__/fragmentsHandler.test.ts index 23567e6c307d..c60ea5f4100d 100644 --- a/packages/cli/src/commands/setup/graphql/features/fragments/__tests__/fragmentsHandler.test.ts +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__tests__/fragmentsHandler.test.ts @@ -1,7 +1,6 @@ let mockExecutedTaskTitles: Array = [] let mockSkippedTaskTitles: Array = [] -console.log('mockity mock?') jest.mock('fs', () => require('memfs').fs) jest.mock('node:fs', () => require('memfs').fs) jest.mock('execa') @@ -40,56 +39,17 @@ jest.mock('listr2', () => { } }) -import path from 'node:path' - import { vol } from 'memfs' import { handler } from '../fragmentsHandler' -// Suppress terminal logging. -// console.log = jest.fn() - // Set up RWJS_CWD let original_RWJS_CWD: string | undefined const FIXTURE_PATH = '/redwood-app' -let testProjectAppTsx: string - beforeAll(() => { original_RWJS_CWD = process.env.RWJS_CWD process.env.RWJS_CWD = FIXTURE_PATH - - const actualFs = jest.requireActual('fs') - testProjectAppTsx = actualFs.readFileSync( - path.join( - __dirname, - '../../../../../../../../../__fixtures__/test-project/web/src/App.tsx' - ), - 'utf-8' - ) -}) - -beforeEach(() => { - mockExecutedTaskTitles = [] - mockSkippedTaskTitles = [] - - vol.reset() - vol.fromNestedJSON( - { - 'redwood.toml': '', - web: { - src: { - 'App.tsx': testProjectAppTsx, - }, - }, - }, - FIXTURE_PATH - ) -}) - -afterEach(() => { - mockExecutedTaskTitles = [] - mockSkippedTaskTitles = [] }) afterAll(() => { @@ -99,6 +59,8 @@ afterAll(() => { }) test('`fragments = true` is added to redwood.toml', async () => { + vol.fromJSON({ 'redwood.toml': '', 'web/src/App.tsx': '' }, FIXTURE_PATH) + await handler({ force: false }) expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toMatch( @@ -107,6 +69,8 @@ test('`fragments = true` is added to redwood.toml', async () => { }) test('all tasks are being called', async () => { + vol.fromJSON({ 'redwood.toml': '', 'web/src/App.tsx': '' }, FIXTURE_PATH) + await handler({ force: false }) expect(mockExecutedTaskTitles).toMatchInlineSnapshot(` @@ -120,17 +84,10 @@ test('all tasks are being called', async () => { }) test('redwood.toml update is skipped if fragments are already enabled', async () => { - vol.fromNestedJSON( + vol.fromJSON( { - 'redwood.toml': ` - [graphql] - fragments = true - `, - web: { - src: { - 'App.tsx': testProjectAppTsx, - }, - }, + 'redwood.toml': '[graphql]\nfragments = true', + 'web/src/App.tsx': '', }, FIXTURE_PATH ) @@ -152,4 +109,95 @@ test('redwood.toml update is skipped if fragments are already enabled', async () `) }) -// Add test that checks all steps being called +test('redwood.toml update is skipped if fragments are already enabled, together with other settings', async () => { + const toml = ` +[graphql] +foo = "bar" +fragments = true +` + vol.fromJSON({ 'redwood.toml': toml, 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(mockSkippedTaskTitles).toMatchInlineSnapshot(` + [ + "Update Redwood Project Configuration to enable GraphQL Fragments", + ] + `) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toEqual(toml) +}) + +test('redwood.toml is updated even if `fragments = true` exists for other sections', async () => { + const toml = ` +[notGraphql] + fragments = true +` + vol.fromJSON({ 'redwood.toml': toml, 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toEqual( + toml + '\n\n[graphql]\n fragments = true' + ) +}) + +test('`fragments = true` is added to existing [graphql] section', async () => { + const toml = ` +[graphql] + + isAwesome = true + +[browser] + open = true +` + vol.fromJSON({ 'redwood.toml': toml, 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toEqual(` +[graphql] + + isAwesome = true + fragments = true + +[browser] + open = true +`) +}) + +test("`fragments = true` is not indented if other settings aren't", async () => { + const toml = ` +[graphql] +isAwesome = true + +[browser] +open = true +` + vol.fromJSON({ 'redwood.toml': toml, 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toEqual(` +[graphql] +isAwesome = true +fragments = true + +[browser] +open = true +`) +}) + +test('[graphql] is last section in redwood.toml', async () => { + const toml = ` +[graphql] + isAwesome = true` + + vol.fromJSON({ 'redwood.toml': toml, 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toEqual( + toml + '\n fragments = true' + ) +}) diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts b/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts index f1db8ea4404d..fb296f120b83 100644 --- a/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts +++ b/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts @@ -1,6 +1,7 @@ import fs from 'node:fs' import path from 'node:path' +import toml from '@iarna/toml' import execa from 'execa' import { Listr } from 'listr2' import { format } from 'prettier' @@ -24,6 +25,12 @@ export async function handler({ force }: Args) { force, }) + const redwoodTomlPath = getConfigPath() + const redwoodTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') + // Can't type toml.parse because this PR has not been included in a released yet + // https://github.com/iarna/iarna-toml/commit/5a89e6e65281e4544e23d3dbaf9e8428ed8140e9 + const redwoodTomlObject = toml.parse(redwoodTomlContent) as any + const tasks = new Listr( [ { @@ -35,9 +42,7 @@ export async function handler({ force }: Args) { return false } - const redwoodTomlPath = getConfigPath() - const redwoodTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') - if (/\bfragments\s*=\s*true/.test(redwoodTomlContent)) { + if (redwoodTomlObject?.graphql?.fragments) { return 'GraphQL Fragments are already enabled.' } @@ -46,12 +51,56 @@ export async function handler({ force }: Args) { task: () => { const redwoodTomlPath = getConfigPath() const originalTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') + const hasExistingGraphqlSection = !!redwoodTomlObject?.graphql + + let newTomlContent = + originalTomlContent + '\n\n[graphql]\n fragments = true' + + if (hasExistingGraphqlSection) { + const existingGraphqlSetting = Object.keys( + redwoodTomlObject.graphql + ) + + let inGraphqlSection = false + let indentation = '' + let lastGraphqlSettingIndex = 0 + + const tomlLines = originalTomlContent.split('\n') + tomlLines.forEach((line, index) => { + if (line.startsWith('[graphql]')) { + inGraphqlSection = true + lastGraphqlSettingIndex = index + } else { + if (/^\s*\[/.test(line)) { + inGraphqlSection = false + } + } + + if (inGraphqlSection) { + const matches = line.match( + new RegExp(`^(\\s*)(${existingGraphqlSetting})\\s*=`, 'i') + ) + + if (matches) { + indentation = matches[1] + } + + if (/^\s*\w+\s*=/.test(line)) { + lastGraphqlSettingIndex = index + } + } + }) + + tomlLines.splice( + lastGraphqlSettingIndex + 1, + 0, + `${indentation}fragments = true` + ) + + newTomlContent = tomlLines.join('\n') + } - const tomlToAppend = `[graphql]\n fragments = true` - - const newConfig = originalTomlContent + '\n' + tomlToAppend - - fs.writeFileSync(redwoodTomlPath, newConfig, 'utf-8') + fs.writeFileSync(redwoodTomlPath, newTomlContent) }, }, {