diff --git a/README.md b/README.md index 9ecd7db..18f2375 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ $ npm install https://github.com/centralnicgroup-opensource/semantic-release-rep The following example uses this plugin to demonstrate using semantic-release in a Python package where `__VERSION__` is defined in the root `__init__.py` file. +### Configuration Example + ```json { "plugins": [ @@ -30,10 +32,10 @@ The following example uses this plugin to demonstrate using semantic-release in { "replacements": [ { - "files": ["foo/**.py"], + "files": ["foo/**/*.py"], "from": "__VERSION__ = \".*\"", "to": "__VERSION__ = \"${nextRelease.version}\"", - "ignore" ["foo/go.py"], + "ignore": ["foo/go.py"], "results": [ { "file": "foo/__init__.py", @@ -50,13 +52,71 @@ The following example uses this plugin to demonstrate using semantic-release in [ "@semantic-release/git", { - "assets": ["foo/*.py"] + "assets": ["foo/**/*.py"] } ] ] } ``` +### Real-world Examples + +#### JSON Version (like whmcs.json) +```json +{ + "replacements": [ + { + "files": ["./modules/addons/cnicdnsmanager/whmcs.json"], + "from": "\"version\": \"\\d+\\.\\d+\\.\\d+\"", + "to": "\"version\": \"${nextRelease.version}\"", + "countMatches": true + } + ] +} +``` + +#### Gradle Build File +```json +{ + "replacements": [ + { + "files": ["./build.gradle"], + "from": "version = '[^']+'", + "to": "version = '${nextRelease.version}'", + "countMatches": true + } + ] +} +``` + +#### Multiple Files with Results Validation +```json +{ + "replacements": [ + { + "files": ["./release.json", "./COPYRIGHTS"], + "from": "(19|20)\\d{2}[- /.](0[1-9]|1[012])[- /.](0[1-9]|[12]\\d|3[01])", + "to": "${(new Date()).toISOString().split('T')[0]}", + "countMatches": true, + "results": [ + { + "file": "./release.json", + "hasChanged": true, + "numMatches": 1, + "numReplacements": 1 + }, + { + "file": "./COPYRIGHTS", + "hasChanged": true, + "numMatches": 1, + "numReplacements": 1 + } + ] + } + ] +} +``` + ### Validation The presence of the `results` array will trigger validation that a replacement has been made. This is optional but recommended. @@ -74,6 +134,44 @@ This plugin will not commit changes unless you specify assets for the @semantic- ] ``` +## Troubleshooting + +### Common Issues + +#### Pattern Not Matching +**Problem:** "No files found matching pattern" + +**Solutions:** +- Verify glob pattern is correct (use `foo/**/*.py` not `foo/**.py`) +- Check file paths relative to project root +- Use `countMatches: true` to get detailed feedback +- Test your regex pattern at [regex101.com](https://regex101.com/) + +#### JSON Escaping Issues +**Problem:** Regex pattern not matching (common in `.releaserc.json`) + +**Remember:** In JSON, backslashes must be escaped with another backslash: +- ❌ Wrong: `"from": "\d+\.\d+"` +- ✅ Correct: `"from": "\\d+\\.\\d+"` + +**Rule:** Double every backslash in JSON strings + +#### Results Validation Failed +**Problem:** "Expected match not found" + +**Check:** +1. Does the file actually exist? +2. Is the glob pattern matching the right file? +3. Do the `numMatches` and `numReplacements` match actual replacements? +4. Is `countMatches: true` enabled? + +### Debugging Tips + +1. **Enable verbose logging**: The plugin logs all matched files and replacements +2. **Start simple**: Test with a single replacement first +3. **Use `results` validation**: It forces you to be explicit about expectations +4. **Test regex patterns separately**: Use online tools before adding to config + ## Options Please refer to the [documentation](./docs/README.md) for more options. diff --git a/dist/index.d.ts b/dist/index.d.ts deleted file mode 100644 index 56e9c4d..0000000 --- a/dist/index.d.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { VerifyReleaseContext as Context } from "semantic-release"; -export type From = FromCallback | RegExp | string; -export type FromCallback = (filename: string, ...args: unknown[]) => RegExp | string; -export type To = string | ToCallback; -export type ToCallback = (match: string, ...args: unknown[]) => string; -/** - * Replacement is similar to the interface used by https://www.npmjs.com/package/replace-in-file - * with the difference being the single string for `to` and `from`. - */ -export interface Replacement { - /** - * files to search for replacements - */ - files: string[]; - /** - * The RegExp pattern to use to match. - * - * Uses `String.replace(new RegExp(s, 'gm'), to)` for implementation, if - * `from` is a string. - * - * For advanced matching, i.e. when using a `release.config.js` file, consult - * the documentation of the `replace-in-file` package - * (https://github.com/adamreisnz/replace-in-file/blob/main/README.md) on its - * `from` option. This allows explicit specification of `RegExp`s, callback - * functions, etc. - * - * Multiple matchers may be provided as an array, following the same - * conversion rules as mentioned above. - */ - from: From | From[]; - /** - * The replacement value using a template of variables. - * - * `__VERSION__ = "${context.nextRelease.version}"` - * - * The context object is used to render the template. Additional values - * can be found at: https://semantic-release.gitbook.io/semantic-release/developer-guide/js-api#result - * - * For advanced replacement (NOTE: only for use with `release.config.js` file version), pass in a function to replace non-standard variables - * ``` - * { - * from: `__VERSION__ = 11`, // eslint-disable-line - * to: (matched) => `__VERSION: ${parseInt(matched.split('=')[1].trim()) + 1}`, // eslint-disable-line - * }, - * ``` - * - * The `args` for a callback function can take a variety of shapes. In its - * simplest form, e.g. if `from` is a string, it's the filename in which the - * replacement is done. If `from` is a regular expression the `args` of the - * callback include captures, the offset of the matched string, the matched - * string, etc. See the `String.replace` documentation for details - * - * Multiple replacements may be specified as an array. These can be either - * strings or callback functions. Note that the amount of replacements needs - * to match the amount of `from` matchers. - */ - to: To | To[]; - ignore?: string[]; - allowEmptyPaths?: boolean; - countMatches?: boolean; - disableGlobs?: boolean; - encoding?: string; - dry?: boolean; - /** - * The results array can be passed to ensure that the expected replacements - * have been made, and if not, throw and exception with the diff. - */ - results?: { - file: string; - hasChanged: boolean; - numMatches?: number; - numReplacements?: number; - }[]; -} -/** - * PluginConfig is used to provide multiple replacement. - * - * ``` - * [ - * "@google/semantic-release-replace-plugin", - * { - * "replacements": [ - * { - * "files": ["foo/__init__.py"], - * "from": "__VERSION__ = \".*\"", - * "to": "__VERSION__ = \"${context.nextRelease.version}\"", - * "results": [ - * { - * "file": "foo/__init__.py", - * "hasChanged": true, - * "numMatches": 1, - * "numReplacements": 1 - * } - * ], - * "countMatches": true - * } - * ] - * } - * ] - * ``` - */ -export interface PluginConfig { - /** An array of replacements to be made. */ - replacements: Replacement[]; -} -export declare function prepare(PluginConfig: PluginConfig, context: Context): Promise; diff --git a/dist/index.js b/dist/index.js deleted file mode 100644 index 0154642..0000000 --- a/dist/index.js +++ /dev/null @@ -1,211 +0,0 @@ -import { replaceInFile } from "replace-in-file"; -/** - * Wraps the `callback` in a new function that passes the `context` as the - * final argument to the `callback` when it gets called. - */ -function applyContextToCallback(callback, context) { - return (...args) => callback.apply(null, args.concat(context)); -} -/** - * Applies the `context` to the replacement property `to` depending on whether - * it is a string template or a callback function. - */ -function applyContextToReplacement(to, context) { - return typeof to === "function" - ? applyContextToCallback(to, context) - : new Function(...Object.keys(context), `return \`${to}\`;`)(...Object.values(context)); -} -/** - * Normalizes a `value` into an array, making it more straightforward to apply - * logic to a single value of type `T` or an array of those values. - */ -function normalizeToArray(value) { - return value instanceof Array ? value : [value]; -} -/** - * Compares two values for deep equality. - * - * This function handles complex data types such as `RegExp`, `Date`, `Map`, `Set`, - * and performs deep comparison of nested objects and arrays. - * - * @param {any} a - The first value to compare. - * @param {any} b - The second value to compare. - * @returns {boolean} `true` if the values are deeply equal, `false` otherwise. - * - * @example - * const obj1 = { regex: /abc/g, date: new Date(), set: new Set([1, 2, 3]) }; - * const obj2 = { regex: /abc/g, date: new Date(), set: new Set([1, 2, 3]) }; - * - * console.log(deepEqual(obj1, obj2)); // true - * - * @example - * const obj1 = { regex: /abc/g, date: new Date(2022, 0, 1) }; - * const obj2 = { regex: /abc/g, date: new Date(2021, 0, 1) }; - * - * console.log(deepEqual(obj1, obj2)); // false - */ -function deepEqual(a, b) { - if (a === b) - return true; // Handle primitives - // Check for null or undefined - if (a == null || b == null) - return false; - // Handle RegExp - if (a instanceof RegExp && b instanceof RegExp) { - return a.source === b.source && a.flags === b.flags; - } - // Handle Date - if (a instanceof Date && b instanceof Date) { - return a.getTime() === b.getTime(); - } - // Handle Map and Set - if (a instanceof Map && b instanceof Map) { - if (a.size !== b.size) - return false; - for (let [key, value] of a) { - if (!b.has(key) || !deepEqual(value, b.get(key))) - return false; - } - return true; - } - if (a instanceof Set && b instanceof Set) { - if (a.size !== b.size) - return false; - for (let item of a) { - if (!b.has(item)) - return false; - } - return true; - } - // Handle objects and arrays - if (typeof a === "object" && typeof b === "object") { - const keysA = Object.keys(a); - const keysB = Object.keys(b); - if (keysA.length !== keysB.length) - return false; - for (let key of keysA) { - if (!keysB.includes(key) || !deepEqual(a[key], b[key])) { - return false; - } - } - return true; - } - // If none of the checks match, return false - return false; -} -/** - * Recursively compares two objects and returns an array of differences. - * - * The function traverses the two objects (or arrays) and identifies differences - * in their properties or elements. It supports complex types like `Date`, `RegExp`, - * `Map`, `Set`, and checks nested objects and arrays. - * - * @param {any} obj1 - The first value to compare. - * @param {any} obj2 - The second value to compare. - * @param {string} [path=""] - The current path to the property or element being compared (used for recursion). - * @returns {string[]} An array of strings representing the differences between the two values. - * - * @example - * const obj1 = { a: 1, b: { c: 2 } }; - * const obj2 = { a: 1, b: { c: 3 } }; - * - * const differences = deepDiff(obj1, obj2); - * console.log(differences); // ['Difference at b.c: 2 !== 3'] - * - * @example - * const set1 = new Set([1, 2, 3]); - * const set2 = new Set([1, 2, 4]); - * - * const differences = deepDiff(set1, set2); - * console.log(differences); // ['Difference at : Set { 1, 2, 3 } !== Set { 1, 2, 4 }'] - */ -function deepDiff(obj1, obj2, path = "") { - let differences = []; - if (typeof obj1 !== "object" || - typeof obj2 !== "object" || - obj1 === null || - obj2 === null) { - if (obj1 !== obj2) { - differences.push(`Difference at ${path}: ${obj1} !== ${obj2}`); - } - return differences; - } - // Check for Map or Set - if (obj1 instanceof Map && obj2 instanceof Map) { - if (obj1.size !== obj2.size) { - differences.push(`Difference at ${path}: Map sizes do not match`); - } - for (let [key, value] of obj1) { - if (!obj2.has(key) || !deepEqual(value, obj2.get(key))) { - differences.push(`Difference at ${path}.${key}: ${value} !== ${obj2.get(key)}`); - } - } - return differences; - } - if (obj1 instanceof Set && obj2 instanceof Set) { - if (obj1.size !== obj2.size) { - differences.push(`Difference at ${path}: Set sizes do not match`); - } - for (let item of obj1) { - if (!obj2.has(item)) { - differences.push(`Difference at ${path}: Set items do not match`); - } - } - return differences; - } - // Handle RegExp - if (obj1 instanceof RegExp && obj2 instanceof RegExp) { - if (obj1.source !== obj2.source || obj1.flags !== obj2.flags) { - differences.push(`Difference at ${path}: RegExp ${obj1} !== ${obj2}`); - } - return differences; - } - // Handle Date - if (obj1 instanceof Date && obj2 instanceof Date) { - if (obj1.getTime() !== obj2.getTime()) { - differences.push(`Difference at ${path}: Date ${obj1} !== ${obj2}`); - } - return differences; - } - // Handle objects and arrays - const keys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]); - for (const key of keys) { - const newPath = path ? `${path}.${key}` : key; - differences = differences.concat(deepDiff(obj1[key], obj2[key], newPath)); - } - return differences; -} -export async function prepare(PluginConfig, context) { - for (const replacement of PluginConfig.replacements) { - let { results } = replacement; - delete replacement.results; - const replaceInFileConfig = { - ...replacement, - from: replacement.from ?? [], - to: replacement.to ?? [], - }; - replaceInFileConfig.from = normalizeToArray(replacement.from).map((from) => { - switch (typeof from) { - case "function": - return applyContextToCallback(from, context); - case "string": - return new RegExp(from, "gm"); - default: - return from; - } - }); - replaceInFileConfig.to = - replacement.to instanceof Array - ? replacement.to.map((to) => applyContextToReplacement(to, context)) - : applyContextToReplacement(replacement.to, context); - let actual = await replaceInFile(replaceInFileConfig); - if (results) { - results = results.sort(); - actual = actual.sort(); - if (!deepEqual([...actual].sort(), [...results].sort())) { - const difference = deepDiff(actual, results); - throw new Error(`Expected match not found!\n${difference.join("\n")}`); - } - } - } -} diff --git a/dist/index.test.d.ts b/dist/index.test.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/index.test.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/index.test.js b/dist/index.test.js deleted file mode 100644 index 547bae3..0000000 --- a/dist/index.test.js +++ /dev/null @@ -1,332 +0,0 @@ -import * as m from "./index.js"; -import fs from "fs-extra"; -import path from "path"; -import tmp from "tmp"; -import { test, expect, beforeEach, afterEach } from "vitest"; -import { prepare } from "./index.js"; -const context = { - stdout: process.stdout, - stderr: process.stderr, - logger: {}, // You might need to provide appropriate options to Signale constructor - cwd: "/path/to/your/repository", - env: process.env, - envCi: { - isCi: true, - commit: "abcdef123456", - branch: "main", - }, - branch: { - name: "main", - channel: false, - range: undefined, - }, - branches: [ - { - name: "main", - channel: false, - range: undefined, - }, - ], - commits: [ - { - commit: { - long: "abcdef1234567890", - short: "abcdef12", - }, - tree: { - long: "1234567890abcdef", - short: "12345678", - }, - author: { - name: "John Doe", - email: "john.doe@example.com", - short: "2024-03-26", - }, - committer: { - name: "Jane Doe", - email: "jane.doe@example.com", - short: "2024-03-26", - }, - subject: "Example commit", - body: "This is an example commit.", - message: "Example commit: This is an example commit.", - hash: "abcdef1234567890", - committerDate: "2024-03-26", - }, - ], - releases: [ - { - name: "patch", - url: "https://example.com/release", - type: "patch", - version: "1.0.0", - gitHead: "abcdef123456", - gitTag: "v1.0.0", - notes: "These are the release notes for the dummy release.", - pluginName: "Dummy Release Plugin", - }, - ], - lastRelease: { - version: "1.0.0", - gitTag: "v1.0.0", - channels: [], - gitHead: "abcdef123456", - name: "patch", - }, - nextRelease: { - name: "abc", - type: "patch", - version: "2.0.0", - gitHead: "abcdef123456", - gitTag: "v2.0.0", - channel: "main", - notes: "These are the release notes for the next release.", - }, - options: { - ci: false, - }, -}; -let d; -beforeEach(() => { - d = tmp.dirSync({ unsafeCleanup: true }); - fs.copySync("fixtures", d.name); -}); -afterEach(() => { - d.removeCallback(); -}); -async function assertFileContents(name, expected) { - const actual = await fs.readFileSync(path.join(d.name, name), "utf-8"); - expect(actual).toEqual(expected); -} -async function assertFileContentsContain(name, expected) { - const filePath = path.join(d.name, name); - const actual = await fs.readFileSync(filePath, "utf-8"); - if (!actual.includes(expected)) { - throw new Error(`File ${filePath} does not contain "${expected}"`); - } -} -test("should expose prepare", async () => { - expect(m.prepare).toBeDefined(); -}); -test("prepare should replace using regex", async () => { - const replacements = [ - { - files: [path.join(d.name, "/*.py")], - from: '__VERSION__ = ".*"', - to: '__VERSION__ = "${nextRelease.version}"', - }, - { - files: [path.join(d.name, "/build.gradle")], - from: "version = '.*'", - to: "version = '${nextRelease.version}'", - }, - ]; - await prepare({ replacements }, context); - await assertFileContentsContain("__init__.py", `__VERSION__ = "${context.nextRelease?.version}"`); - await assertFileContents("build.gradle", `version = '${context.nextRelease?.version}'`); -}); -test("prepare should use result check", async () => { - const replacements = [ - { - files: [path.join(d.name, "/*.py")], - from: '__VERSION__ = "1.0.0"', - to: '__VERSION__ = "${nextRelease.version}"', - results: [ - { - file: path.join(d.name, "/__init__.py"), - hasChanged: true, - numMatches: 1, - numReplacements: 1, - }, - ], - countMatches: true, - }, - ]; - await prepare({ replacements }, context); - await assertFileContentsContain("__init__.py", `__VERSION__ = "${context.nextRelease?.version}"`); -}); -test("prepare should throw error if result mismatch", async () => { - const replacements = [ - { - files: [path.join(d.name, "/*")], - from: '__VERSION__ = "1.0.0"', - to: '__VERSION__ = "${nextRelease.version}"', - results: [], - countMatches: true, - }, - ]; - await expect(prepare({ replacements }, context)).rejects.toThrow(); -}); -test("prepare should use result check", async () => { - const replacements = [ - { - files: [path.join(d.name, "/*.py")], - from: '__VERSION__ = "1.0.0"', - to: '__VERSION__ = "${nextRelease.version}"', - results: [ - { - file: path.join(d.name, "/__init__.py"), - hasChanged: true, - numMatches: 1, - numReplacements: 1, - }, - ], - countMatches: true, - }, - ]; - await prepare({ replacements }, context); - await assertFileContentsContain("__init__.py", `__VERSION__ = "${context.nextRelease?.version}"`); -}); -test("replacements are global", async () => { - const replacements = [ - { - files: [path.join(d.name, "/*.md")], - from: "foo@.*", - to: 'foo@"${nextRelease.version}"', - results: [ - { - file: path.join(d.name, "/foo.md"), - hasChanged: true, - numMatches: 2, - numReplacements: 2, - }, - ], - countMatches: true, - }, - ]; - await prepare({ replacements }, context); - // Will throw if results do not match -}); -test("prepare should replace using function", async () => { - const replacements = [ - { - files: [path.join(d.name, "/*.py")], - from: '__VERSION__ = ".*"', - to: () => `__VERSION__ = 2`, - }, - { - files: [path.join(d.name, "/build.gradle")], - from: "version = '.*'", - to: () => "version = 2", - }, - ]; - await prepare({ replacements }, context); - await assertFileContentsContain("__init__.py", `__VERSION__ = 2`); - await assertFileContents("build.gradle", "version = 2"); -}); -test("prepare accepts regular expressions for `from`", async () => { - const replacements = [ - { - files: [path.join(d.name, "/foo.md")], - from: /yarn(.+?)@.*/g, - to: `yarn add foo@${context.nextRelease?.version}`, - }, - ]; - await prepare({ replacements }, context); - await assertFileContentsContain("foo.md", "npm i foo@1.0.0"); - await assertFileContentsContain("foo.md", "yarn add foo@2.0.0"); -}); -test("prepare accepts callback functions for `from`", async () => { - const replacements = [ - { - files: [path.join(d.name, "/foo.md")], - from: (filename) => `${path.basename(filename, ".md")}@1.0.0`, // Equivalent to "foo@1.0.0" - to: `foo@${context.nextRelease?.version}`, - }, - ]; - await prepare({ replacements }, context); - // As `from` ended up being a string after executing the function, only the - // first occurrence of `foo@1.0.0` in the file should have been replaced. - // Note that this is different behavior from the case where a string is - // passed directly to `from` (which the plugin implicitly turns into a global - // regular expression) - await assertFileContentsContain("foo.md", "npm i foo@2.0.0"); - await assertFileContentsContain("foo.md", "yarn add foo@1.0.0"); -}); -test("prepare accepts multi-argument `to` callback functions for regular expression `from`", async () => { - const replacements = [ - { - files: [path.join(d.name, "/foo.md")], - from: /npm i (.+)@(.+)`/g, - to: ((match, packageName, version) => { - return match - .replace(version, context.nextRelease?.version ?? version) - .replace(packageName, packageName.split("").reverse().join("")); - }), - }, - ]; - await prepare({ replacements }, context); - await assertFileContentsContain("foo.md", "npm i oof@2.0.0"); - await assertFileContentsContain("foo.md", "yarn add foo@1.0.0"); -}); -test("prepare passes the `context` as the final function argument to `from` callbacks", async () => { - const replacements = [ - { - files: [path.join(d.name, "/foo.md")], - // Returns a regular expression matching the previous version, so that - // _all_ occurrences in the document are updated - from: ((_, context) => new RegExp(context?.lastRelease?.version || "", "g")), - to: "3.0.0", - }, - ]; - await prepare({ replacements }, context); - await assertFileContentsContain("foo.md", "npm i foo@3.0.0"); - await assertFileContentsContain("foo.md", "yarn add foo@3.0.0"); -}); -test("prepare passes the `context` as the final function argument to `to` callbacks", async () => { - const replacements = [ - { - files: [path.join(d.name, "/foo.md")], - from: /npm i (.*)@(.*)`/, - to: ((_, package_name, ...args) => { - const reversed_package_name = package_name.split("").reverse().join(""); - const context = args.pop(); - return `npm i ${reversed_package_name}@${context?.nextRelease?.version}`; - }), - }, - ]; - await prepare({ replacements }, context); - await assertFileContentsContain("foo.md", `npm i oof@${context.nextRelease?.version}`); - await assertFileContentsContain("foo.md", "yarn add foo@1.0.0"); -}); -test("prepare accepts an array of `from` matchers", async () => { - const replacements = [ - { - files: [path.join(d.name, "/foo.md")], - // Similarly to single string values, strings in arrays should be taken - // to mean global replacements for improved JSON configuration - // capabilities. The regular expression and function matchers should only - // replace a single occurrence and hence only affect the `npm` line - from: [ - "1.0.0", - /install with/, - (filename) => path.basename(filename, ".md"), - ], - to: "bar", - }, - ]; - await prepare({ replacements }, context); - await assertFileContentsContain("foo.md", "bar `npm i bar@bar`"); - await assertFileContentsContain("foo.md", "install with `yarn add foo@bar`"); -}); -test("prepare accepts an array of `to` replacements", async () => { - // This replaces `npm i` with `npm install` and all occurrences of `1.0.0` - // with the `version` of the `nextRelease` as string `from` matchers are - // turned into global regular expressions - const replacements = [ - { - files: [path.join(d.name, "/foo.md")], - from: ["npm i", "1.0.0"], - to: [ - "npm install", - (...args) => { - const context = args.pop(); - return context?.nextRelease?.version || ""; - }, - ], - }, - ]; - await prepare({ replacements }, context); - await assertFileContentsContain("foo.md", `npm install foo@${context.nextRelease?.version}`); - await assertFileContentsContain("foo.md", `yarn add foo@${context.nextRelease?.version}`); -}); diff --git a/fixtures/modules/addons/cnicdnsmanager/whmcs.json b/fixtures/modules/addons/cnicdnsmanager/whmcs.json new file mode 100644 index 0000000..9bcc248 --- /dev/null +++ b/fixtures/modules/addons/cnicdnsmanager/whmcs.json @@ -0,0 +1,24 @@ +{ + "schema": "1.0", + "type": "whmcs-addons", + "name": "dummy text-addon", + "license": "MIT", + "category": "digitalservices", + "description": { + "name": "dummy text", + "tagline": "dummy text", + "long": "dummy text", + "features": [ + "dummy text" + ] + }, + "authors": [ + { + "name": "dummy text", + "homepage": "https://www.dummytext.com/" + } + ], + "_module": { + "version": "27.15.2" + } +} \ No newline at end of file diff --git a/package.json b/package.json index 2e2f012..d1c6967 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "lint": "prettier --write . --ignore-path .prettierignore && eslint", "format": "eslint --fix", "test": "tsc --noEmit && vitest --coverage", + "test:watch": "vitest --watch", + "test:coverage": "vitest --coverage --run", + "ci": "pnpm run build && pnpm run test && pnpm run lint", + "prepublishOnly": "pnpm run build && pnpm run test", "release": "semantic-release" }, "dependencies": { diff --git a/src/index.test.ts b/src/index.test.ts index c228d31..d97dc0b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -412,3 +412,30 @@ test("prepare accepts an array of `to` replacements", async () => { `yarn add foo@${context.nextRelease?.version}`, ); }); + +test("should update whmcs.json version using regex with countMatches and validate fixture integrity", async () => { + const replacements = [ + { + files: [path.join(d.name, "/modules/addons/cnicdnsmanager/whmcs.json")], + from: '"version": "\\d+\\.\\d+\\.\\d+"', + to: `"version": "${context.nextRelease?.version}"`, + countMatches: true, + results: [ + { + file: path.join(d.name, "/modules/addons/cnicdnsmanager/whmcs.json"), + hasChanged: true, + numMatches: 1, + numReplacements: 1, + }, + ], + }, + ]; + + await prepare({ replacements }, context); + + // Verify temp file was updated correctly + await assertFileContentsContain( + "modules/addons/cnicdnsmanager/whmcs.json", + `"version": "${context.nextRelease?.version}"`, + ); +}); diff --git a/src/index.ts b/src/index.ts index 99c3053..3094515 100644 --- a/src/index.ts +++ b/src/index.ts @@ -320,6 +320,11 @@ export async function prepare( delete replacement.results; + // Log file patterns being searched + context.logger?.log?.( + `🔍 Searching for files matching: ${JSON.stringify(replacement.files)}`, + ); + const replaceInFileConfig: ReplaceInFileConfig & { from: From | From[]; to: To | To[]; @@ -355,13 +360,43 @@ export async function prepare( }, ); + // Log results of replacement + if (actual && actual.length > 0) { + context.logger?.log?.( + `✅ Files processed: ${actual.length} file(s) matched and updated`, + ); + actual.forEach((file) => { + context.logger?.log?.( + ` 📄 ${file.file}: ${file.numReplacements ?? 0} replacement(s) made (${file.numMatches ?? 0} match(es))`, + ); + }); + } else { + context.logger?.warn?.( + `⚠️ No files found matching pattern: ${JSON.stringify(replacement.files)}`, + ); + } + if (results) { results = results.sort(); actual = actual.sort(); if (!deepEqual([...actual].sort(), [...results].sort())) { const difference = deepDiff(actual, results); - throw new Error(`Expected match not found!\n${difference.join("\n")}`); + const errorMsg = [ + "❌ Replacement validation failed!", + "", + "Expected results did not match actual results.", + "", + "Possible causes:", + " • File glob pattern didn't match expected files", + " • Regex pattern didn't find expected matches", + " • Check for proper escaping in JSON (use \\\\ for backslash)", + " • Verify numMatches and numReplacements expectations", + "", + "Details:", + ...difference.map(d => ` ${d}`), + ].join("\n"); + throw new Error(errorMsg); } } }