From 8eae847efc6cf1d1bcb370591e41030ac89b8b66 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 12 Sep 2023 11:35:39 +0200 Subject: [PATCH] feat(sourcemaps): Automatically insert Sentry Webpack plugin (#432) Add automatic Webpack plugin insertion to the sourcemaps wizard's webpack flow. Behaviour of when to modify or create a new file is identical with vite flow, similarly, when we shoud fallback copy/paste instructions. More details in PR Co-authored-by: Luca Forstner --- CHANGELOG.md | 4 + src/sourcemaps/tools/vite.ts | 110 ++----- src/sourcemaps/tools/webpack.ts | 399 ++++++++++++++++++++++++-- src/sveltekit/sdk-setup.ts | 8 +- src/utils/ast-utils.ts | 30 +- src/utils/clack-utils.ts | 141 +++++++++ test/sourcemaps/tools/webpack.test.ts | 303 +++++++++++++++++++ test/utils/ast-utils.test.ts | 37 ++- test/utils/clack-utils.test.ts | 142 +++++++++ 9 files changed, 1038 insertions(+), 136 deletions(-) create mode 100644 test/sourcemaps/tools/webpack.test.ts create mode 100644 test/utils/clack-utils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f2abdacd..371c9fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- feat(sourcemaps): Automatically insert Sentry Webpack plugin (#432) + ## 3.11.0 - feat(android): Add wizard support for Android (#389) diff --git a/src/sourcemaps/tools/vite.ts b/src/sourcemaps/tools/vite.ts index c85b7247..bf1cfb49 100644 --- a/src/sourcemaps/tools/vite.ts +++ b/src/sourcemaps/tools/vite.ts @@ -15,8 +15,11 @@ import chalk from 'chalk'; import { abortIfCancelled, addDotEnvSentryBuildPluginFile, + askForToolConfigPath, + createNewConfigFile, getPackageDotJson, installPackage, + showCopyPasteInstructions, } from '../../utils/clack-utils'; import { hasPackageInstalled } from '../../utils/package-json'; @@ -91,58 +94,43 @@ export const configureVitePlugin: SourceMapUploadToolConfigurationFunction = }); const viteConfigPath = - findFile(path.resolve(process.cwd(), 'vite.config')) || - (await askForViteConfigPath()); + findFile(path.resolve(process.cwd(), 'vite.config')) ?? + (await askForToolConfigPath('Vite', 'vite.config.js')); let successfullyAdded = false; if (viteConfigPath) { successfullyAdded = await addVitePluginToConfig(viteConfigPath, options); } else { - successfullyAdded = await createNewViteConfig(options); + successfullyAdded = await createNewConfigFile( + path.join(process.cwd(), 'vite.config.js'), + getViteConfigSnippet(options, false), + 'More information about vite configs: https://vitejs.dev/config/', + ); + Sentry.setTag( + 'created-new-config', + successfullyAdded ? 'success' : 'fail', + ); } if (successfullyAdded) { + clack.log.info( + `We recommend checking the ${ + viteConfigPath ? 'modified' : 'added' + } file after the wizard finished to ensure it works with your build setup.`, + ); + Sentry.setTag('ast-mod', 'success'); } else { Sentry.setTag('ast-mod', 'fail'); await showCopyPasteInstructions( path.basename(viteConfigPath || 'vite.config.js'), - options, + getViteConfigSnippet(options, true), ); } await addDotEnvSentryBuildPluginFile(options.authToken); }; -async function createNewViteConfig( - options: SourceMapUploadToolConfigurationOptions, -): Promise { - try { - await fs.promises.writeFile( - 'vite.config.js', - getViteConfigSnippet(options, false), - ); - Sentry.setTag('created-new-config', 'success'); - return true; - } catch (e) { - debug(e); - Sentry.setTag('created-new-config', 'fail'); - clack.log.warn( - `Could not create a new ${chalk.cyan( - 'vite.config.js', - )} file. Please create one manually and follow the instructions below.`, - ); - - clack.log.info( - chalk.gray( - 'More information about vite configs: https://vitejs.dev/config/', - ), - ); - - return false; - } -} - export async function addVitePluginToConfig( viteConfigPath: string, options: SourceMapUploadToolConfigurationOptions, @@ -156,7 +144,7 @@ export async function addVitePluginToConfig( const mod = parseModule(viteConfigContent); - if (hasSentryContent(mod)) { + if (hasSentryContent(mod.$ast as t.Program)) { const shouldContinue = await abortIfCancelled( clack.select({ message: `${prettyViteConfigFilename} already contains Sentry-related code. Should the wizard modify it anyway?`, @@ -215,60 +203,6 @@ export async function addVitePluginToConfig( } } -async function showCopyPasteInstructions( - viteConfigFilename: string, - options: SourceMapUploadToolConfigurationOptions, -) { - clack.log.step( - `Add the following code to your ${chalk.cyan(viteConfigFilename)} file:`, - ); - - // Intentionally logging directly to console here so that the code can be copied/pasted directly - // eslint-disable-next-line no-console - console.log(`\n${getViteConfigSnippet(options, true)}`); - - await abortIfCancelled( - clack.select({ - message: 'Did you copy the snippet above?', - options: [{ label: 'Yes, continue!', value: true }], - initialValue: true, - }), - ); -} - -async function askForViteConfigPath(): Promise { - const hasViteConfig = await abortIfCancelled( - clack.confirm({ - message: `Do you have a vite config file (e.g. ${chalk.cyan( - 'vite.config.js', - )}?`, - initialValue: true, - }), - ); - - if (!hasViteConfig) { - return undefined; - } - - return await abortIfCancelled( - clack.text({ - message: 'Please enter the path to your vite config file:', - placeholder: `.${path.sep}vite.config.js`, - validate: (value) => { - if (!value) { - return 'Please enter a path.'; - } - - try { - fs.accessSync(value); - } catch { - return 'Could not access the file at this path.'; - } - }, - }), - ); -} - function enableSourcemapGeneration(program: t.Program): boolean { const configObj = getViteConfigObject(program); diff --git a/src/sourcemaps/tools/webpack.ts b/src/sourcemaps/tools/webpack.ts index b87b81cb..28121ee2 100644 --- a/src/sourcemaps/tools/webpack.ts +++ b/src/sourcemaps/tools/webpack.ts @@ -1,11 +1,24 @@ +import * as path from 'path'; +import * as fs from 'fs'; + // @ts-ignore - clack is ESM and TS complains about that. It works though -import clack, { select } from '@clack/prompts'; +import * as clack from '@clack/prompts'; import chalk from 'chalk'; + +import * as recast from 'recast'; +import x = recast.types; +import t = x.namedTypes; + +import * as Sentry from '@sentry/node'; + import { abortIfCancelled, addDotEnvSentryBuildPluginFile, + askForToolConfigPath, + createNewConfigFile, getPackageDotJson, installPackage, + showCopyPasteInstructions, } from '../../utils/clack-utils'; import { hasPackageInstalled } from '../../utils/package-json'; @@ -14,28 +27,57 @@ import { SourceMapUploadToolConfigurationOptions, } from './types'; -const getCodeSnippet = (options: SourceMapUploadToolConfigurationOptions) => - chalk.gray(` -${chalk.greenBright( - 'const { sentryWebpackPlugin } = require("@sentry/webpack-plugin");', -)} +import { findFile, hasSentryContent } from '../../utils/ast-utils'; +import { debug } from '../../utils/debug'; -module.exports = { - // ... other config options - ${chalk.greenBright( - 'devtool: "source-map", // Source map generation must be turned on', - )} - plugins: [ - ${chalk.greenBright(`sentryWebpackPlugin({ +const getCodeSnippet = ( + options: SourceMapUploadToolConfigurationOptions, + colors: boolean, +) => { + const rawImportStmt = + 'const { sentryWebpackPlugin } = require("@sentry/webpack-plugin");'; + const rawGenerateSourceMapsOption = + 'devtool: "source-map", // Source map generation must be turned on'; + const rawSentryWebpackPluginFunction = `sentryWebpackPlugin({ authToken: process.env.SENTRY_AUTH_TOKEN, org: "${options.orgSlug}", project: "${options.projectSlug}",${ - options.selfHosted ? `\n url: "${options.url}",` : '' - } - })`)}, - ], + options.selfHosted ? `\n url: "${options.url}",` : '' + } + })`; + + const importStmt = colors ? chalk.greenBright(rawImportStmt) : rawImportStmt; + const generateSourceMapsOption = colors + ? chalk.greenBright(rawGenerateSourceMapsOption) + : rawGenerateSourceMapsOption; + const sentryWebpackPluginFunction = colors + ? chalk.greenBright(rawSentryWebpackPluginFunction) + : rawSentryWebpackPluginFunction; + + const code = getWebpackConfigContent( + importStmt, + generateSourceMapsOption, + sentryWebpackPluginFunction, + ); + + return colors ? chalk.gray(code) : code; }; -`); + +const getWebpackConfigContent = ( + importStmt: string, + generateSourceMapsOption: string, + sentryWebpackPluginFunction: string, +) => `${importStmt} + +module.exports = { + // ... other options + ${generateSourceMapsOption}, + plugins: [ + // Put the Sentry Webpack plugin after all other plugins + ${sentryWebpackPluginFunction}, + ], +} +`; export const configureWebPackPlugin: SourceMapUploadToolConfigurationFunction = async (options) => { @@ -47,21 +89,318 @@ export const configureWebPackPlugin: SourceMapUploadToolConfigurationFunction = ), }); - clack.log.step( - `Add the following code to your ${chalk.bold('webpack.config.js')} file:`, - ); + const webpackConfigPath = + findFile(path.resolve(process.cwd(), 'webpack.config')) ?? + (await askForToolConfigPath('Webpack', 'webpack.config.js')); + + let successfullyAdded = false; + if (webpackConfigPath) { + successfullyAdded = await modifyWebpackConfig(webpackConfigPath, options); + } else { + successfullyAdded = await createNewConfigFile( + path.join(process.cwd(), 'webpack.config.js'), + getCodeSnippet(options, false), + 'More information about Webpack configs: https://vitejs.dev/config/', + ); + Sentry.setTag( + 'created-new-config', + successfullyAdded ? 'success' : 'fail', + ); + } + + if (successfullyAdded) { + clack.log.info( + `We recommend checking the ${ + webpackConfigPath ? 'modified' : 'added' + } file after the wizard finished to ensure it works with your build setup.`, + ); + + Sentry.setTag('ast-mod', 'success'); + } else { + Sentry.setTag('ast-mod', 'fail'); + await showCopyPasteInstructions( + path.basename(webpackConfigPath || 'webpack.config.js'), + getCodeSnippet(options, true), + ); + } + + await addDotEnvSentryBuildPluginFile(options.authToken); + }; + +/** + * Modifies a webpack config file to enable source map generation and add the Sentry webpack plugin + * exported only for testing + */ +export async function modifyWebpackConfig( + webpackConfigPath: string, + options: SourceMapUploadToolConfigurationOptions, +): Promise { + try { + const webpackConfig = await fs.promises.readFile(webpackConfigPath, { + encoding: 'utf-8', + }); + + const prettyConfigFilename = chalk.cyan(path.basename(webpackConfigPath)); + + // no idea why recast returns any here, this is dumb :/ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const program = recast.parse(webpackConfig.toString()).program as t.Program; + + if (!(await shouldModifyWebpackConfig(program, prettyConfigFilename))) { + // Sentry tag is set in shouldModifyWebpackConfig + return false; + } + + const exportStmt = getCjsModuleExports(program); + if (!exportStmt) { + // We only care about CJS at the moment since it's probably the most widely used format for webpack configs. + debug(`Could not find module.exports = {...} in ${webpackConfigPath}.`); + Sentry.setTag('ast-mod-fail-reason', 'config-object-not-found'); + return false; + } + + const configObject = getWebpackConfigObject(exportStmt, program); + + if (!configObject) { + debug(`Couldn't find config object in ${webpackConfigPath}`); + Sentry.setTag('ast-mod-fail-reason', 'config-object-not-found'); + return false; + } - // Intentially logging directly to console here so that the code can be copied/pasted directly - // eslint-disable-next-line no-console - console.log(getCodeSnippet(options)); + const enabledSourcemaps = enableSourcemapsGeneration(configObject); - await abortIfCancelled( - select({ - message: 'Did you copy the snippet above?', - options: [{ label: 'Yes, continue!', value: true }], + if (enabledSourcemaps) { + clack.log.success( + `Enabled source map generation in ${prettyConfigFilename}.`, + ); + } else { + clack.log.warn( + `Couldn't enable source maps generation in ${prettyConfigFilename} Please follow the instructions below.`, + ); + Sentry.setTag('ast-mod-fail-reason', 'insertion-fail'); + return false; + } + + const addedPlugin = addSentryWebpackPlugin(program, configObject, options); + if (addedPlugin) { + clack.log.success( + `Added Sentry webpack plugin to ${prettyConfigFilename}.`, + ); + } else { + clack.log.warn( + `Couldn't add Sentry webpack plugin to ${prettyConfigFilename}. Please follow the instructions below.`, + ); + Sentry.setTag('ast-mod-fail-reason', 'insertion-fail'); + return false; + } + + const code = recast.print(program).code; + await fs.promises.writeFile(webpackConfigPath, code); + + return true; + } catch (e) { + Sentry.setTag('ast-mod-fail-reason', 'insertion-fail'); + debug(e); + return false; + } +} + +async function shouldModifyWebpackConfig( + program: t.Program, + prettyConfigFilename: string, +) { + if (hasSentryContent(program)) { + const shouldContinue = await abortIfCancelled( + clack.select({ + message: `Seems like ${prettyConfigFilename} already contains Sentry-related code. Should the wizard modify it anyway?`, + options: [ + { + label: 'Yes, add the Sentry Webpack plugin', + value: true, + }, + { + label: 'No, show me instructions to manually add the plugin', + value: false, + }, + ], initialValue: true, }), ); - await addDotEnvSentryBuildPluginFile(options.authToken); - }; + if (!shouldContinue) { + Sentry.setTag('ast-mod-fail-reason', 'has-sentry-content'); + return false; + } + } + + return true; +} + +function addSentryWebpackPlugin( + program: t.Program, + configObj: t.ObjectExpression, + options: SourceMapUploadToolConfigurationOptions, +) { + const b = addSentryWebpackPluginImport(program); + + const sentryPluginCall = b.callExpression( + b.identifier('sentryWebpackPlugin'), + [ + b.objectExpression([ + b.objectProperty( + b.identifier('authToken'), + b.identifier('process.env.SENTRY_AUTH_TOKEN'), + ), + b.objectProperty(b.identifier('org'), b.stringLiteral(options.orgSlug)), + b.objectProperty( + b.identifier('project'), + b.stringLiteral(options.projectSlug), + ), + ...(options.selfHosted + ? [ + b.objectProperty( + b.identifier('url'), + b.stringLiteral(options.url), + ), + ] + : []), + ]), + ], + ); + + const pluginsProp = configObj.properties.find( + (p): p is t.Property => + p.type === 'Property' && + p.key.type === 'Identifier' && + p.key.name === 'plugins', + ); + + if (pluginsProp) { + if (pluginsProp.value.type === 'ArrayExpression') { + pluginsProp.value.elements.push(sentryPluginCall); + } else { + pluginsProp.value = b.arrayExpression([sentryPluginCall]); + } + return true; + } + + configObj.properties.push( + b.objectProperty( + b.identifier('plugins'), + b.arrayExpression([sentryPluginCall]), + ), + ); + + return true; +} + +function addSentryWebpackPluginImport(program: t.Program) { + const b = recast.types.builders; + + const sentryPluginRequireStmt = b.variableDeclaration('const', [ + b.variableDeclarator( + b.objectPattern([ + b.objectProperty.from({ + key: b.identifier('sentryWebpackPlugin'), + value: b.identifier('sentryWebpackPlugin'), + shorthand: true, + }), + ]), + b.callExpression(b.identifier('require'), [ + b.stringLiteral('@sentry/webpack-plugin'), + ]), + ), + ]); + + program.body.unshift(sentryPluginRequireStmt); + return b; +} + +function enableSourcemapsGeneration(configObj: t.ObjectExpression): boolean { + const b = recast.types.builders; + + const devtoolProp = configObj.properties.find( + (p): p is t.Property => + p.type === 'Property' && + p.key.type === 'Identifier' && + p.key.name === 'devtool', + ); + + if (devtoolProp) { + // devtool can have quite a lot of source maps values. + // see: https://webpack.js.org/configuration/devtool/#devtool + // For Sentry to work best, we should set it to "source-map" or "hidden-source-map" + // Heuristic: + // - all values that contain "hidden" will be set to "hidden-source-map" + // - all other values will be set to "source-map" + if ( + (devtoolProp.value.type === 'Literal' || + devtoolProp.value.type === 'StringLiteral') && + devtoolProp.value.value?.toString().startsWith('hidden-') + ) { + devtoolProp.value = b.stringLiteral('hidden-source-map'); + } else { + devtoolProp.value = b.stringLiteral('source-map'); + } + return true; + } + + configObj.properties.push( + b.objectProperty(b.identifier('devtool'), b.stringLiteral('source-map')), + ); + + return true; +} + +function getWebpackConfigObject( + moduleExports: t.AssignmentExpression, + program: t.Program, +): t.ObjectExpression | undefined { + const rhs = moduleExports.right; + if (rhs.type === 'ObjectExpression') { + return rhs; + } + if (rhs.type === 'Identifier') { + const configId = rhs.name; + + const configDeclaration = program.body.find( + (s): s is t.VariableDeclaration => + s.type === 'VariableDeclaration' && + !!s.declarations.find( + (d) => + d.type === 'VariableDeclarator' && + d.id.type === 'Identifier' && + d.id.name === configId, + ), + ); + + const declarator = configDeclaration?.declarations.find( + (d): d is t.VariableDeclarator => + d.type === 'VariableDeclarator' && + d.id.type === 'Identifier' && + d.id.name === configId, + ); + + return declarator?.init?.type === 'ObjectExpression' + ? declarator.init + : undefined; + } + + return undefined; +} + +function getCjsModuleExports( + program: t.Program, +): t.AssignmentExpression | undefined { + const moduleExports = program.body.find( + (s): s is t.ExpressionStatement => + s.type === 'ExpressionStatement' && + s.expression.type === 'AssignmentExpression' && + s.expression.left.type === 'MemberExpression' && + s.expression.left.object.type === 'Identifier' && + s.expression.left.object.name === 'module' && + s.expression.left.property.type === 'Identifier' && + s.expression.left.property.name === 'exports', + ); + return moduleExports?.expression as t.AssignmentExpression; +} diff --git a/src/sveltekit/sdk-setup.ts b/src/sveltekit/sdk-setup.ts index 172ceac1..09613040 100644 --- a/src/sveltekit/sdk-setup.ts +++ b/src/sveltekit/sdk-setup.ts @@ -17,6 +17,10 @@ import { abortIfCancelled, isUsingTypeScript } from '../utils/clack-utils'; import { debug } from '../utils/debug'; import { findFile, hasSentryContent } from '../utils/ast-utils'; +import * as recast from 'recast'; +import x = recast.types; +import t = x.namedTypes; + const SVELTE_CONFIG_FILE = 'svelte.config.js'; export type PartialSvelteConfig = { @@ -139,7 +143,7 @@ async function mergeHooksFile( dsn: string, ): Promise { const originalHooksMod = await loadFile(hooksFile); - if (hasSentryContent(originalHooksMod)) { + if (hasSentryContent(originalHooksMod.$ast as t.Program)) { // We don't want to mess with files that already have Sentry content. // Let's just bail out at this point. clack.log.warn( @@ -399,7 +403,7 @@ async function modifyViteConfig( try { const viteModule = parseModule(viteConfigContent); - if (hasSentryContent(viteModule)) { + if (hasSentryContent(viteModule.$ast as t.Program)) { clack.log.warn( `File ${chalk.cyan( path.basename(viteConfigPath), diff --git a/src/utils/ast-utils.ts b/src/utils/ast-utils.ts index 4379e28b..bb15f598 100644 --- a/src/utils/ast-utils.ts +++ b/src/utils/ast-utils.ts @@ -1,6 +1,8 @@ import * as fs from 'fs'; -// @ts-ignore - magicast is ESM and TS complains about that. It works though -import { ProxifiedModule } from 'magicast'; + +import * as recast from 'recast'; +import x = recast.types; +import t = x.namedTypes; /** * Checks if a file where we don't know its concrete file type yet exists @@ -8,15 +10,29 @@ import { ProxifiedModule } from 'magicast'; */ export function findFile( filePath: string, - fileTypes: string[] = ['.js', '.ts', '.mjs'], + fileTypes: string[] = ['.js', '.ts', '.mjs', '.cjs'], ): string | undefined { return fileTypes .map((type) => `${filePath}${type}`) .find((file) => fs.existsSync(file)); } -/** Checks if a Sentry package is already mentioned in the file */ -export function hasSentryContent(mod: ProxifiedModule): boolean { - const imports = mod.imports.$items.map((i) => i.from); - return !!imports.find((i) => i.startsWith('@sentry/')); +/** + * checks for require('@sentry/*') syntax + */ +export function hasSentryContent(program: t.Program): boolean { + let foundSentry: boolean | undefined = false; + recast.visit(program, { + visitStringLiteral(path) { + foundSentry = foundSentry || path.node.value.startsWith('@sentry/'); + this.traverse(path); + }, + visitLiteral(path) { + foundSentry = + foundSentry || path.node.value?.toString().startsWith('@sentry/'); + this.traverse(path); + }, + }); + + return !!foundSentry; } diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts index dcbada0e..c78c7001 100644 --- a/src/utils/clack-utils.ts +++ b/src/utils/clack-utils.ts @@ -17,6 +17,7 @@ import { installPackageWithPackageManager, packageManagers, } from './package-manager'; +import { debug } from './debug'; const opn = require('opn') as ( url: string, @@ -797,3 +798,143 @@ async function askForProjectSelection( return selection; } + +/** + * Asks users if they have a config file for @param tool (e.g. Vite). + * If yes, asks users to specify the path to their config file. + * + * Use this helper function as a fallback mechanism if the lookup for + * a config file with its most usual location/name fails. + * + * @param toolName Name of the tool for which we're looking for the config file + * @param configFileName Name of the most common config file name (e.g. vite.config.js) + * + * @returns a user path to the config file or undefined if the user doesn't have a config file + */ +export async function askForToolConfigPath( + toolName: string, + configFileName: string, +): Promise { + const hasConfig = await abortIfCancelled( + clack.confirm({ + message: `Do you have a ${toolName} config file (e.g. ${chalk.cyan( + configFileName, + )}?`, + initialValue: true, + }), + ); + + if (!hasConfig) { + return undefined; + } + + return await abortIfCancelled( + clack.text({ + message: `Please enter the path to your ${toolName} config file:`, + placeholder: path.join('.', configFileName), + validate: (value) => { + if (!value) { + return 'Please enter a path.'; + } + + try { + fs.accessSync(value); + } catch { + return 'Could not access the file at this path.'; + } + }, + }), + ); +} + +/** + * Prints copy/paste-able instructions to the console. + * Afterwards asks the user if they added the code snippet to their file. + * + * While there's no point in providing a "no" answer here, it gives users time to fulfill the + * task before the wizard continues with additional steps. + * + * Use this function if you want to show users instructions on how to add/modify + * code in their file. This is helpful if automatic insertion failed or is not possible/feasible. + * + * @param filename the name of the file to which the code snippet should be applied. + * If a path is provided, only the filename will be used. + * @param codeSnippet the snippet to be printed. + * Make sure to follow the diff-like format of highlighting lines that require changes + * and showing unchanged lines in gray. + * + * TODO: Link to wizard spec (develop) once it is live + * TODO: refactor copy paste instructions across different wizards to use this function. + * this might require adding a custom message parameter to the function + */ +export async function showCopyPasteInstructions( + filename: string, + codeSnippet: string, +): Promise { + clack.log.step( + `Add the following code to your ${chalk.cyan( + path.basename(filename), + )} file:`, + ); + + // Intentionally logging directly to console here so that the code can be copied/pasted directly + // eslint-disable-next-line no-console + console.log(`\n${codeSnippet}`); + + await abortIfCancelled( + clack.select({ + message: 'Did you apply the snippet above?', + options: [{ label: 'Yes, continue!', value: true }], + initialValue: true, + }), + ); +} + +/** + * Creates a new config file with the given @param filepath and @param codeSnippet. + * + * Use this function to create a new config file for users. This is useful + * when users answered that they don't yet have a config file for a tool. + * + * (This doesn't mean that they don't yet have some other way of configuring + * their tool but we can leave it up to them to figure out how to merge configs + * here.) + * + * @param filepath absolute path to the new config file + * @param codeSnippet the snippet to be inserted into the file + * @param moreInformation (optional) the message to be printed after the file was created + * For example, this can be a link to more information about configuring the tool. + * + * @returns true on sucess, false otherwise + */ +export async function createNewConfigFile( + filepath: string, + codeSnippet: string, + moreInformation?: string, +): Promise { + if (!path.isAbsolute(filepath)) { + debug(`createNewConfigFile: filepath is not absolute: ${filepath}`); + return false; + } + + const prettyFilename = chalk.cyan(path.relative(process.cwd(), filepath)); + + try { + await fs.promises.writeFile(filepath, codeSnippet); + + clack.log.success(`Added new ${prettyFilename} file.`); + + if (moreInformation) { + clack.log.info(chalk.gray(moreInformation)); + } + + return true; + } catch (e) { + debug(e); + clack.log.warn( + `Could not create a new ${prettyFilename} file. Please create one manually and follow the instructions below.`, + ); + } + + return false; +} diff --git a/test/sourcemaps/tools/webpack.test.ts b/test/sourcemaps/tools/webpack.test.ts new file mode 100644 index 00000000..f8b259ea --- /dev/null +++ b/test/sourcemaps/tools/webpack.test.ts @@ -0,0 +1,303 @@ +import * as fs from 'fs'; + +import { modifyWebpackConfig } from '../../../src/sourcemaps/tools/webpack'; + +function updateFileContent(content: string): void { + fileContent = content; +} + +let fileContent = ''; + +jest.mock('@clack/prompts', () => { + return { + log: { + info: jest.fn(), + success: jest.fn(), + }, + select: jest.fn().mockImplementation(() => Promise.resolve(true)), + isCancel: jest.fn().mockReturnValue(false), + }; +}); + +jest + .spyOn(fs.promises, 'readFile') + .mockImplementation(() => Promise.resolve(fileContent)); + +const writeFileSpy = jest + .spyOn(fs.promises, 'writeFile') + .mockImplementation(() => Promise.resolve(void 0)); + +const noSourcemapNoPluginsPojo = `module.exports = { + entry: "./src/index.js", + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, +};`; + +const noSourcemapNoPluginsPojoResult = `const { + sentryWebpackPlugin +} = require("@sentry/webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, + + devtool: "source-map", + + plugins: [sentryWebpackPlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: "my-org", + project: "my-project" + })] +};`; + +const noSourcemapsNoPluginsId = `const config = { + entry: "./src/index.js", + + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, +}; + +module.exports = config;`; + +const noSourcemapsNoPluginsIdResult = `const { + sentryWebpackPlugin +} = require("@sentry/webpack-plugin"); + +const config = { + entry: "./src/index.js", + + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, + + devtool: "source-map", + + plugins: [sentryWebpackPlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: "my-org", + project: "my-project" + })] +}; + +module.exports = config;`; + +const hiddenSourcemapNoPluginsId = `const config = { + entry: "./src/index.js", + + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, + + devtool: "hidden-cheap-source-map", +}; + +module.exports = config; + `; +const hiddenSourcemapNoPluginsIdResult = `const { + sentryWebpackPlugin +} = require("@sentry/webpack-plugin"); + +const config = { + entry: "./src/index.js", + + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, + + devtool: "hidden-source-map", + + plugins: [sentryWebpackPlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: "my-org", + project: "my-project" + })] +}; + +module.exports = config;`; + +const arbitrarySourcemapNoPluginsId = ` +const config = { + entry: "./src/index.js", + + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, + + devtool: getSourcemapSetting(), +}; + +module.exports = config; + `; +const arbitrarySourcemapNoPluginsIdResult = `const { + sentryWebpackPlugin +} = require("@sentry/webpack-plugin"); + +const config = { + entry: "./src/index.js", + + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, + + devtool: "source-map", + + plugins: [sentryWebpackPlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: "my-org", + project: "my-project" + })] +}; + +module.exports = config;`; + +const noSourcemapUndefinedPluginsPojo = `module.exports = { + entry: "./src/index.js", + plugins: undefined, + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, +};`; + +const noSourcemapUndefinedPluginsPojoResult = `const { + sentryWebpackPlugin +} = require("@sentry/webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + + plugins: [sentryWebpackPlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: "my-org", + project: "my-project" + })], + + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, + + devtool: "source-map" +};`; + +const noSourcemapPluginsPojo = `module.exports = { + entry: "./src/index.js", + plugins: [ + new HtmlWebpackPlugin(), + new MiniCssExtractPlugin(), + ], + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, +};`; + +const noSourcemapPluginsPojoResult = `const { + sentryWebpackPlugin +} = require("@sentry/webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + + plugins: [new HtmlWebpackPlugin(), new MiniCssExtractPlugin(), sentryWebpackPlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: "my-org", + project: "my-project" + })], + + output: { + filename: "main.js", + path: path.resolve(__dirname, "build"), + }, + + devtool: "source-map" +};`; + +describe('modifyWebpackConfig', () => { + afterEach(() => { + fileContent = ''; + jest.clearAllMocks(); + }); + + it.each([ + [ + 'no sourcemap option, no plugins, object', + noSourcemapNoPluginsPojo, + noSourcemapNoPluginsPojoResult, + ], + [ + 'no sourcemap option, no plugins, identifier', + noSourcemapsNoPluginsId, + noSourcemapsNoPluginsIdResult, + ], + [ + 'hidden sourcemap option, no plugins, identifier', + hiddenSourcemapNoPluginsId, + hiddenSourcemapNoPluginsIdResult, + ], + [ + 'arbitrary sourcemap option, no plugins, identifier', + arbitrarySourcemapNoPluginsId, + arbitrarySourcemapNoPluginsIdResult, + ], + [ + 'no sourcemap option, plugins, object', + noSourcemapUndefinedPluginsPojo, + noSourcemapUndefinedPluginsPojoResult, + ], + [ + 'no sourcemap option, plugins, object', + noSourcemapPluginsPojo, + noSourcemapPluginsPojoResult, + ], + ])( + 'adds plugin and source maps emission to the webpack config (%s)', + async (_, originalCode, expectedCode) => { + updateFileContent(originalCode); + + // updateFileContent(originalCode); + const addedCode = await modifyWebpackConfig('', { + authToken: '', + orgSlug: 'my-org', + projectSlug: 'my-project', + selfHosted: false, + url: 'https://sentry.io/', + }); + + expect(writeFileSpy).toHaveBeenCalledTimes(1); + const [[, fileContent]] = writeFileSpy.mock.calls; + expect(fileContent).toBe(expectedCode); + expect(addedCode).toBe(true); + }, + ); + + it('adds the url parameter to the webpack plugin options if self-hosted', async () => { + updateFileContent(noSourcemapNoPluginsPojo); + + const addedCode = await modifyWebpackConfig('', { + authToken: '', + orgSlug: 'my-org', + projectSlug: 'my-project', + selfHosted: true, + url: 'https://santry.io/', + }); + + expect(writeFileSpy).toHaveBeenCalledTimes(1); + const [[, fileContent]] = writeFileSpy.mock.calls; + expect(fileContent).toContain('url: "https://santry.io/"'); + expect(addedCode).toBe(true); + }); +}); diff --git a/test/utils/ast-utils.test.ts b/test/utils/ast-utils.test.ts index a1397f8d..f704a23b 100644 --- a/test/utils/ast-utils.test.ts +++ b/test/utils/ast-utils.test.ts @@ -1,22 +1,37 @@ -//@ts-ignore -import { parseModule } from 'magicast'; import { hasSentryContent } from '../../src/utils/ast-utils'; +import * as recast from 'recast'; + describe('AST utils', () => { describe('hasSentryContent', () => { - it("returns true if a '@sentry/' import was found in the parsed module", () => { - const code = ` + it.each([ + ` + const { sentryVitePlugin } = require("@sentry/vite-plugin"); + const somethingelse = require('gs'); + `, + ` import { sentryVitePlugin } from "@sentry/vite-plugin"; import * as somethingelse from 'gs'; export default { plugins: [sentryVitePlugin()] } - `; + `, + ])( + "returns true if a require('@sentry/') call was found in the parsed module", + (code) => { + // recast.parse returns a Program node (or fails) but it's badly typed as any + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const program = recast.parse(code) + .program as recast.types.namedTypes.Program; + expect(hasSentryContent(program)).toBe(true); + }, + ); - expect(hasSentryContent(parseModule(code))).toBe(true); - }); it.each([ + `const whatever = require('something')`, + `// const {sentryWebpackPlugin} = require('@sentry/webpack-plugin')`, + `const {sAntryWebpackPlugin} = require('webpack-plugin-@sentry')`, ` import * as somethingelse from 'gs'; export default { @@ -35,9 +50,13 @@ describe('AST utils', () => { } `, ])( - "reutrns false for modules without a valid '@sentry/' import", + "returns false if the file doesn't contain any require('@sentry/') calls", (code) => { - expect(hasSentryContent(parseModule(code))).toBe(false); + // recast.parse returns a Program node (or fails) but it's badly typed as any + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const program = recast.parse(code) + .program as recast.types.namedTypes.Program; + expect(hasSentryContent(program)).toBe(false); }, ); }); diff --git a/test/utils/clack-utils.test.ts b/test/utils/clack-utils.test.ts new file mode 100644 index 00000000..8c67133d --- /dev/null +++ b/test/utils/clack-utils.test.ts @@ -0,0 +1,142 @@ +import { + askForToolConfigPath, + createNewConfigFile, +} from '../../src/utils/clack-utils'; + +import * as fs from 'fs'; + +type ClackMock = { + confirm: jest.Mock; + text: jest.Mock; + isCancel: jest.Mock; + cancel: jest.Mock; + log: { + info: jest.Mock; + success: jest.Mock; + warn: jest.Mock; + }; +}; + +let clackMock: ClackMock; + +jest.mock('@clack/prompts', () => { + clackMock = { + log: { + info: jest.fn(), + success: jest.fn(), + warn: jest.fn(), + }, + text: jest.fn(), + confirm: jest.fn(), + cancel: jest.fn(), + // passthrough for abortIfCancelled + isCancel: jest.fn().mockReturnValue(false), + }; + return clackMock; +}); + +function mockUserResponse(fn: jest.Mock, response: any) { + fn.mockReturnValueOnce(response); +} + +describe('askForToolConfigPath', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns undefined if users have no config file', async () => { + mockUserResponse(clackMock.confirm, false); + + const result = await askForToolConfigPath('Webpack', 'webpack.config.js'); + + expect(clackMock.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('have a Webpack config file'), + }), + ); + + expect(result).toBeUndefined(); + }); + + it('returns the path if users have a config file and the entered path is valid', async () => { + mockUserResponse(clackMock.confirm, true); + mockUserResponse(clackMock.text, 'my.webpack.config.js'); + + const result = await askForToolConfigPath('Webpack', 'webpack.config.js'); + + expect(clackMock.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('have a Webpack config file'), + }), + ); + + expect(clackMock.text).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + 'enter the path to your Webpack config file', + ), + }), + ); + + expect(result).toBe('my.webpack.config.js'); + }); +}); + +describe('createNewConfigFile', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('writes the file to disk and returns true if the file was created successfully', async () => { + const writeFileSpy = jest + .spyOn(fs.promises, 'writeFile') + .mockImplementation(jest.fn()); + + const filename = '/weboack.config.js'; + const code = `module.exports = {/*config...*/}`; + + const result = await createNewConfigFile(filename, code); + + expect(result).toBe(true); + expect(writeFileSpy).toHaveBeenCalledWith(filename, code); + }); + + it('logs more information if provided as an argument', async () => { + jest.spyOn(fs.promises, 'writeFile').mockImplementation(jest.fn()); + + const filename = '/weboack.config.js'; + const code = `module.exports = {/*config...*/}`; + const moreInfo = 'More information...'; + + await createNewConfigFile(filename, code, moreInfo); + + expect(clackMock.log.info).toHaveBeenCalledTimes(1); + expect(clackMock.log.info).toHaveBeenCalledWith( + expect.stringContaining(moreInfo), + ); + }); + + it('returns false and logs a warning if the file could not be created', async () => { + const writeFileSpy = jest + .spyOn(fs.promises, 'writeFile') + .mockImplementation(() => Promise.reject(new Error('Could not write'))); + + const filename = '/webpack.config.js'; + const code = `module.exports = {/*config...*/}`; + + const result = await createNewConfigFile(filename, code); + + expect(result).toBe(false); + expect(writeFileSpy).toHaveBeenCalledWith(filename, code); + expect(clackMock.log.warn).toHaveBeenCalledTimes(1); + }); + + it('returns false if the passed path is not absolute', async () => { + const result = await createNewConfigFile( + './relative/webpack.config.js', + '', + ); + + expect(result).toBe(false); + }); +});