From efd778466c3447e4a09fa483d3005b57ece411ac Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 6 Sep 2023 12:24:02 +0200 Subject: [PATCH 1/4] feat(sourcemaps): Automatically insert Sentry Webpack plugin --- src/sourcemaps/tools/vite.ts | 74 +----- src/sourcemaps/tools/webpack.ts | 368 +++++++++++++++++++++++--- src/utils/ast-utils.ts | 29 +- src/utils/clack-utils.ts | 139 ++++++++++ test/sourcemaps/tools/webpack.test.ts | 303 +++++++++++++++++++++ test/utils/ast-utils.test.ts | 39 ++- test/utils/clack-utils.test.ts | 133 ++++++++++ 7 files changed, 987 insertions(+), 98 deletions(-) create mode 100644 test/sourcemaps/tools/webpack.test.ts create mode 100644 test/utils/clack-utils.test.ts diff --git a/src/sourcemaps/tools/vite.ts b/src/sourcemaps/tools/vite.ts index c85b7247..5e178dd0 100644 --- a/src/sourcemaps/tools/vite.ts +++ b/src/sourcemaps/tools/vite.ts @@ -15,6 +15,8 @@ import chalk from 'chalk'; import { abortIfCancelled, addDotEnvSentryBuildPluginFile, + askForToolConfigPath, + createNewConfigFile, getPackageDotJson, installPackage, } from '../../utils/clack-utils'; @@ -91,14 +93,18 @@ 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( + 'vite.config.js', + getViteConfigSnippet(options, false), + 'More information about vite configs: https://vitejs.dev/config/', + ); } if (successfullyAdded) { @@ -114,35 +120,6 @@ export const configureVitePlugin: SourceMapUploadToolConfigurationFunction = 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, @@ -236,39 +213,6 @@ async function showCopyPasteInstructions( ); } -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..a2be33fe 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,56 @@ import { SourceMapUploadToolConfigurationOptions, } from './types'; -const getCodeSnippet = (options: SourceMapUploadToolConfigurationOptions) => - chalk.gray(` -${chalk.greenBright( - 'const { sentryWebpackPlugin } = require("@sentry/webpack-plugin");', -)} +import { findFile, hasSentryContentCjs } from '../../utils/ast-utils'; -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 +88,288 @@ 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')); - // 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 successfullyAdded = webpackConfigPath + ? await modifyWebpackConfig(webpackConfigPath, options) + : await createNewConfigFile( + path.join(process.cwd(), 'webpack.config.js'), + getCodeSnippet(options, false), + 'More information about Webpack configs: https://vitejs.dev/config/', + ); - await abortIfCancelled( - select({ - message: 'Did you copy the snippet above?', - options: [{ label: 'Yes, continue!', value: true }], + if (successfullyAdded) { + 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 { + 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 (hasSentryContentCjs(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; + } + } + + 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. + clack.log.warn( + `Could not find module.exports = { /* config */ } in ${prettyConfigFilename}. +This is fine, please follow the instructions below.`, + ); + return false; + } + + const configObject = getWebpackConfigObject(exportStmt, program); + + if (!configObject) { + clack.log.warn( + `Couldn't find config object in ${prettyConfigFilename}, please follow the instructions below.`, + ); + return false; + } + + const enabledSourcemaps = enableSourcemapsGeneration(configObject); + + 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.`, + ); + 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.`, + ); + return false; + } + + const code = recast.print(program).code; + await fs.promises.writeFile(webpackConfigPath, code); + + 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/utils/ast-utils.ts b/src/utils/ast-utils.ts index 4379e28b..b46e2aeb 100644 --- a/src/utils/ast-utils.ts +++ b/src/utils/ast-utils.ts @@ -2,13 +2,17 @@ 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 * and returns the full path to the file with the correct file type. */ export function findFile( filePath: string, - fileTypes: string[] = ['.js', '.ts', '.mjs'], + fileTypes: string[] = ['.js', '.ts', '.mjs', '.cjs'], ): string | undefined { return fileTypes .map((type) => `${filePath}${type}`) @@ -20,3 +24,26 @@ 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 hasSentryContentCjs(program: t.Program): boolean { + let foundRequire = false; + recast.visit(program, { + visitCallExpression(path) { + const callee = path.node.callee; + if ( + callee.type === 'Identifier' && + callee.name === 'require' && + path.node.arguments[0].type === 'Literal' && + path.node.arguments[0].value?.toString().startsWith('@sentry/') + ) { + foundRequire = true; + } + this.traverse(path); + }, + }); + + return !!foundRequire; +} diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts index dcbada0e..0c2f9654 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,141 @@ 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 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 { + const prettyFilename = chalk.cyan(path.basename(filepath)); + try { + await fs.promises.writeFile(filepath, codeSnippet); + + Sentry.setTag('created-new-config', 'success'); + + clack.log.success(`Added new ${prettyFilename} file.`); + + if (moreInformation) { + clack.log.info(chalk.gray(moreInformation)); + } + + return true; + } catch (e) { + debug(e); + Sentry.setTag('created-new-config', 'fail'); + + 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..a8aa7d3b 100644 --- a/test/utils/ast-utils.test.ts +++ b/test/utils/ast-utils.test.ts @@ -1,6 +1,11 @@ //@ts-ignore import { parseModule } from 'magicast'; -import { hasSentryContent } from '../../src/utils/ast-utils'; +import { + hasSentryContent, + hasSentryContentCjs, +} from '../../src/utils/ast-utils'; + +import * as recast from 'recast'; describe('AST utils', () => { describe('hasSentryContent', () => { @@ -35,10 +40,40 @@ describe('AST utils', () => { } `, ])( - "reutrns false for modules without a valid '@sentry/' import", + "returns false for modules without a valid '@sentry/' import", (code) => { expect(hasSentryContent(parseModule(code))).toBe(false); }, ); }); + + describe('hasSentryContentCjs', () => { + it("returns true if a require('@sentry/') call was found in the parsed module", () => { + const code = ` + const { sentryVitePlugin } = require("@sentry/vite-plugin"); + const somethingelse = require('gs'); + `; + + // 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(hasSentryContentCjs(program)).toBe(true); + }); + + it.each([ + `const whatever = require('something')`, + `// const {sentryWebpackPlugin} = require('@sentry/webpack-plugin')`, + `const {sAntryWebpackPlugin} = require('webpack-plugin-@sentry')`, + ])( + "returns false if the file doesn't contain any require('@sentry/') calls", + (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(hasSentryContentCjs(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..8c98f686 --- /dev/null +++ b/test/utils/clack-utils.test.ts @@ -0,0 +1,133 @@ +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 = 'weboack.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); + }); +}); From 6f4ad3b80614e72b60ac5ce080312545334a4793 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 7 Sep 2023 18:34:13 +0200 Subject: [PATCH 2/4] cleanup, changelog --- CHANGELOG.md | 4 + src/sourcemaps/tools/vite.ts | 24 +---- src/sourcemaps/tools/webpack.ts | 152 +++++++++++++++++--------------- 3 files changed, 87 insertions(+), 93 deletions(-) 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 5e178dd0..1ff101ea 100644 --- a/src/sourcemaps/tools/vite.ts +++ b/src/sourcemaps/tools/vite.ts @@ -19,6 +19,7 @@ import { createNewConfigFile, getPackageDotJson, installPackage, + showCopyPasteInstructions, } from '../../utils/clack-utils'; import { hasPackageInstalled } from '../../utils/package-json'; @@ -113,7 +114,7 @@ export const configureVitePlugin: SourceMapUploadToolConfigurationFunction = Sentry.setTag('ast-mod', 'fail'); await showCopyPasteInstructions( path.basename(viteConfigPath || 'vite.config.js'), - options, + getViteConfigSnippet(options, true), ); } @@ -192,27 +193,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, - }), - ); -} - 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 a2be33fe..a9a135cb 100644 --- a/src/sourcemaps/tools/webpack.ts +++ b/src/sourcemaps/tools/webpack.ts @@ -28,6 +28,7 @@ import { } from './types'; import { findFile, hasSentryContentCjs } from '../../utils/ast-utils'; +import { debug } from '../../utils/debug'; const getCodeSnippet = ( options: SourceMapUploadToolConfigurationOptions, @@ -121,88 +122,97 @@ export async function modifyWebpackConfig( webpackConfigPath: string, options: SourceMapUploadToolConfigurationOptions, ): Promise { - 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 (hasSentryContentCjs(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, - }), - ); + try { + const webpackConfig = await fs.promises.readFile(webpackConfigPath, { + encoding: 'utf-8', + }); - if (!shouldContinue) { - Sentry.setTag('ast-mod-fail-reason', 'has-sentry-content'); - return false; + 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 (hasSentryContentCjs(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, + }), + ); + + if (!shouldContinue) { + Sentry.setTag('ast-mod-fail-reason', 'has-sentry-content'); + 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. - clack.log.warn( - `Could not find module.exports = { /* config */ } in ${prettyConfigFilename}. + 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. + clack.log.warn( + `Could not find module.exports = { /* config */ } in ${prettyConfigFilename}. This is fine, please follow the instructions below.`, - ); - return false; - } + ); + return false; + } - const configObject = getWebpackConfigObject(exportStmt, program); + const configObject = getWebpackConfigObject(exportStmt, program); - if (!configObject) { - clack.log.warn( - `Couldn't find config object in ${prettyConfigFilename}, please follow the instructions below.`, - ); - return false; - } + if (!configObject) { + clack.log.warn( + `Couldn't find config object in ${prettyConfigFilename}, please follow the instructions below.`, + ); + Sentry.setTag('ast-mod-fail-reason', 'config-object-not-found'); + return false; + } - const enabledSourcemaps = enableSourcemapsGeneration(configObject); + const enabledSourcemaps = enableSourcemapsGeneration(configObject); - 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.`, - ); - return false; - } + 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.`, - ); - 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); + const code = recast.print(program).code; + await fs.promises.writeFile(webpackConfigPath, code); - return true; + return true; + } catch (e) { + Sentry.setTag('ast-mod-fail-reason', 'insertion-fail'); + debug(e); + return false; + } } function addSentryWebpackPlugin( From 15af7c0d58efd0b5458e68320019936ece3b90cd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 8 Sep 2023 12:40:40 +0200 Subject: [PATCH 3/4] apply feedback --- src/sourcemaps/tools/vite.ts | 14 ++++- src/sourcemaps/tools/webpack.ts | 95 ++++++++++++++++++++------------- src/sveltekit/sdk-setup.ts | 8 ++- src/utils/ast-utils.ts | 31 ++++------- src/utils/clack-utils.ts | 14 ++--- test/utils/ast-utils.test.ts | 60 ++++++++------------- test/utils/clack-utils.test.ts | 15 ++++-- 7 files changed, 128 insertions(+), 109 deletions(-) diff --git a/src/sourcemaps/tools/vite.ts b/src/sourcemaps/tools/vite.ts index 1ff101ea..bf1cfb49 100644 --- a/src/sourcemaps/tools/vite.ts +++ b/src/sourcemaps/tools/vite.ts @@ -102,13 +102,23 @@ export const configureVitePlugin: SourceMapUploadToolConfigurationFunction = successfullyAdded = await addVitePluginToConfig(viteConfigPath, options); } else { successfullyAdded = await createNewConfigFile( - 'vite.config.js', + 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'); @@ -134,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?`, diff --git a/src/sourcemaps/tools/webpack.ts b/src/sourcemaps/tools/webpack.ts index a9a135cb..28121ee2 100644 --- a/src/sourcemaps/tools/webpack.ts +++ b/src/sourcemaps/tools/webpack.ts @@ -27,7 +27,7 @@ import { SourceMapUploadToolConfigurationOptions, } from './types'; -import { findFile, hasSentryContentCjs } from '../../utils/ast-utils'; +import { findFile, hasSentryContent } from '../../utils/ast-utils'; import { debug } from '../../utils/debug'; const getCodeSnippet = ( @@ -93,15 +93,28 @@ export const configureWebPackPlugin: SourceMapUploadToolConfigurationFunction = findFile(path.resolve(process.cwd(), 'webpack.config')) ?? (await askForToolConfigPath('Webpack', 'webpack.config.js')); - const successfullyAdded = webpackConfigPath - ? await modifyWebpackConfig(webpackConfigPath, options) - : await createNewConfigFile( - path.join(process.cwd(), 'webpack.config.js'), - getCodeSnippet(options, false), - 'More information about Webpack configs: https://vitejs.dev/config/', - ); + 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'); @@ -133,46 +146,23 @@ export async function modifyWebpackConfig( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const program = recast.parse(webpackConfig.toString()).program as t.Program; - if (hasSentryContentCjs(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, - }), - ); - - if (!shouldContinue) { - Sentry.setTag('ast-mod-fail-reason', 'has-sentry-content'); - return false; - } + 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. - clack.log.warn( - `Could not find module.exports = { /* config */ } in ${prettyConfigFilename}. -This is fine, please follow the instructions below.`, - ); + 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) { - clack.log.warn( - `Couldn't find config object in ${prettyConfigFilename}, please follow the instructions below.`, - ); + debug(`Couldn't find config object in ${webpackConfigPath}`); Sentry.setTag('ast-mod-fail-reason', 'config-object-not-found'); return false; } @@ -215,6 +205,37 @@ This is fine, please follow the instructions below.`, } } +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, + }), + ); + + if (!shouldContinue) { + Sentry.setTag('ast-mod-fail-reason', 'has-sentry-content'); + return false; + } + } + + return true; +} + function addSentryWebpackPlugin( program: t.Program, configObj: t.ObjectExpression, 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 b46e2aeb..bb15f598 100644 --- a/src/utils/ast-utils.ts +++ b/src/utils/ast-utils.ts @@ -1,6 +1,4 @@ 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; @@ -19,31 +17,22 @@ export function findFile( .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 hasSentryContentCjs(program: t.Program): boolean { - let foundRequire = false; +export function hasSentryContent(program: t.Program): boolean { + let foundSentry: boolean | undefined = false; recast.visit(program, { - visitCallExpression(path) { - const callee = path.node.callee; - if ( - callee.type === 'Identifier' && - callee.name === 'require' && - path.node.arguments[0].type === 'Literal' && - path.node.arguments[0].value?.toString().startsWith('@sentry/') - ) { - foundRequire = true; - } + 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 !!foundRequire; + return !!foundSentry; } diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts index 0c2f9654..c78c7001 100644 --- a/src/utils/clack-utils.ts +++ b/src/utils/clack-utils.ts @@ -900,7 +900,7 @@ export async function showCopyPasteInstructions( * their tool but we can leave it up to them to figure out how to merge configs * here.) * - * @param filepath path to the new config file + * @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. @@ -912,12 +912,16 @@ export async function createNewConfigFile( codeSnippet: string, moreInformation?: string, ): Promise { - const prettyFilename = chalk.cyan(path.basename(filepath)); + 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); - Sentry.setTag('created-new-config', 'success'); - clack.log.success(`Added new ${prettyFilename} file.`); if (moreInformation) { @@ -927,8 +931,6 @@ export async function createNewConfigFile( return true; } catch (e) { debug(e); - Sentry.setTag('created-new-config', 'fail'); - clack.log.warn( `Could not create a new ${prettyFilename} file. Please create one manually and follow the instructions below.`, ); diff --git a/test/utils/ast-utils.test.ts b/test/utils/ast-utils.test.ts index a8aa7d3b..f704a23b 100644 --- a/test/utils/ast-utils.test.ts +++ b/test/utils/ast-utils.test.ts @@ -1,27 +1,37 @@ -//@ts-ignore -import { parseModule } from 'magicast'; -import { - hasSentryContent, - hasSentryContentCjs, -} from '../../src/utils/ast-utils'; +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 { @@ -39,32 +49,6 @@ describe('AST utils', () => { plugins: [thirdPartyVitePlugin()] } `, - ])( - "returns false for modules without a valid '@sentry/' import", - (code) => { - expect(hasSentryContent(parseModule(code))).toBe(false); - }, - ); - }); - - describe('hasSentryContentCjs', () => { - it("returns true if a require('@sentry/') call was found in the parsed module", () => { - const code = ` - const { sentryVitePlugin } = require("@sentry/vite-plugin"); - const somethingelse = require('gs'); - `; - - // 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(hasSentryContentCjs(program)).toBe(true); - }); - - it.each([ - `const whatever = require('something')`, - `// const {sentryWebpackPlugin} = require('@sentry/webpack-plugin')`, - `const {sAntryWebpackPlugin} = require('webpack-plugin-@sentry')`, ])( "returns false if the file doesn't contain any require('@sentry/') calls", (code) => { @@ -72,7 +56,7 @@ describe('AST utils', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const program = recast.parse(code) .program as recast.types.namedTypes.Program; - expect(hasSentryContentCjs(program)).toBe(false); + expect(hasSentryContent(program)).toBe(false); }, ); }); diff --git a/test/utils/clack-utils.test.ts b/test/utils/clack-utils.test.ts index 8c98f686..f3e5ea88 100644 --- a/test/utils/clack-utils.test.ts +++ b/test/utils/clack-utils.test.ts @@ -92,7 +92,7 @@ describe('createNewConfigFile', () => { .spyOn(fs.promises, 'writeFile') .mockImplementation(jest.fn()); - const filename = 'weboack.config.js'; + const filename = '/weboack.config.js'; const code = `module.exports = {/*config...*/}`; const result = await createNewConfigFile(filename, code); @@ -104,7 +104,7 @@ describe('createNewConfigFile', () => { it('logs more information if provided as an argument', async () => { jest.spyOn(fs.promises, 'writeFile').mockImplementation(jest.fn()); - const filename = 'weboack.config.js'; + const filename = '/weboack.config.js'; const code = `module.exports = {/*config...*/}`; const moreInfo = 'More information...'; @@ -121,7 +121,7 @@ describe('createNewConfigFile', () => { .spyOn(fs.promises, 'writeFile') .mockImplementation(() => Promise.reject(new Error('Could not write'))); - const filename = 'weboack.config.js'; + const filename = '/webpack.config.js'; const code = `module.exports = {/*config...*/}`; const result = await createNewConfigFile(filename, code); @@ -130,4 +130,13 @@ describe('createNewConfigFile', () => { expect(writeFileSpy).toHaveBeenCalledWith(filename, code); expect(clackMock.log.warn).toHaveBeenCalledTimes(1); }); + + it('returns fals if the passed path is not absolute', async () => { + const result = await createNewConfigFile( + './relative/webpack.config.js', + '', + ); + + expect(result).toBe(false); + }); }); From 2f4035c00208d3d5c03b4df0a9e2f1fc2066063a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 8 Sep 2023 12:54:15 +0200 Subject: [PATCH 4/4] Update test/utils/clack-utils.test.ts Co-authored-by: Luca Forstner --- test/utils/clack-utils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils/clack-utils.test.ts b/test/utils/clack-utils.test.ts index f3e5ea88..8c67133d 100644 --- a/test/utils/clack-utils.test.ts +++ b/test/utils/clack-utils.test.ts @@ -131,7 +131,7 @@ describe('createNewConfigFile', () => { expect(clackMock.log.warn).toHaveBeenCalledTimes(1); }); - it('returns fals if the passed path is not absolute', async () => { + it('returns false if the passed path is not absolute', async () => { const result = await createNewConfigFile( './relative/webpack.config.js', '',