diff --git a/packages/storybook/src/plugins/mock-router.ts b/packages/storybook/src/plugins/mock-router.ts index 1043006947..60f4c67bf5 100644 --- a/packages/storybook/src/plugins/mock-router.ts +++ b/packages/storybook/src/plugins/mock-router.ts @@ -8,6 +8,7 @@ export function mockRouter(): PluginOption { if (id.includes('src')) { code = code.replace( "'@cedarjs/router'", + // TODO(storybook): Use the mock router from @cedarjs/testing instead "'storybook-framework-cedarjs/dist/mocks/MockRouter'", ) } diff --git a/packages/testing/build.mts b/packages/testing/build.mts index 6427862176..3169cac85e 100644 --- a/packages/testing/build.mts +++ b/packages/testing/build.mts @@ -1,6 +1,6 @@ import fs from 'node:fs' -import { buildCjs, buildEsm } from '@cedarjs/framework-tools' +import { build, buildEsm, defaultBuildOptions } from '@cedarjs/framework-tools' import { generateTypesCjs, generateTypesEsm, @@ -10,7 +10,21 @@ import { await buildEsm() await generateTypesEsm() -await buildCjs() +await build({ + buildOptions: { + ...defaultBuildOptions, + tsconfig: 'tsconfig.cjs.json', + outdir: 'dist/cjs', + logOverride: { + // This feels a bit dangerous, I wish I could do this with a comment + // inside the file where I need this for greater control, but I haven't + // found a way to do that yet. + // This is to silence the CJS warning for `import.meta.glob` and + // `import.meta.dirname` + 'empty-import-meta': 'silent', + }, + }, +}) await generateTypesCjs() await insertCommonJsPackageJson({ buildFileUrl: import.meta.url, @@ -71,3 +85,21 @@ fs.writeFileSync( configJestWebJestSetupPath, webJestSetupFile.replaceAll('await import', 'require'), ) + +// ./src/web/globRoutesImporter.ts contains `import.meta.glob`. This is not +// supported in CJS. And for CJS we don't really use this, but it does get +// imported and executed, so we need to mock it. esbuild will just make +// `import.meta` be an empty object, but that's not quite enough for what we +// need here, so I extend it a bit more. +const globRoutesImporterBuildPath = './dist/cjs/web/globRoutesImporter.js' +const globRoutesImporterFile = fs.readFileSync( + globRoutesImporterBuildPath, + 'utf-8', +) +fs.writeFileSync( + globRoutesImporterBuildPath, + globRoutesImporterFile.replaceAll( + 'const import_meta = {};', + 'const import_meta = { glob: () => ({ "routes.tsx": () => null }) };', + ), +) diff --git a/packages/testing/package.json b/packages/testing/package.json index f66eeae16a..b90f9e9af2 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -63,6 +63,10 @@ "default": "./dist/cjs/api/index.js" } }, + "./api/vitest": { + "types": "./dist/api/vitest/index.d.ts", + "default": "./dist/api/vitest/index.js" + }, "./cache": { "import": { "types": "./dist/cache/index.d.ts", @@ -82,6 +86,20 @@ "types": "./dist/cjs/web/index.d.ts", "default": "./dist/cjs/web/index.js" } + }, + "./web/MockRouter.js": { + "import": { + "types": "./dist/web/MockRouter.d.ts", + "default": "./dist/web/MockRouter.js" + }, + "require": { + "types": "./dist/cjs/web/MockRouter.d.ts", + "default": "./dist/cjs/web/MockRouter.js" + } + }, + "./web/vitest": { + "types": "./dist/web/vitest/index.d.ts", + "default": "./dist/web/vitest/index.js" } }, "files": [ diff --git a/packages/testing/src/api/mockContext.ts b/packages/testing/src/api/mockContext.ts new file mode 100644 index 0000000000..54343cd4a6 --- /dev/null +++ b/packages/testing/src/api/mockContext.ts @@ -0,0 +1,46 @@ +// TODO: See if we can use `GlobalContext` instead of `any` here to more +// closely match the production context. +// https://github.com/cedarjs/cedar/pull/355#discussion_r2264851576 +const mockContextStore = new Map() +const mockContext = new Proxy( + {}, + { + get: (_target, prop) => { + // Handle toJSON() calls, i.e. JSON.stringify(context) + if (prop === 'toJSON') { + return () => mockContextStore.get('context') + } + + const ctx = mockContextStore.get('context') + + if (!ctx) { + return undefined + } + + return ctx[prop] + }, + set: (_target, prop, value) => { + const ctx = mockContextStore.get('context') + + if (!ctx) { + return false + } + + ctx[prop] = value + + return true + }, + }, +) + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface GlobalContext extends Record {} + +export const context = mockContext + +export const setContext = (newContext: GlobalContext): GlobalContext => { + mockContextStore.set('context', newContext) + // TODO: See if this should be `newContext` instead + // https://github.com/cedarjs/cedar/pull/355#discussion_r2264851567 + return mockContext +} diff --git a/packages/testing/src/api/vitest/CedarApiVitestEnv.ts b/packages/testing/src/api/vitest/CedarApiVitestEnv.ts new file mode 100644 index 0000000000..c933d0286f --- /dev/null +++ b/packages/testing/src/api/vitest/CedarApiVitestEnv.ts @@ -0,0 +1,59 @@ +import { getSchema } from '@prisma/internals' +import 'dotenv-defaults/config.js' +import execa from 'execa' +import type { Environment } from 'vitest/environments' + +import { getPaths } from '@cedarjs/project-config' + +import { getDefaultDb, checkAndReplaceDirectUrl } from '../directUrlHelpers.js' + +const CedarApiVitestEnvironment: Environment = { + name: 'cedar-api', + transformMode: 'ssr', + + async setup() { + if (process.env.SKIP_DB_PUSH === '1') { + return { + teardown() {}, + } + } + + const cedarPaths = getPaths() + const defaultDb = getDefaultDb(cedarPaths.base) + + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL || defaultDb + + // NOTE: This is a workaround to get the directUrl from the schema + // Instead of using the schema, we can use the config file + // const prismaConfig = await getConfig(rwjsPaths.api.dbSchema) + // and then check for the prismaConfig.datasources[0].directUrl + const prismaSchema = (await getSchema(cedarPaths.api.dbSchema)).toString() + + const directUrlEnvVar = checkAndReplaceDirectUrl(prismaSchema, defaultDb) + + const command = + process.env.TEST_DATABASE_STRATEGY === 'reset' + ? ['prisma', 'migrate', 'reset', '--force', '--skip-seed'] + : ['prisma', 'db', 'push', '--force-reset', '--accept-data-loss'] + + const directUrlDefinition = directUrlEnvVar + ? { [directUrlEnvVar]: process.env[directUrlEnvVar] } + : {} + + execa.sync(`yarn rw`, command, { + cwd: cedarPaths.api.base, + stdio: 'inherit', + shell: true, + env: { + DATABASE_URL: process.env.DATABASE_URL, + ...directUrlDefinition, + }, + }) + + return { + teardown() {}, + } + }, +} + +export default CedarApiVitestEnvironment diff --git a/packages/testing/src/api/vitest/index.ts b/packages/testing/src/api/vitest/index.ts new file mode 100644 index 0000000000..95a6a2063f --- /dev/null +++ b/packages/testing/src/api/vitest/index.ts @@ -0,0 +1,3 @@ +export { autoImportsPlugin } from './vite-plugin-auto-import.js' +export { cedarVitestApiConfigPlugin } from './vite-plugin-cedar-vitest-api-config.js' +export { trackDbImportsPlugin } from './vite-plugin-track-db-imports.js' diff --git a/packages/testing/src/api/vitest/vite-plugin-auto-import.ts b/packages/testing/src/api/vitest/vite-plugin-auto-import.ts new file mode 100644 index 0000000000..2817e33b9c --- /dev/null +++ b/packages/testing/src/api/vitest/vite-plugin-auto-import.ts @@ -0,0 +1,36 @@ +import autoImport from 'unplugin-auto-import/vite' + +export function autoImportsPlugin() { + return autoImport({ + // targets to transform + include: [ + /\.[tj]sx?$/, // .ts, .tsx, .js, .jsx + ], + + // global imports to register + imports: [ + // import { mockContext, mockHttpEvent, mockSignedWebhook } from '@cedarjs/testing/api'; + { + '@cedarjs/testing/api': [ + 'mockContext', + 'mockHttpEvent', + 'mockSignedWebhook', + ], + }, + // import { gql } from 'graphql-tag' + { + 'graphql-tag': ['gql'], + }, + // import { context } from '@cedarjs/context' + { + '@cedarjs/context': ['context'], + }, + ], + + // We provide our mocking types elsewhere and so don't need this plugin to + // generate them. + // TODO: Maybe we should have it at least generate the types for the gql + // import? (Or do we already provide that some other way?) + dts: false, + }) +} diff --git a/packages/testing/src/api/vitest/vite-plugin-cedar-vitest-api-config.ts b/packages/testing/src/api/vitest/vite-plugin-cedar-vitest-api-config.ts new file mode 100644 index 0000000000..d8f102d930 --- /dev/null +++ b/packages/testing/src/api/vitest/vite-plugin-cedar-vitest-api-config.ts @@ -0,0 +1,35 @@ +import path from 'node:path' + +import type { Plugin } from 'vite' + +import { getEnvVarDefinitions, getPaths } from '@cedarjs/project-config' + +export function cedarVitestApiConfigPlugin(): Plugin { + return { + name: 'cedar-vitest-plugin', + config: () => { + return { + define: getEnvVarDefinitions(), + ssr: { + noExternal: ['@cedarjs/testing'], + }, + resolve: { + alias: { + src: getPaths().api.src, + }, + }, + test: { + environment: path.join(import.meta.dirname, 'CedarApiVitestEnv.js'), + // fileParallelism: false, + // fileParallelism doesn't work with vitest projects (which is what + // we're using in the root vitest.config.ts). As a workaround we set + // poolOptions instead, which also shouldn't work, but was suggested + // by Vitest team member AriPerkkio (Hiroshi's answer didn't work). + // https://github.com/vitest-dev/vitest/discussions/7416 + poolOptions: { forks: { singleFork: true } }, + setupFiles: [path.join(import.meta.dirname, 'vitest-api.setup.js')], + }, + } + }, + } +} diff --git a/packages/testing/src/api/vitest/vite-plugin-track-db-imports.ts b/packages/testing/src/api/vitest/vite-plugin-track-db-imports.ts new file mode 100644 index 0000000000..fa32f7f127 --- /dev/null +++ b/packages/testing/src/api/vitest/vite-plugin-track-db-imports.ts @@ -0,0 +1,33 @@ +import type { Plugin } from 'vite' + +export function trackDbImportsPlugin(): Plugin { + return { + name: 'db-import-tracker', + transform(code, id) { + // This regex and code content check could potentially match other files. + // It's very unlikely, but it is possible. For now this is good enough + if ( + id.match(/\/api\/src\/lib\/db\.(js|ts)$/) && + code.includes('PrismaClient') + ) { + // Inserting the code last (instead of at the top) works nicer with + // sourcemaps + return ( + code + + ` + ;if (typeof globalThis !== "undefined") { + globalThis.__cedarjs_db_imported__ = true; + } else { + throw new Error( + "vite-plugin-track-db-imports: globalThis is undefined. " + + "This is an error with CedarJS" + ); + } + ` + ) + } + + return code + }, + } +} diff --git a/packages/testing/src/api/vitest/vitest-api.setup.ts b/packages/testing/src/api/vitest/vitest-api.setup.ts new file mode 100644 index 0000000000..cada8e3d20 --- /dev/null +++ b/packages/testing/src/api/vitest/vitest-api.setup.ts @@ -0,0 +1,413 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { afterAll, beforeEach, it, describe, vi, beforeAll } from 'vitest' + +import { getPaths } from '@cedarjs/project-config' +import { defineScenario } from '@cedarjs/testing/api' +import type { DefineScenario } from '@cedarjs/testing/api' + +// Attempt to emulate the request context isolation behavior +// This is a little more complicated than it would necessarily need to be +// but we're following the same pattern as in `@cedarjs/context` +const mockContextStore = vi.hoisted(() => new Map()) +const mockContext = vi.hoisted( + () => + new Proxy( + {}, + { + get: (_target, prop) => { + // Handle toJSON() calls, i.e. JSON.stringify(context) + if (prop === 'toJSON') { + return () => mockContextStore.get('context') + } + + return mockContextStore.get('context')[prop] + }, + set: (_target, prop, value) => { + const ctx = mockContextStore.get('context') + ctx[prop] = value + + return true + }, + }, + ), +) + +vi.mock('@cedarjs/context', () => { + return { + context: mockContext, + setContext: (newContext: unknown) => { + mockContextStore.set('context', newContext) + }, + } +}) + +beforeEach(() => { + mockContextStore.set('context', {}) +}) + +declare global { + // eslint-disable-next-line no-var + var mockCurrentUser: (currentUser: Record | null) => void +} + +globalThis.mockCurrentUser = (currentUser: Record | null) => { + mockContextStore.set('context', { currentUser }) +} + +// ==================================== +// Scenario support +// ==================================== + +declare global { + // eslint-disable-next-line no-var + var defineScenario: DefineScenario + // eslint-disable-next-line no-var + var __cedarjs_db_imported__: boolean +} + +globalThis.defineScenario = defineScenario + +const cedarPaths = getPaths() + +// Error codes thrown by [MySQL, SQLite, Postgres] when foreign key constraint +// fails on DELETE +const FOREIGN_KEY_ERRORS = [1451, 1811, 23503] +const TEARDOWN_CACHE_PATH = path.join( + cedarPaths.generated.base, + 'scenarioTeardown.json', +) +const DEFAULT_SCENARIO = 'standard' +let teardownOrder: (string | null)[] = [] +let originalTeardownOrder: string[] = [] + +type It = typeof it | typeof it.only +type Describe = typeof describe | typeof describe.only +type TestFunc = (scenarioData: any) => any +type DescribeBlock = (getScenario: () => any) => any + +/** + * Wraps "it" or "test", to seed and teardown the scenario after each test + * This one passes scenario data to the test function + */ +function buildScenario(itFunc: It) { + return ( + ...args: + | [scenarioName: string, testName: string, testFunc: TestFunc] + | [testName: string, testFunc: TestFunc] + ) => { + let scenarioName: string + let testName: string + let testFunc: TestFunc + + if (args.length === 3) { + ;[scenarioName, testName, testFunc] = args + } else if (args.length === 2) { + scenarioName = DEFAULT_SCENARIO + ;[testName, testFunc] = args + } else { + throw new Error('scenario() requires 2 or 3 arguments') + } + + return itFunc(testName, async (ctx) => { + const testPath = ctx.task.file.filepath + const { scenario } = await loadScenarios(testPath, scenarioName) + + const scenarioData = await seedScenario(scenario) + try { + const result = await testFunc(scenarioData) + + return result + } finally { + // Make sure to cleanup, even if test fails + await teardown() + } + }) + } +} + +/** + * This creates a describe() block that will seed the scenario ONCE before all tests in the block + * Note that you need to use the getScenario() function to get the data. + */ +function buildDescribeScenario(describeFunc: Describe) { + return ( + ...args: [string, string, DescribeBlock] | [string, DescribeBlock] + ) => { + let scenarioName: string + let describeBlockName: string + let describeBlock: DescribeBlock + + if (args.length === 3) { + ;[scenarioName, describeBlockName, describeBlock] = args + } else if (args.length === 2) { + scenarioName = DEFAULT_SCENARIO + ;[describeBlockName, describeBlock] = args + } else { + throw new Error('describeScenario() requires 2 or 3 arguments') + } + + return describeFunc(describeBlockName, () => { + let scenarioData: Record + + beforeAll(async (ctx) => { + const testPath = ctx.file.filepath + const { scenario } = await loadScenarios(testPath, scenarioName) + scenarioData = await seedScenario(scenario) + }) + + afterAll(async () => { + await teardown() + }) + + const getScenario = () => scenarioData + + describeBlock(getScenario) + }) + } +} + +async function configureTeardown() { + if (!wasDbImported()) { + return + } + + const { getDMMF, getSchema } = await import('@prisma/internals') + + // @NOTE prisma utils are available in cli lib/schemaHelpers + // But avoid importing them, to prevent memory leaks in jest + const datamodel = await getSchema(cedarPaths.api.dbSchema) + const schema = await getDMMF({ datamodel }) + const schemaModels = schema.datamodel.models.map((m) => { + return m.dbName || m.name + }) + + // check if pre-defined delete order already exists and if so, use it to start + if (fs.existsSync(TEARDOWN_CACHE_PATH)) { + teardownOrder = JSON.parse(fs.readFileSync(TEARDOWN_CACHE_PATH).toString()) + } + + // check the number of models in case we've added or removed any models since + // cache was built + if (teardownOrder.length !== schemaModels.length) { + teardownOrder = schemaModels + } + + // keep a copy of the original order to compare against + originalTeardownOrder = deepCopy(teardownOrder) +} + +beforeAll(async () => { + await configureTeardown() +}) + +afterAll(() => { + // afterAll runs after all the tests in a single test file, and then for the + // next test file the code that's injected in src/lib/db.ts in the user's + // project will set this to `true` again if it's imported + globalThis.__cedarjs_db_imported__ = false +}) + +async function teardown() { + if (!wasDbImported()) { + return + } + + const quoteStyle = await getQuoteStyle() + const projectDb = await getProjectDb() + + for (const modelName of teardownOrder) { + try { + const query = `DELETE FROM ${quoteStyle}${modelName}${quoteStyle}` + await projectDb.$executeRawUnsafe(query) + } catch (e) { + console.error('teardown error\n', e) + const match = isErrorWithMessage(e) && e.message.match(/Code: `(\d+)`/) + + if (match && FOREIGN_KEY_ERRORS.includes(parseInt(match[1]))) { + const index = teardownOrder.indexOf(modelName) + teardownOrder[index] = null + teardownOrder.push(modelName) + } else { + throw e + } + } + } + + // remove nulls + teardownOrder = teardownOrder.filter((val) => val) + + // if the order of delete changed, write out the cached file again + if (!isIdenticalArray(teardownOrder, originalTeardownOrder)) { + originalTeardownOrder = deepCopy(teardownOrder) + fs.writeFileSync(TEARDOWN_CACHE_PATH, JSON.stringify(teardownOrder)) + } +} + +const seedScenario = async (scenario: Record) => { + if (scenario) { + const scenarios: Record = {} + + const projectDb = await getProjectDb() + + for (const [model, namedFixtures] of Object.entries(scenario)) { + scenarios[model] = {} + + for (const [name, createArgs] of Object.entries(namedFixtures)) { + if (typeof createArgs === 'function') { + scenarios[model][name] = await projectDb[model].create( + createArgs(scenarios), + ) + } else { + scenarios[model][name] = await projectDb[model].create(createArgs) + } + } + } + + return scenarios + } else { + return {} + } +} + +async function loadScenarios(testPath: string, scenarioName: string) { + const testFileDir = path.parse(testPath) + // e.g. ['comments', 'test'] or ['signup', 'state', 'machine', 'test'] + const testFileNameParts = testFileDir.name.split('.') + const testFilePath = `${testFileDir.dir}/${testFileNameParts + .slice(0, testFileNameParts.length - 1) + .join('.')}.scenarios` + let allScenarios: Record | undefined + let scenario: any + + try { + allScenarios = await import(testFilePath) + } catch (e) { + // ignore error if scenario file not found, otherwise re-throw + if (isErrorWithCode(e)) { + if (e instanceof Error) { + throw e + } else { + console.error('unexpected error type', e) + // eslint-disable-next-line + throw e + } + } + } + + if (allScenarios) { + if (allScenarios[scenarioName]) { + scenario = allScenarios[scenarioName] + } else { + throw new Error( + `UndefinedScenario: There is no scenario named "${scenarioName}" in ` + + `${testFilePath}.{js,ts}`, + ) + } + } + return { scenario } +} + +/** + * All these hooks run in the VM/Context that the test runs in since we're using + * "setupAfterEnv". + * There's a new context for each test-suite i.e. each test file + * + * Doing this means if the db isn't used in the current test context, no need to + * do any of the teardown logic - allowing simple tests to run faster + * At the same time, if the db is used, disconnecting it in this context + * prevents connection limit errors. + * Just disconnecting db in jest-preset is not enough, because the Prisma client + * is created in a different context. + */ +const wasDbImported = () => { + return Boolean(globalThis.__cedarjs_db_imported__) +} + +let quoteStyle: string +// determine what kind of quotes are needed around table names in raw SQL +async function getQuoteStyle() { + const { getConfig: getPrismaConfig, getSchema } = await import( + '@prisma/internals' + ) + + // @NOTE prisma utils are available in cli lib/schemaHelpers + // But avoid importing them, to prevent memory leaks in jest + const datamodel = await getSchema(cedarPaths.api.dbSchema) + + if (!quoteStyle) { + const config = await getPrismaConfig({ + datamodel, + }) + + switch (config.datasources?.[0]?.provider) { + case 'mysql': + quoteStyle = '`' + break + default: + quoteStyle = '"' + } + } + + return quoteStyle +} + +async function getProjectDb() { + const libDb = await import(`${cedarPaths.api.lib}/db`) + + return libDb.db +} + +function isIdenticalArray(a: unknown[], b: unknown[]) { + return JSON.stringify(a) === JSON.stringify(b) +} + +function deepCopy(obj: unknown[]) { + return JSON.parse(JSON.stringify(obj)) +} + +function isErrorWithMessage(e: unknown): e is { message: string } { + return ( + !!e && + typeof e === 'object' && + 'message' in e && + typeof e.message === 'string' + ) +} + +function isErrorWithCode(e: unknown): e is { code: string } { + return ( + !!e && typeof e === 'object' && 'code' in e && typeof e.code === 'string' + ) +} + +// These types are still in `global.d.ts` to work with Jest +// +// interface GlobalScenario { +// (...args: [string, string, TestFunc] | [string, TestFunc]): ReturnType +// only?: ( +// ...args: [string, string, TestFunc] | [string, TestFunc] +// ) => ReturnType +// } + +// interface DescribeScenario { +// ( +// ...args: [string, string, DescribeBlock] | [string, DescribeBlock] +// ): ReturnType +// only?: ( +// ...args: [string, string, DescribeBlock] | [string, DescribeBlock] +// ) => ReturnType +// } + +// declare global { +// // eslint-disable-next-line no-var +// var scenario: GlobalScenario +// // eslint-disable-next-line no-var +// var describeScenario: DescribeScenario +// } + +globalThis.scenario = buildScenario(it) +globalThis.scenario.only = buildScenario(it.only) +globalThis.describeScenario = buildDescribeScenario(describe) +globalThis.describeScenario.only = buildDescribeScenario(describe.only) diff --git a/packages/testing/src/web/MockProviders.tsx b/packages/testing/src/web/MockProviders.tsx index e480b43b94..fdff96138a 100644 --- a/packages/testing/src/web/MockProviders.tsx +++ b/packages/testing/src/web/MockProviders.tsx @@ -1,13 +1,10 @@ -/** - * NOTE: This module should not contain any nodejs functionality, - * because it's also used by Storybook in the browser. - */ import React from 'react' import { LocationProvider } from '@cedarjs/router' import { RedwoodProvider } from '@cedarjs/web' import { RedwoodApolloProvider } from '@cedarjs/web/apollo' +import { UserRoutes as VitestUserRoutes } from './globRoutesImporter.js' import { useAuth } from './mockAuth.js' import { MockParamsProvider } from './MockParamsProvider.js' @@ -30,7 +27,8 @@ try { UserRoutes = () => <> } -// TODO(pc): see if there are props we want to allow to be passed into our mock provider (e.g. AuthProviderProps) +// TODO(pc): see if there are props we want to allow to be passed into our mock +// provider (e.g. AuthProviderProps) export const MockProviders: React.FunctionComponent<{ children: React.ReactNode }> = ({ children }) => { @@ -38,6 +36,7 @@ export const MockProviders: React.FunctionComponent<{ + {children} diff --git a/packages/testing/src/web/MockRouter.tsx b/packages/testing/src/web/MockRouter.tsx index 0130c21be3..5eeed8fd31 100644 --- a/packages/testing/src/web/MockRouter.tsx +++ b/packages/testing/src/web/MockRouter.tsx @@ -1,3 +1,11 @@ +// For Vitest we use a plugin to swap out imports from `@cedarjs/router` to this +// file. @see ../vitest/cedarJsRoutesImportTransformPlugin.ts +// +// For Jest we overwrite the default `Router` export using jest-preset. So every +// import of @cedarjs/router will import this Router instead +// +// It's therefore important to reexport everything that we *don't* want to mock. + import type React from 'react' import { flattenAll } from '@cedarjs/router/dist/react-util' @@ -7,14 +15,12 @@ import { flattenAll } from '@cedarjs/router/dist/react-util' import { isValidRoute } from '@cedarjs/router/dist/route-validators' import type { RouterProps } from '@cedarjs/router/dist/router' import { replaceParams } from '@cedarjs/router/dist/util' + export * from '@cedarjs/router/dist/index' export const routes: { [routeName: string]: () => string } = {} /** - * We overwrite the default `Router` export (see jest-preset). So every import - * of @cedarjs/router will import this Router instead - * * This router populates the `routes.()` utility object. */ export const Router: React.FC = ({ children }) => { diff --git a/packages/testing/src/web/globRoutesImporter.ts b/packages/testing/src/web/globRoutesImporter.ts new file mode 100644 index 0000000000..6598a872fb --- /dev/null +++ b/packages/testing/src/web/globRoutesImporter.ts @@ -0,0 +1,52 @@ +/// + +// TODO(storybook): Use this for Storybook as well + +// We're building this file with esbuild, which doesn't understand the vite- +// specific `import.meta.glob` feature. So it'll just leave it as is, which is +// actually exactly what we want. We can't evaluate the glob import when +// building the framework, because at that time we have no idea what user +// project this will be used in or what routes it will have. So instead I tell +// vite to process this file when building the user's project. I do that by +// including @cedarjs/testing in `noExternal` in the default vite config (see +// lib/getMergedConfig.ts in the vite package) + +// We want to find the user's Routes file in web/src/Routes.{tsx,jsx} +// When running tests from the root of the user's project, vite will see the +// path as `/src/Routes.tsx`. When running the tests from the web/ directory, +// vite will see the path as `/Routes.tsx` +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore - Silence the TS error on this line for CJS builds +const defaultExports = import.meta.glob( + ['/src/Routes.{tsx,jsx}', '/Routes.{tsx,jsx}'], + { + import: 'default', + eager: true, + }, +) +const routesFileName = Object.keys(defaultExports)[0] + +if (!routesFileName) { + throw new Error('@cedarjs/testing: No routes found') +} + +const routesFunction = defaultExports[routesFileName] + +if (typeof routesFunction !== 'function') { + throw new Error( + '@cedarjs/testing: Routes file does not export a React component', + ) +} + +/** + * All the routes the user has defined + * + * We render this in the `` component to populate the `routes` + * import from `@cedarjs/router` to make sure code like + * `Home` works in tests + * + * The final piece to this puzzle is to realize that the user's Routes file + * imports `@cedarjs/router`, which we replace to import from '@cedarjs/testing' + * instead using a vite plugin that we only run for vitest and storybook + */ +export const UserRoutes = routesFunction as React.FC diff --git a/packages/testing/src/web/vitest/index.ts b/packages/testing/src/web/vitest/index.ts new file mode 100644 index 0000000000..62f593972b --- /dev/null +++ b/packages/testing/src/web/vitest/index.ts @@ -0,0 +1,3 @@ +export { cedarJsRouterImportTransformPlugin } from './vite-plugin-cedarjs-router-import-transform.js' +export { createAuthImportTransformPlugin } from './vite-plugin-create-auth-import-transform.js' +export { autoImportsPlugin } from './vite-plugin-auto-import.js' diff --git a/packages/testing/src/web/vitest/vite-plugin-auto-import.ts b/packages/testing/src/web/vitest/vite-plugin-auto-import.ts new file mode 100644 index 0000000000..36797a1929 --- /dev/null +++ b/packages/testing/src/web/vitest/vite-plugin-auto-import.ts @@ -0,0 +1,25 @@ +import autoImport from 'unplugin-auto-import/vite' + +export function autoImportsPlugin() { + return autoImport({ + // targets to transform + include: [ + /\.[tj]sx?$/, // .ts, .tsx, .js, .jsx + ], + + // global imports to register + imports: [ + { + '@cedarjs/testing/web': [ + 'mockGraphQLQuery', + 'mockGraphQLMutation', + 'mockCurrentUser', + ], + }, + ], + + // We provide our mocking types elsewhere and so don't need this plugin to + // generate them. + dts: false, + }) +} diff --git a/packages/testing/src/web/vitest/vite-plugin-cedarjs-router-import-transform.ts b/packages/testing/src/web/vitest/vite-plugin-cedarjs-router-import-transform.ts new file mode 100644 index 0000000000..b123aa5049 --- /dev/null +++ b/packages/testing/src/web/vitest/vite-plugin-cedarjs-router-import-transform.ts @@ -0,0 +1,24 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore - Silence the TS error on this line for CJS builds +import type { PluginOption } from 'vite' + +/** + * Replace `@cedarjs/router` imports with imports of + * `@cedarjs/testing/web/MockRouter.js` instead + */ +export function cedarJsRouterImportTransformPlugin(): PluginOption { + return { + name: 'cedarjs-router-import-transform', + enforce: 'pre', + transform(code: string, id: string) { + if (id.includes('/web/src')) { + code = code.replace( + /['"]@cedarjs\/router['"]/, + "'@cedarjs/testing/web/MockRouter.js'", + ) + } + + return code + }, + } +} diff --git a/packages/testing/src/web/vitest/vite-plugin-create-auth-import-transform.ts b/packages/testing/src/web/vitest/vite-plugin-create-auth-import-transform.ts new file mode 100644 index 0000000000..819394a54f --- /dev/null +++ b/packages/testing/src/web/vitest/vite-plugin-create-auth-import-transform.ts @@ -0,0 +1,28 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore - Silence the TS error on this line for CJS builds +import type { PluginOption } from 'vite' + +export function createAuthImportTransformPlugin(): PluginOption { + return { + name: 'create-auth-import-transform', + enforce: 'pre', + transform(code: string, id: string) { + if (id.endsWith('/web/src/auth.ts') || id.endsWith('/web/src/auth.js')) { + // Remove any existing import of `createAuth` without affecting anything + // else. + // This regex defines 4 capture groups, where the second is `createAuth` + // and the third is an (optional) comma for subsequent named imports — + // we want to remove those two. + code = code.replace( + /(^\s*import\s*{[^}]*?)(\bcreateAuth\b)(,?)([^}]*})/m, + '$1$4', + ) + // Add import to mocked `createAuth` at the top of the file. + code = + "import { createAuthentication as createAuth } from '@cedarjs/testing/auth'\n" + + code + } + return code + }, + } +} diff --git a/packages/testing/tsconfig.cjs.json b/packages/testing/tsconfig.cjs.json index a660cecf11..233539bd2c 100644 --- a/packages/testing/tsconfig.cjs.json +++ b/packages/testing/tsconfig.cjs.json @@ -3,5 +3,13 @@ "compilerOptions": { "outDir": "dist/cjs", "tsBuildInfoFile": "./tsconfig.cjs.tsbuildinfo" - } + }, + "exclude": [ + "dist", + "node_modules", + "**/__tests__", + "**/__mocks__", + "**/*.test.*", + "src/api/vitest" + ] } diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index f4d25123f0..60a7dce7af 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -3,6 +3,11 @@ import type { PluginOption } from 'vite' import { getWebSideDefaultBabelConfig } from '@cedarjs/babel-config' import { getConfig } from '@cedarjs/project-config' +import { + autoImportsPlugin, + cedarJsRouterImportTransformPlugin, + createAuthImportTransformPlugin, +} from '@cedarjs/testing/web/vitest' import { cedarCellTransform } from './plugins/vite-plugin-cedar-cell.js' import { cedarEntryInjectionPlugin } from './plugins/vite-plugin-cedar-entry-injection.js' @@ -24,10 +29,14 @@ export { cedarTransformJsAsJsx } from './plugins/vite-plugin-jsx-loader.js' export { cedarMergedConfig } from './plugins/vite-plugin-merged-config.js' export { cedarSwapApolloProvider } from './plugins/vite-plugin-swap-apollo-provider.js' +type PluginOptions = { + mode?: string | undefined +} + /** * Pre-configured vite plugin, with required config for CedarJS apps. */ -export function cedar(): PluginOption[] { +export function cedar({ mode }: PluginOptions = {}): PluginOption[] { const rwConfig = getConfig() const rscEnabled = rwConfig.experimental?.rsc?.enabled @@ -51,6 +60,9 @@ export function cedar(): PluginOption[] { } return [ + mode === 'test' && cedarJsRouterImportTransformPlugin(), + mode === 'test' && createAuthImportTransformPlugin(), + mode === 'test' && autoImportsPlugin(), cedarNodePolyfills(), cedarHtmlEnvPlugin(), cedarEntryInjectionPlugin(), diff --git a/packages/vite/src/lib/getMergedConfig.ts b/packages/vite/src/lib/getMergedConfig.ts index 8110f6e67d..6f319af527 100644 --- a/packages/vite/src/lib/getMergedConfig.ts +++ b/packages/vite/src/lib/getMergedConfig.ts @@ -1,8 +1,8 @@ import path from 'node:path' import type { InputOption } from 'rollup' -import type { ConfigEnv, UserConfig } from 'vite' import { mergeConfig } from 'vite' +import type { ConfigEnv, ViteUserConfig } from 'vitest/config' import type { Config, Paths } from '@cedarjs/project-config' import { @@ -19,7 +19,7 @@ import { * build */ export function getMergedConfig(rwConfig: Config, rwPaths: Paths) { - return (userConfig: UserConfig, env: ConfigEnv): UserConfig => { + return (userConfig: ViteUserConfig, env: ConfigEnv): ViteUserConfig => { let apiHost = process.env.REDWOOD_API_HOST apiHost ??= rwConfig.api.host apiHost ??= process.env.NODE_ENV === 'production' ? '0.0.0.0' : '[::]' @@ -35,7 +35,7 @@ export function getMergedConfig(rwConfig: Config, rwPaths: Paths) { apiPort = rwConfig.api.port } - const defaultRwViteConfig: UserConfig = { + const defaultRwViteConfig: ViteUserConfig = { root: rwPaths.web.src, // @MARK: when we have these aliases, the warnings from the FE server go // away BUT, if you have imports like this: @@ -145,6 +145,15 @@ export function getMergedConfig(rwConfig: Config, rwPaths: Paths) { }, }, }, + ssr: { + // `@cedarjs/testing` is not externalized in order to support + // `import.meta.glob`, which we use in one of the files in the package + noExternal: env.mode === 'test' ? ['@cedarjs/testing'] : [], + }, + test: { + globals: false, + environment: 'jsdom', + }, } return mergeConfig(defaultRwViteConfig, userConfig) diff --git a/packages/vite/tsconfig.build.json b/packages/vite/tsconfig.build.json index c5ad0e00c5..5380b45b53 100644 --- a/packages/vite/tsconfig.build.json +++ b/packages/vite/tsconfig.build.json @@ -13,6 +13,7 @@ { "path": "../project-config" }, { "path": "../router/tsconfig.build.json" }, { "path": "../server-store" }, + { "path": "../testing/tsconfig.build.json" }, { "path": "../web/tsconfig.build.json" } ] } diff --git a/packages/vite/tsconfig.json b/packages/vite/tsconfig.json index 4f59899bb5..0b25aa9ec7 100644 --- a/packages/vite/tsconfig.json +++ b/packages/vite/tsconfig.json @@ -15,6 +15,7 @@ { "path": "../project-config" }, { "path": "../router/tsconfig.build.json" }, { "path": "../server-store" }, + { "path": "../testing/tsconfig.build.json" }, { "path": "../web/tsconfig.build.json" } ] }