diff --git a/README.md b/README.md index da3383e..0b8407e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ available: | Option | Description | Type | Default | Required | | ------------------- | ------------------------------------------------------------------------------------------------------ | -------- | ---------- | ----------------------------------- | -| `--mode` | The output mode ('json' or 'github_action'). | string | `json` | No | +| `--mode` | The output mode ('json', 'markdown', or 'github_action'). | string | `json` | No | | `--diagramTool` | The diagram tool to use ('plantuml', 'graphviz', or 'mermaid'). | string | `graphviz` | No | | `--filePath` | Path(s) to the Salesforce Flow XML file(s). Specify multiple files using space separated values. | string[] | | No (Git diff or file path required) | | `--gitDiffFromHash` | The starting commit hash for the Git diff. | string | | No (Only with Git diff) | @@ -41,16 +41,6 @@ available: | `--outputDirectory` | The directory to save the output file. | string | | Yes (Only in json mode) | | `--outputFileName` | The name of the output file (without extension). | string | | Yes (Only in json mode) | -### Output Modes - -Flow Lens supports two output modes: - -1. **json mode (default):** Generates a JSON file containing the UML diagram(s) - that can be used for further processing. -2. **github_action mode:** Automatically posts comments with flow diagrams on - pull requests when used in a GitHub Actions workflow. When using this mode, - you must specify `mermaid` as the diagram tool. - **Example using file path (json mode):** ```shell @@ -83,10 +73,86 @@ deno run \ --outputFileName="test" ``` -### Setting up a GitHub Action +## Output + +Flow Lens supports three output modes: + +1. **json mode (default):** Generates a JSON file containing the UML diagram(s) + that can be used for further processing. +2. **markdown mode:** Generates individual `.md` files for each flow in the + specified output directory. Each markdown file contains Mermaid diagrams + wrapped in code blocks. +3. **github_action mode:** Automatically posts comments with flow diagrams on + pull requests when used in a GitHub Actions workflow. When using this mode, + you must specify `mermaid` as the diagram tool. + +### JSON Mode (default) + +When using the `json` mode, the output is a JSON file containing the generated +UML diagram(s). The structure will contain the file paths and their associated +old (if applicable) and new UML strings. + +```json +[ + { + "path": "force-app/main/default/flows/ArticleSubmissionStatus.flow-meta.xml", + "difference": { + "old": "UML_STRING_HERE", + "new": "UML_STRING_HERE" + } + }, + { + "path": "force-app/main/default/flows/LeadConversionScreen.flow-meta.xml", + "difference": { + "old": "UML_STRING_HERE", + "new": "UML_STRING_HERE" + } + } +] +``` + +### Markdown Mode + +When using the `markdown` mode, Flow Lens generates individual `.md` files for +each flow in the specified output directory. Each markdown file is named after +the flow's API name and contains the UML diagram wrapped in Mermaid code blocks. + +**File Structure:** + +``` +outputDirectory/ +├── FlowName1.md +├── FlowName2.md +└── FlowName3.md +``` + +**Markdown Content Format:** + +- If there's only a new version (no diff), the file contains a single Mermaid + diagram +- If there are both old and new versions (diff mode), the file contains: + - `## Old Version` section with the previous diagram + - `## New Version` section with the current diagram +- Each diagram is wrapped in triple backticks with the `mermaid` language + identifier + +**Note:** Markdown mode only works with the `mermaid` diagram tool and requires +an `outputDirectory` to be specified. + +### GitHub Action Mode + +When using the `github_action` mode, Flow Lens automatically posts flow diagrams +as comments on pull requests. This mode is designed for use in GitHub Actions +workflows and requires the `mermaid` diagram tool. + +**Requirements:** -You can set up a GitHub Action to automatically generate and post flow diagrams -as comments on pull requests. Here's an example workflow configuration: +- Must be run in a GitHub Actions workflow +- Requires `mermaid` as the diagram tool +- Needs appropriate GitHub permissions to post comments +- Requires `GITHUB_TOKEN` environment variable + +**Example GitHub Actions Workflow:** ```yaml name: Generate Flow Preview @@ -128,54 +194,13 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` -When using the GitHub Action mode, Flow Lens will automatically post a comment -on the pull request with the old (if applicable) and new versions of the flow -whenever a pull request is created or updated. This makes it easy to visualize -flow changes directly in the pull request review process. - -## Output - -When using the json mode, the output is a JSON file containing the generated UML -diagram(s). The structure will contain the file paths and their associated old -(if applicable) and new UML strings. - -```json -[ - { - "path": "force-app/main/default/flows/ArticleSubmissionStatus.flow-meta.xml", - "difference": { - "old": "UML_STRING_HERE", - "new": "UML_STRING_HERE" - } - }, - { - "path": "force-app/main/default/flows/LeadConversionScreen.flow-meta.xml", - "difference": { - "old": "UML_STRING_HERE", - "new": "UML_STRING_HERE" - } - } -] -``` - -## Frequently Asked Questions +**What Happens:** -### Why is this built using Deno? - -Porting the project from Google's internal Blaze build system to Deno was easier -than setting it up in Node.js, as there is no transpilation step from TypeScript -to JavaScript. Deno's built-in TypeScript support made the migration process -much smoother. - -### How is this different than Todd Halfpenny's flow visualizer? - -While -[Todd's project](https://github.com/toddhalfpenny/salesforce-flow-visualiser) is -excellent, Flow Lens was built and used internally at Google before Todd's -project was available for commercial use. The key differentiator is that Flow -Lens represents flow differences structurally, making it ideal for assistance -with code reviews. This structural diff visualization is not available in other -flow visualization tools. +- Flow Lens detects the pull request context automatically +- Generates Mermaid diagrams for changed flows +- Posts a comment with both old and new versions (if applicable) +- Updates existing comments when the PR is modified +- Requires no manual file management or output directory setup ## Example @@ -320,7 +345,10 @@ flow visualization tools. deno run \ --allow-read \ --allow-write \ + --allow-run \ + --allow-env \ jsr:@goog/flow-lens \ + --mode="markdown" \ --diagramTool="mermaid" \ --gitRepo="/path/to/salesforce_project/" \ --gitDiffFromHash="HEAD~1" \ @@ -329,30 +357,66 @@ deno run \ --outputFileName="test" ``` -`test.json` - -```json -[ - { - "path": "force-app/main/default/flows/Demo.flow-meta.xml", - "difference": { - "old": "---\ntitle: \"Demo\"\n---\nstateDiagram-v2\n\n classDef pink fill:#F9548A, color:white\n classDef orange fill:#DD7A00, color:white\n classDef navy fill:#344568, color:white\n classDef blue fill:#1B96FF, color:white\n classDef modified stroke-width: 5px, stroke: orange\n classDef added stroke-width: 5px, stroke: green\n classDef deleted stroke-width: 5px, stroke: red\n\n state \"-Assignment 📝
Set the Description
Get_the_Acme_Account.Description = This is a Demonstration!\" as Set_the_Description\n class Set_the_Description orange deleted\n state \"ΔRecord Lookup 🔍
Get the 'Acme' Account
sObject: Account
Fields Queried: all
Filter Logic: and
1. Name EqualTo Acme
Limit: First Record Only\" as Get_the_Acme_Account\n class Get_the_Acme_Account pink modified\n state \"ΔRecord Update ✏️
Update the 'Acme' Account
Reference Update
Get_the_Acme_Account
\" as Update_the_Acme_Account\n class Update_the_Acme_Account pink modified\n FLOW_START --> Get_the_Acme_Account\n Get_the_Acme_Account --> Set_the_Description\n Set_the_Description --> Update_the_Acme_Account", - "new": "---\ntitle: \"Demo\"\n---\nstateDiagram-v2\n\n classDef pink fill:#F9548A, color:white\n classDef orange fill:#DD7A00, color:white\n classDef navy fill:#344568, color:white\n classDef blue fill:#1B96FF, color:white\n classDef modified stroke-width: 5px, stroke: orange\n classDef added stroke-width: 5px, stroke: green\n classDef deleted stroke-width: 5px, stroke: red\n\n state \"+Assignment 📝
Set the Type
Get_the_Acme_Account.Type = Other\" as Set_the_Type\n class Set_the_Type orange added\n state \"ΔRecord Lookup 🔍
Get the 'Acme' Account
sObject: Account
Fields Queried: all
Filter Logic: and
1. Name EqualTo Acme
Limit: First Record Only\" as Get_the_Acme_Account\n class Get_the_Acme_Account pink modified\n state \"ΔRecord Update ✏️
Update the 'Acme' Account
Reference Update
Get_the_Acme_Account
\" as Update_the_Acme_Account\n class Update_the_Acme_Account pink modified\n state \"+Action Call
Log Error\" as Log_Error\n class Log_Error navy added\n state \"+Action Call
Log Error\" as Log_Error2\n class Log_Error2 navy added\n FLOW_START --> Get_the_Acme_Account\n Get_the_Acme_Account --> Set_the_Type\n Get_the_Acme_Account --> Log_Error : ❌ Fault ❌\n Set_the_Type --> Update_the_Acme_Account\n Update_the_Acme_Account --> Log_Error2 : ❌ Fault ❌" - } - } -] +# `./Demo.md` + +## Old Version + +```mermaid +--- +title: "Demo" +--- +stateDiagram-v2 + + classDef pink fill:#F9548A, color:white + classDef orange fill:#DD7A00, color:white + classDef navy fill:#344568, color:white + classDef blue fill:#1B96FF, color:white + classDef modified stroke-width: 5px, stroke: orange + classDef added stroke-width: 5px, stroke: green + classDef deleted stroke-width: 5px, stroke: red + + state "Flow Start
Flow Start
Flow Details
Process Type: AutoLaunchedFlow" as FLOW_START + state "-Assignment 📝
Set the Description
Get_the_Acme_Account.Description = This is a Demonstration!" as Set_the_Description + class Set_the_Description orange deleted + state "ΔRecord Lookup 🔍
Get the 'Acme' Account
sObject: Account
Fields Queried: all
Filter Logic: and
1. Name EqualTo Acme
Limit: First Record Only" as Get_the_Acme_Account + class Get_the_Acme_Account pink modified + state "ΔRecord Update ✏️
Update the 'Acme' Account
Reference Update
Get_the_Acme_Account
" as Update_the_Acme_Account + class Update_the_Acme_Account pink modified + FLOW_START --> Get_the_Acme_Account + Get_the_Acme_Account --> Set_the_Description + Set_the_Description --> Update_the_Acme_Account ``` - - - - - - - - -
Old New
- - - -
+## New Version + +```mermaid +--- +title: "Demo" +--- +stateDiagram-v2 + + classDef pink fill:#F9548A, color:white + classDef orange fill:#DD7A00, color:white + classDef navy fill:#344568, color:white + classDef blue fill:#1B96FF, color:white + classDef modified stroke-width: 5px, stroke: orange + classDef added stroke-width: 5px, stroke: green + classDef deleted stroke-width: 5px, stroke: red + + state "Flow Start
Flow Start
Flow Details
Process Type: AutoLaunchedFlow" as FLOW_START + state "+Assignment 📝
Set the Type
Get_the_Acme_Account.Type = Other" as Set_the_Type + class Set_the_Type orange added + state "ΔRecord Lookup 🔍
Get the 'Acme' Account
sObject: Account
Fields Queried: all
Filter Logic: and
1. Name EqualTo Acme
Limit: First Record Only" as Get_the_Acme_Account + class Get_the_Acme_Account pink modified + state "ΔRecord Update ✏️
Update the 'Acme' Account
Reference Update
Get_the_Acme_Account
" as Update_the_Acme_Account + class Update_the_Acme_Account pink modified + state "+Action Call
Log Error" as Log_Error + class Log_Error navy added + state "+Action Call
Log Error" as Log_Error2 + class Log_Error2 navy added + FLOW_START --> Get_the_Acme_Account + Get_the_Acme_Account --> Set_the_Type + Get_the_Acme_Account --> Log_Error : ❌ Fault ❌ + Set_the_Type --> Update_the_Acme_Account + Update_the_Acme_Account --> Log_Error2 : ❌ Fault ❌ +``` diff --git a/deno.json b/deno.json index 5ec8342..54feb3b 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@goog/flow-lens", - "version": "0.1.12", + "version": "0.1.14", "license": "Apache", "exports": "./src/main/main.ts", "imports": { diff --git a/docs/img/Diff_New.png b/docs/img/Diff_New.png deleted file mode 100644 index f3155d3..0000000 Binary files a/docs/img/Diff_New.png and /dev/null differ diff --git a/docs/img/Diff_Old.png b/docs/img/Diff_Old.png deleted file mode 100644 index 6df0363..0000000 Binary files a/docs/img/Diff_Old.png and /dev/null differ diff --git a/docs/img/Initial_Diagram.png b/docs/img/Initial_Diagram.png deleted file mode 100644 index cfbf1e9..0000000 Binary files a/docs/img/Initial_Diagram.png and /dev/null differ diff --git a/src/main/argument_processor.ts b/src/main/argument_processor.ts index bf7f719..6280559 100644 --- a/src/main/argument_processor.ts +++ b/src/main/argument_processor.ts @@ -67,6 +67,7 @@ export enum DiagramTool { export enum Mode { JSON = "json", GITHUB_ACTION = "github_action", + MARKDOWN = "markdown", } /** @@ -93,6 +94,7 @@ export const ERROR_MESSAGES = { "gitDiffFromHash and gitDiffToHash must be specified together", outputFileNameRequired: "outputFileName is required for JSON mode", outputDirectoryRequired: "outputDirectory is required for JSON mode", + unsupportedMode: (mode: string) => `Unsupported mode: ${mode}. Valid options are: ${ Object.values(Mode).join( @@ -106,6 +108,7 @@ export const ERROR_MESSAGES = { "GitHub Action mode requires gitDiffToHash to be 'HEAD'", githubActionRequiresHeadMinusOne: "GitHub Action mode requires gitDiffFromHash to be 'HEAD^1'", + markdownRequiresMermaid: "Markdown mode requires diagramTool to be 'mermaid'", }; /** @@ -168,11 +171,21 @@ export class ArgumentProcessor { this.validateOutputFileName(); } + // Validate output directory for markdown mode + if (this.config.mode?.toLowerCase() === Mode.MARKDOWN) { + this.validateOutputDirectory(); + } + // Validate GitHub Action specific requirements if (this.config.mode?.toLowerCase() === Mode.GITHUB_ACTION) { this.validateGitHubActionMode(); } + // Validate Markdown specific requirements + if (this.config.mode?.toLowerCase() === Mode.MARKDOWN) { + this.validateMarkdownMode(); + } + this.validateRequiredArguments(); this.validateMutuallyExclusiveArguments(); this.validateConditionalArguments(); @@ -254,6 +267,12 @@ export class ArgumentProcessor { } } + private validateMarkdownMode() { + if (this.config.diagramTool?.toLowerCase() !== DiagramTool.MERMAID) { + this.errorsEncountered.push(ERROR_MESSAGES.markdownRequiresMermaid); + } + } + private validateRequiredArguments() { if ( (!this.config.filePath || this.config?.filePath?.length === 0) && diff --git a/src/main/flow_file_change_detector.ts b/src/main/flow_file_change_detector.ts index bd49985..b139eb4 100644 --- a/src/main/flow_file_change_detector.ts +++ b/src/main/flow_file_change_detector.ts @@ -137,8 +137,7 @@ export class FlowFileChangeDetector { */ private executeGitCommand(args: string[]): Uint8Array { const repo = Configuration.getInstance().gitRepo; - const commandArgs = [repo ? `-C ${repo}` : "", ...args].filter(Boolean); - + const commandArgs = repo ? ["-C", repo, ...args] : args; return new Deno.Command("git", { args: commandArgs }).outputSync().stdout; } } diff --git a/src/main/flow_parser.ts b/src/main/flow_parser.ts index fdbd4cf..c173b94 100644 --- a/src/main/flow_parser.ts +++ b/src/main/flow_parser.ts @@ -67,6 +67,7 @@ export interface Transition { */ export interface ParsedFlow { label?: string; + fullName?: string; processType?: flowTypes.FlowProcessType; start?: flowTypes.FlowStart; apexPluginCalls?: flowTypes.FlowApexPluginCall[]; @@ -132,6 +133,7 @@ export class FlowParser { private populateFlowNodes(flow: flowTypes.Flow) { this.beingParsed.label = flow.label; + this.beingParsed.fullName = flow.fullName; this.beingParsed.processType = flow.processType; this.beingParsed.start = flow.start; this.validateFlowStart(); @@ -228,6 +230,7 @@ export class FlowParser { if (!start) { return result; } + const queue: flowTypes.FlowNode[] = [start]; const visitedNodes = new Set(); while (queue.length > 0) { @@ -237,6 +240,7 @@ export class FlowParser { } visitedNodes.add(node.name); const transitions = this.getTransitionsForNode(node); + for (const transition of transitions) { const toNode = this.beingParsed.nameToNode?.get(transition.to); if (toNode) { @@ -245,6 +249,7 @@ export class FlowParser { } result.push(...transitions); } + return result; } @@ -290,6 +295,7 @@ export class FlowParser { ) { transitions.push(...this.getTransitionsFromConnector(node)); } + return transitions; } @@ -361,6 +367,7 @@ export class FlowParser { | flowTypes.FlowActionCall, ): Transition[] { const result: Transition[] = []; + if (node.connector) { result.push( this.createTransition(node, node.connector, false, undefined), @@ -371,6 +378,7 @@ export class FlowParser { this.createTransition(node, node.faultConnector, true, FAULT), ); } + return result; } @@ -405,6 +413,7 @@ export class FlowParser { | flowTypes.FlowCustomError, ): Transition[] { const result: Transition[] = []; + if (node.connector) { for ( const connector of Array.isArray(node.connector) @@ -414,6 +423,7 @@ export class FlowParser { result.push(this.createTransition(node, connector, false, undefined)); } } + return result; } @@ -727,5 +737,6 @@ function isCustomError( function isFlowStart( node: flowTypes.FlowNode, ): node is flowTypes.FlowStart { - return (node as flowTypes.FlowStart).connector !== undefined; + return (node as flowTypes.FlowStart).name === START && + (node as flowTypes.FlowStart).label === undefined; } diff --git a/src/main/uml_writer.ts b/src/main/uml_writer.ts index 5f1c65f..1553ae4 100644 --- a/src/main/uml_writer.ts +++ b/src/main/uml_writer.ts @@ -23,6 +23,8 @@ import { Configuration, Mode, RuntimeConfig } from "./argument_processor.ts"; import { FlowDifference } from "./flow_to_uml_transformer.ts"; import { GithubClient } from "./github_client.ts"; +const EOL = Deno.build.os === "windows" ? "\r\n" : "\n"; + const FILE_EXTENSION = ".json"; const HIDDEN_COMMENT_PREFIX = ""; const MERMAID_OPEN_TAG = "```mermaid"; @@ -48,10 +50,13 @@ export class UmlWriter { */ async writeUmlDiagrams() { const config = Configuration.getInstance(); + if (config.mode === Mode.JSON) { this.writeJsonFile(config); } else if (config.mode === Mode.GITHUB_ACTION) { await this.writeGithubComment(config); + } else if (config.mode === Mode.MARKDOWN) { + this.writeMarkdownFiles(config); } } @@ -94,6 +99,44 @@ export class UmlWriter { throw error; } } + + private writeMarkdownFiles(config: RuntimeConfig) { + for (const [filePath, flowDifference] of this.filePathToFlowDifference) { + const flowApiName = this.extractFlowApiName(filePath); + + const outputPath = join( + config.outputDirectory!, + `${flowApiName}.md`, + ); + + let markdownContent = ""; + const tripleBackticks = "```"; + + if (flowDifference.old) { + markdownContent += + `## Old Version${EOL}${EOL}${tripleBackticks}mermaid${EOL}${flowDifference.old}${EOL}${tripleBackticks}${EOL}${EOL}`; + markdownContent += + `## New Version${EOL}${EOL}${tripleBackticks}mermaid${EOL}${flowDifference.new}${EOL}${tripleBackticks}${EOL}`; + } else { + markdownContent += + `${tripleBackticks}mermaid${EOL}${flowDifference.new}${EOL}${tripleBackticks}${EOL}`; + } + + Deno.writeTextFileSync(outputPath, markdownContent); + } + } + + private extractFlowApiName(filePath: string): string { + // Extract the flow API name from the file path + // The file path should contain the flow API name + const fileName = filePath.split("/").pop() || ""; + // Remove common flow file extensions + const flowApiName = fileName + .replace(/\.flow-meta\.xml$/, "") + .replace(/\.flow$/, "") + .replace(/\.xml$/, ""); + return flowApiName || "flow"; + } } interface DefaultFormat { diff --git a/src/test/argument_processor_test.ts b/src/test/argument_processor_test.ts index 456ed29..2cf5db0 100644 --- a/src/test/argument_processor_test.ts +++ b/src/test/argument_processor_test.ts @@ -98,6 +98,74 @@ Deno.test("ArgumentProcessor", async (t) => { }, ); + await t.step( + "should validate when mode is MARKDOWN and outputDirectory is provided", + () => { + const { argumentProcessor, config } = setupTest((config) => { + config.mode = Mode.MARKDOWN; + config.diagramTool = DiagramTool.MERMAID; + config.outputDirectory = "."; + config.outputFileName = undefined; + }); + const result = argumentProcessor.getConfig(); + assertEquals(result, config); + }, + ); + + await t.step( + "should reject markdown mode with non-mermaid diagram tool", + () => { + assertThrows( + () => { + const { argumentProcessor } = setupTest((config) => { + config.mode = Mode.MARKDOWN; + config.diagramTool = DiagramTool.GRAPH_VIZ; + config.outputDirectory = "test/output"; + }); + argumentProcessor.getConfig(); + }, + Error, + ERROR_MESSAGES.markdownRequiresMermaid, + ); + }, + ); + + await t.step( + "should reject markdown mode without outputDirectory", + () => { + assertThrows( + () => { + const { argumentProcessor } = setupTest((config) => { + config.mode = Mode.MARKDOWN; + config.diagramTool = DiagramTool.MERMAID; + config.outputDirectory = undefined; + }); + argumentProcessor.getConfig(); + }, + Error, + ERROR_MESSAGES.outputDirectoryRequired, + ); + }, + ); + + await t.step( + "should reject markdown mode with non-existent outputDirectory", + () => { + assertThrows( + () => { + const { argumentProcessor } = setupTest((config) => { + config.mode = Mode.MARKDOWN; + config.diagramTool = DiagramTool.MERMAID; + config.outputDirectory = "non/existent/directory"; + }); + argumentProcessor.getConfig(); + }, + Error, + ERROR_MESSAGES.invalidOutputDirectory("non/existent/directory"), + ); + }, + ); + await t.step( "should throw an exception when outputDirectory is not provided in JSON mode", () => { diff --git a/src/test/uml_writer_test.ts b/src/test/uml_writer_test.ts index 963d22b..5efaf78 100644 --- a/src/test/uml_writer_test.ts +++ b/src/test/uml_writer_test.ts @@ -175,4 +175,49 @@ Deno.test("UmlWriter", async (t) => { Deno.env.get = originalEnvGet; } }); + + await t.step("should write UML diagrams as markdown files", async () => { + // Mock the Configuration.getInstance to return our test config + const originalGetInstance = Configuration.getInstance; + const testConfig = getRuntimeConfig(DiagramTool.MERMAID, Mode.MARKDOWN); + + Configuration.getInstance = () => testConfig; + + try { + writer = new UmlWriter(FILE_PATH_TO_FLOW_DIFFERENCE); + + writer.writeUmlDiagrams(); + + // Check that markdown files were created + const expectedFile1Path = join(TEST_UNDECLARED_OUTPUTS_DIR, "file1.md"); + const expectedFile2Path = join(TEST_UNDECLARED_OUTPUTS_DIR, "file2.md"); + + assertExists(existsSync(expectedFile1Path)); + assertExists(existsSync(expectedFile2Path)); + + // Use OS-appropriate newlines for test expectations + const eol = Deno.build.os === "windows" ? "\r\n" : "\n"; + + // Check the content of the first file (no old version) + fileContent = Deno.readTextFileSync(expectedFile1Path); + assertEquals( + fileContent, + `\`\`\`mermaid${eol}uml1${eol}\`\`\`${eol}`, + ); + + // Check the content of the second file (with old version) + fileContent = Deno.readTextFileSync(expectedFile2Path); + assertEquals( + fileContent, + `## Old Version${eol}${eol}\`\`\`mermaid${eol}uml1${eol}\`\`\`${eol}${eol}## New Version${eol}${eol}\`\`\`mermaid${eol}uml2${eol}\`\`\`${eol}`, + ); + + // Clean up + await Deno.remove(expectedFile1Path); + await Deno.remove(expectedFile2Path); + } finally { + // Restore original Configuration.getInstance + Configuration.getInstance = originalGetInstance; + } + }); });