diff --git a/.changeset/shy-hairs-poke.md b/.changeset/shy-hairs-poke.md new file mode 100644 index 000000000..1a1897f2c --- /dev/null +++ b/.changeset/shy-hairs-poke.md @@ -0,0 +1,5 @@ +--- +"@rnx-kit/tools-formatting": minor +--- + +Add structured logging support for github, azure, along with auto-detection logic diff --git a/incubator/tools-formatting/README.md b/incubator/tools-formatting/README.md index 2b6d31bbc..acafe84cd 100644 --- a/incubator/tools-formatting/README.md +++ b/incubator/tools-formatting/README.md @@ -9,11 +9,17 @@ 🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧 -Provides light-weight, zero-dependency, formatting utilities for console (or log-file) output. +Light-weight, zero-dependency formatting utilities for console output, log +files, and CI build logs (GitHub Actions and Azure Pipelines). ## Motivation -Lightweight and centralized formatting utilities. +Most React Native build tooling needs to surface the same diagnostic in three +places: a developer's terminal, a CI log, and inline annotations on a pull +request. This package centralizes that formatting behind a small **Reporter** +abstraction so callers write the message once and the right syntax is emitted +for the active environment. The package also ships standalone helpers for +tables, trees, and path shortening. ## Installation @@ -21,7 +27,7 @@ Lightweight and centralized formatting utilities. yarn add @rnx-kit/tools-formatting --dev ``` -or if you're using npm +or with npm: ```sh npm add --save-dev @rnx-kit/tools-formatting @@ -29,9 +35,242 @@ npm add --save-dev @rnx-kit/tools-formatting ## Usage +### Reporters + +A **Reporter** describes how to render three kinds of output: + +| Method | Renders | +| ------------------- | ------------------------------------------------------------- | +| `formatMessage` | A plain severity-tagged message (no source location). | +| `formatFileMessage` | A diagnostic tied to a `file:line:col` location. | +| `formatGroup` | A collapsible / tree-shaped group with a header and children. | + +Four built-in reporters are available, keyed by name: + +| Name | Used for | +| ----------- | ----------------------------------------------------------------------------------------------------- | +| `"github"` | GitHub Actions logs (`::error file=...,line=...::msg`, `::group::header`, etc.). | +| `"azure"` | Azure Pipelines logs (`##vso[task.logissue type=error;sourcepath=...]msg`, `##[group]header`, etc.). | +| `"console"` | A developer terminal — color-prefixed severity, GCC-style file locations, unicode trees, ANSI colors. | +| `"file"` | A plain log file — same layout as `console`, but colors are stripped by default. | + +The top-level helpers — `formatMessage`, `formatFileMessage`, `formatGroup` — +accept an optional reporter parameter. If omitted, a reporter is selected +automatically by inspecting the environment (see [Default reporter +resolution](#default-reporter-resolution)). + +```typescript +import { + formatMessage, + formatFileMessage, + formatGroup, +} from "@rnx-kit/tools-formatting"; + +// Auto-detected reporter (console locally, github on GHA, azure on Azure DevOps). +console.error(formatMessage("error", "Build failed")); + +console.error( + formatFileMessage("warn", { + message: "x is declared but never used", + file: "src/foo.ts", + line: 12, + col: 5, + title: "TS6133", + root: process.cwd(), + }) +); + +console.log( + formatGroup("Type errors (3)", [ + "src/foo.ts:12:5 - x is declared but never used", + "src/bar.ts:3:1 - Cannot find module", + "src/baz.ts:9:7 - Property does not exist", + ]) +); +``` + +Output on GitHub Actions: + +``` +::error::Build failed +::warning title=TS6133,file=src/foo.ts,line=12,col=5::x is declared but never used +::group::Type errors (3) +src/foo.ts:12:5 - x is declared but never used +src/bar.ts:3:1 - Cannot find module +src/baz.ts:9:7 - Property does not exist +::endgroup:: +``` + +Output on Azure Pipelines: + +``` +##vso[task.logissue type=error]Build failed +##vso[task.logissue type=warning;sourcepath=src/foo.ts;linenumber=12;columnnumber=5]x is declared but never used +##[group]Type errors (3) +src/foo.ts:12:5 - x is declared but never used +src/bar.ts:3:1 - Cannot find module +src/baz.ts:9:7 - Property does not exist +##[endgroup] +``` + +Output in a local terminal (with colors): + +``` +error: Build failed +warn: src/foo.ts:12:5: [TS6133] x is declared but never used +Type errors (3) +├── src/foo.ts:12:5 - x is declared but never used +├── src/bar.ts:3:1 - Cannot find module +└── src/baz.ts:9:7 - Property does not exist +``` + +### Selecting a reporter explicitly + +Each helper accepts an optional reporter argument. You can pass a built-in +name, a custom `Reporter` instance, or leave it off to use the auto-detected +default. + +```typescript +import { formatMessage, getReporter } from "@rnx-kit/tools-formatting"; + +formatMessage("error", "boom", "github"); // built-in by name +formatMessage("error", "boom", "console"); // force plain output on CI +formatMessage("error", "boom", getReporter("file")); // resolve once and reuse +formatMessage("error", "boom", myCustomReporter); // your own implementation +``` + +### Default reporter resolution + +When no reporter is passed, `getDefaultReporter` picks one in this order: + +1. The value of `process.env.RNX_TARGET_REPORTER`, if set to a built-in name. +2. `"github"` if `process.env.GITHUB_ACTIONS === "true"`. +3. `"azure"` if `process.env.TF_BUILD === "True"`. +4. `"console"` otherwise. + +The result is cached for the lifetime of the process. Two predicate helpers +are exported for callers that want to check the environment directly: + +```typescript +import { isGitHubActions, isAzurePipelines } from "@rnx-kit/tools-formatting"; +``` + +### Customizing a built-in reporter + +`createReporter` (and the per-provider `createGitHubReporter` / +`createAzureReporter` factories) accepts a small overrides bag for tweaking +the reporter's `name`, `noColors`, and `asciiOnly` flags: + +```typescript +import { createReporter } from "@rnx-kit/tools-formatting"; + +const asciiFile = createReporter("file", { asciiOnly: true }); +// `formatGroup` will now use `+-- ` / `` `-- `` instead of unicode trees. +``` + +### Extending the registry + +Reporter resolution is owned by a [`ReporterRegistry`](#reporterregistry) +instance. The package exports a process-wide singleton (accessed via +`getReporterRegistry()`); the top-level helpers (`getReporter`, +`getDefaultReporter`, `formatMessage`, …) all delegate to it. + +To add a new reporter type or change the default-resolution chain, subclass +`ReporterRegistry` and install your instance with `setReporterRegistry()` +at the entry point of your tool: + +```typescript +import { + ReporterRegistry, + setReporterRegistry, + type Reporter, + type ReporterPropOverrides, +} from "@rnx-kit/tools-formatting"; + +const teamcityReporter: Reporter = { + name: "teamcity", + noColors: true, + asciiOnly: true, + formatMessage: (sev, msg) => + `##teamcity[message text='${msg}' status='${sev.toUpperCase()}']`, + formatFileMessage: (sev, m) => + `##teamcity[message text='${m.file}:${m.line ?? 0}: ${m.message}' status='${sev.toUpperCase()}']`, + formatGroup: (header, children) => + [ + `##teamcity[blockOpened name='${header}']`, + ...children, + "##teamcity[blockClosed]", + ].join("\n"), +}; + +class MyToolRegistry extends ReporterRegistry { + // Use a tool-specific env var for the explicit override. + protected override envKey = "MYTOOL_REPORTER"; + + override createReporter( + type: string, + options?: ReporterPropOverrides + ): Reporter { + if (type === "teamcity") return teamcityReporter; + return super.createReporter(type, options); + } + + override getDefaultReporterType(): string { + if (process.env.TEAMCITY_VERSION) return "teamcity"; + return super.getDefaultReporterType(); + } +} + +setReporterRegistry(new MyToolRegistry()); +``` + +From this point on, every call to `getReporter("teamcity")` (or +`formatMessage(...)` running under TeamCity) goes through the custom +registry. Tests can construct their own `ReporterRegistry` instance without +touching the singleton, or call `setReporterRegistry(undefined)` to drop the +override and let the next access rebuild a fresh default. + +`ReporterRegistry` is designed for subclassing. The methods worth overriding +are: + +| Method | Purpose | +| ------------------------ | ----------------------------------------------------------------- | +| `createReporter` | Add new reporter types. Fall through with `super.createReporter`. | +| `getDefaultReporterType` | Add CI-provider detection. Fall through with `super`. | +| `envKey` (property) | Change which environment variable provides the explicit override. | +| `reset` | Clear the cached default and the named-reporter cache. | + +### Writing a custom reporter + +A reporter is just an object that implements the [`Reporter` +interface](#reporter): + +```typescript +import type { Reporter } from "@rnx-kit/tools-formatting"; + +const teamCityReporter: Reporter = { + name: "teamcity", + noColors: true, + asciiOnly: false, + formatMessage: (sev, msg) => + `##teamcity[message text='${msg}' status='${sev.toUpperCase()}']`, + formatFileMessage: (sev, m) => + `##teamcity[message text='${m.file}:${m.line ?? 0}: ${m.message}' status='${sev.toUpperCase()}']`, + formatGroup: (header, children) => + [ + `##teamcity[blockOpened name='${header}']`, + ...children, + "##teamcity[blockClosed]", + ].join("\n"), +}; +``` + ### Table formatting -The `formatAsTable` utility can format any 2D data array into a bordered table: +`formatAsTable` renders a 2D array of values as a bordered table. Columns can +be configured with labels, alignment, max width, fixed-digit numeric +formatting, locale-aware number formatting, ANSI styles, and a custom +`format` function. ```typescript import { formatAsTable } from "@rnx-kit/tools-formatting"; @@ -53,11 +292,20 @@ const table = formatAsTable( console.log(table); ``` +> **Sort direction.** `sort` orders numeric columns descending (largest first) +> and string columns ascending (A–Z), which matches the common use case of +> "show the biggest metric on top" while still keeping name columns +> alphabetical. + +Use `asciiOnly: true` (or supply a custom `tableParts`) for ASCII-only border +characters. + ### Tree formatting `formatAsTree` assembles a header and a list of pre-formatted rows into a -tree-shaped string. It is a pure formatter — no buffering, no console output, -no styling — so callers stay in control of how rows are produced and styled. +tree-shaped string. It is a pure formatter — no console output, no styling. +Reporters use it internally for `console` / `file` group rendering, but it is +also exported standalone: ```typescript import { formatAsTree } from "@rnx-kit/tools-formatting"; @@ -74,8 +322,8 @@ console.log(report); // └── homepage is empty ``` -Rows that contain `\n` are expanded into multiple output lines, with the trunk -character preserved on continuation lines so the structure stays readable: +Rows containing `\n` (or `\r\n`) expand into multiple output lines with the +trunk character preserved on continuation lines: ```typescript formatAsTree("Type errors", [ @@ -89,44 +337,45 @@ formatAsTree("Type errors", [ // Cannot find name "baz" ``` -For terminals without unicode support, set `asciiOnly`: +ASCII fallback (`asciiOnly: true`) and a fully-custom `treeParts` override are +supported, as is an `indent` (number of spaces or a literal prefix string) +that is applied to every row line. The result has no trailing newline. -```typescript -formatAsTree("Header", ["a", "b"], { asciiOnly: true }); -// Header -// +-- a -// `-- b -``` +### Console message helpers -Or supply a fully custom set of branch characters via `treeParts`: +When you want to render a single diagnostic without going through a reporter +(for example, you're already writing to a fixed stream), the underlying +formatters are exported directly: ```typescript -formatAsTree("Header", ["a", "b"], { - treeParts: { - row: ["* ", " "], // [first-line prefix, multi-line continuation] - last: ["> ", " "], - }, +import { + formatConsoleMessage, + formatConsoleFileMessage, + colorText, + compareSeverity, +} from "@rnx-kit/tools-formatting"; + +formatConsoleMessage("error", "boom"); +// "error: boom" — with the "error" prefix colored red if colors are enabled + +formatConsoleFileMessage("warn", { + message: "x is declared but never used", + file: "src/foo.ts", + line: 12, + col: 5, + title: "TS6133", }); -``` +// "warn: src/foo.ts:12:5: [TS6133] x is declared but never used" -`indent` (number of spaces or a literal string) is prepended to every row line, -including continuations, while leaving the header column flush: +colorText("red", "danger", { noColors: false }); -```typescript -formatAsTree("Header", ["a", "b"], { indent: 2 }); -// Header -// ├── a -// └── b +compareSeverity("warn", "error"); // negative — "warn" is less severe ``` -The result never has a trailing newline, so it composes cleanly with whatever -emits it. - -### Path shortening +### Path utilities `shortenPath` truncates file paths for display, keeping the most significant -trailing segments and replacing the rest with an ellipsis. This is useful for -tables or logs where long absolute paths waste space. +trailing segments and replacing the rest with an ellipsis: ```typescript import { shortenPath } from "@rnx-kit/tools-formatting"; @@ -137,34 +386,108 @@ shortenPath( // => ".../metro-resolver-symlinks/src/resolver.ts" ``` -By default it keeps 3 path segments. If the segment at the cut boundary is a -known source directory (`src`, `lib`, `dist`, `bin`), it keeps one extra segment -so the parent package name stays visible: +By default it keeps three segments. If the segment at the cut boundary is a +known source directory (`src`, `lib`, `dist`, `bin`), it keeps one extra +segment so the parent package name stays visible: ```typescript shortenPath("/Users/me/dev/rnx-kit/packages/my-package/src/utils/helpers.ts"); // => ".../my-package/src/utils/helpers.ts" (4 segments) ``` -Short paths are returned unchanged when shortening would not save space. The -segment count can be customized: +Short paths are returned unchanged when shortening would not save space; the +segment count is configurable via the second argument. + +`normalizePath` makes a path relative to a `root` (defaulting to +`process.cwd()`) and converts it to POSIX slashes so the resulting string +works as a clickable link in both GitHub Actions and Azure Pipelines logs on +any host platform: ```typescript -shortenPath("/a/b/c/d/e.ts", 2); -// => ".../d/e.ts" +import { normalizePath } from "@rnx-kit/tools-formatting"; + +normalizePath( + "D:\\repos\\rnx-kit\\packages\\foo\\src\\index.ts", + "D:\\repos\\rnx-kit" +); +// => "packages/foo/src/index.ts" ``` ## API Reference ### Functions -| Function | Description | -| ----------------------------------- | ------------------------------------------------------------------------------- | -| `formatAsTable(data, opts?)` | Format a 2D data array into a bordered ASCII table. | -| `formatAsTree(header, rows, opts?)` | Format a header and a list of rows into a tree-shaped string. | -| `shortenPath(path, segs?)` | Shorten a file path to the last _segs_ segments (default 3), with `...` prefix. | - -### TableOptions +| Function | Description | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `formatMessage(severity, message, reporter?)` | Format a plain severity-tagged message using the chosen / default reporter. | +| `formatFileMessage(severity, fileMessage, reporter?)` | Format a diagnostic tied to a file location using the chosen / default reporter. | +| `formatGroup(header, children, reporter?)` | Format a header + child lines as a collapsible group / tree. | +| `formatAsTable(data, opts?)` | Format a 2D data array into a bordered table. | +| `formatAsTree(header, rows, opts?)` | Format a header and a list of rows into a tree-shaped string. | +| `formatConsoleMessage(severity, message, opts?)` | Format a single severity-tagged message for console output. | +| `formatConsoleFileMessage(severity, fileMsg, opts?)` | Format a single file-located diagnostic for console output. | +| `colorText(style, text, opts?)` | Apply an ANSI style to text, respecting `noColors`. | +| `compareSeverity(a, b)` | Numeric comparator over severity levels (`info < warn < error`). | +| `shortenPath(path, segs?)` | Shorten a file path to the last _segs_ segments (default 3), with `...` prefix. | +| `normalizePath(file, root?)` | Make a path relative to `root` (default `process.cwd()`) and convert to POSIX slashes. | +| `getReporter(reporter?)` | Resolve a reporter by name, custom instance, or environment default. | +| `getDefaultReporter()` | Get the cached default reporter for the current environment. | +| `getDefaultReporterType()` | Get the name of the default reporter that would be selected for the environment. | +| `createReporter(type, opts?)` | Build a fresh built-in reporter, optionally overriding its defaults. | +| `createGitHubReporter(opts?)` | Build a GitHub Actions reporter with optional overrides. | +| `createAzureReporter(opts?)` | Build an Azure Pipelines reporter with optional overrides. | +| `createConsoleOrFileReporter(type, opts?)` | Build a `console` or `file` reporter with optional overrides. | +| `getReporterRegistry()` | Get the process-wide singleton `ReporterRegistry` (lazily constructed). | +| `setReporterRegistry(registry)` | Replace (or clear, with `undefined`) the singleton `ReporterRegistry`. | +| `isGitHubActions()` | Returns `true` when `GITHUB_ACTIONS === "true"`. | +| `isAzurePipelines()` | Returns `true` when `TF_BUILD === "True"`. | + +### Types + +`Severity`, `FileMessage`, `Reporter`, `BuiltinReporter`, `ReporterOption`, +`ReporterPropOverrides`, `ColorOptions`, `TextOptions`, `StyleValue`, +`TableOptions`, `ColumnOptions`, `TableViewParts`, `TreeFormattingOptions`, +`TreeViewParts`. + +#### ReporterRegistry + +Class that owns reporter resolution. Subclass to add new reporter types or +extend default detection; install your subclass with `setReporterRegistry()`. + +| Method / property | Description | +| ----------------------------- | ----------------------------------------------------------------------------------------- | +| `getReporter(opt?)` | Resolve a `ReporterOption` to a `Reporter`. Built-in names are cached per name. | +| `getDefaultReporter()` | Cached lookup of the reporter for `getDefaultReporterType()`. | +| `createReporter(type, opts?)` | Construct a fresh reporter by name. **Override** to add new types. | +| `getDefaultReporterType()` | Compute the default reporter name from the environment. **Override** to add CI providers. | +| `envKey` (protected) | Environment variable consulted by the default `getDefaultReporterType`. | +| `reset()` | Drop the cached default and per-name reporter cache. | + +#### Reporter + +| Field | Type | Description | +| ------------------- | ----------------------------------- | --------------------------------------------------------------- | +| `name` | `string` | Identifier for diagnostics / introspection. | +| `noColors` | `boolean` | Whether the reporter strips ANSI styling (consumed by helpers). | +| `asciiOnly` | `boolean` | Whether the reporter uses ASCII-only glyphs for trees/tables. | +| `formatMessage` | `(severity, message) => string` | Render a plain severity-tagged message. | +| `formatFileMessage` | `(severity, fileMessage) => string` | Render a diagnostic tied to a `FileMessage`. | +| `formatGroup` | `(header, children) => string` | Render a collapsible group; result is newline-joined. | + +#### FileMessage + +| Field | Type | Required | Description | +| --------- | -------- | -------- | ------------------------------------------------------------------------------------------------------- | +| `message` | `string` | yes | The diagnostic text. | +| `file` | `string` | yes | Path to the file. Combined with `root` and converted to POSIX slashes via `normalizePath`. | +| `root` | `string` | no | Root directory for relative-path resolution. Defaults to `process.cwd()` inside `normalizePath`. | +| `line` | `number` | no | 1-based line number. | +| `col` | `number` | no | 1-based column number. | +| `endLine` | `number` | no | 1-based end line number. GitHub Actions only — ignored on Azure. | +| `endCol` | `number` | no | 1-based end column number. GitHub Actions only — emitted as `endColumn` per spec. Ignored on Azure. | +| `title` | `string` | no | Short title shown above the message in the GitHub Actions UI; shown as a bracketed tag in plain output. | + +#### TableOptions | Field | Type | Default | Description | | ------------ | ----------------------------- | ------- | ------------------------------------------------------------------------ | @@ -175,7 +498,7 @@ shortenPath("/a/b/c/d/e.ts", 2); | `tableParts` | `TableViewParts` | -- | Fully override the border characters. Takes precedence over `asciiOnly`. | | `noColors` | `boolean` | `false` | Strip ANSI styling from output. | -### ColumnOptions +#### ColumnOptions | Field | Type | Default | Description | | ----------- | --------------------------- | -------- | ------------------------------------------------------- | @@ -187,7 +510,7 @@ shortenPath("/a/b/c/d/e.ts", 2); | `maxWidth` | `number` | -- | Maximum column width (truncates with `...`). | | `style` | `StyleValue \| function` | -- | ANSI style or custom formatter. | -### TreeFormattingOptions +#### TreeFormattingOptions | Field | Type | Default | Description | | ----------- | ------------------ | ------- | -------------------------------------------------------------------------- | @@ -195,13 +518,22 @@ shortenPath("/a/b/c/d/e.ts", 2); | `treeParts` | `TreeViewParts` | -- | Fully override the branch characters. Takes precedence over `asciiOnly`. | | `indent` | `number \| string` | none | Prepend this many spaces (number) or this exact string to every row line. | -### TreeViewParts +#### TreeViewParts Describes the branch glyphs used for each row as `[first-line prefix, continuation prefix]`. The continuation prefix is used -when a row's text contains `\n`. All four prefixes should have the same width. +when a row's text contains a newline. All four prefixes should have the same +width so columns line up. | Field | Type | Description | | ------ | ------------------ | -------------------------------------------------------- | | `row` | `[string, string]` | Prefixes for any non-last row (e.g. `["├── ", "│ "]`). | | `last` | `[string, string]` | Prefixes for the final row (e.g. `["└── ", " "]`). | + +### Environment variables + +| Variable | Used by | Description | +| --------------------- | ----------------------------------------- | -------------------------------------------------------------------------- | +| `RNX_TARGET_REPORTER` | `getDefaultReporter` | Explicit override for the auto-detected reporter (a built-in name). | +| `GITHUB_ACTIONS` | `isGitHubActions` / `getDefaultReporter` | Set to `"true"` by the GitHub Actions runner; triggers the GH reporter. | +| `TF_BUILD` | `isAzurePipelines` / `getDefaultReporter` | Set to `"True"` by the Azure Pipelines agent; triggers the Azure reporter. | diff --git a/incubator/tools-formatting/src/azure.ts b/incubator/tools-formatting/src/azure.ts new file mode 100644 index 000000000..b6d4cf58d --- /dev/null +++ b/incubator/tools-formatting/src/azure.ts @@ -0,0 +1,118 @@ +import { formatConsoleFileMessage, formatConsoleMessage } from "./messages.ts"; +import { normalizePath } from "./paths.ts"; +import type { ColorOptions, FileMessage, Reporter, Severity } from "./types.ts"; + +const SEVERITY_TO_TEXT: Partial> = { + error: "error", + warn: "warning", +}; + +/** + * Pre-built {@link Reporter} that emits Azure Pipelines logging commands + * (`##vso[task.logissue ...]` for errors / warnings, `##[group]` / + * `##[endgroup]` for collapsible sections). Colors are disabled by default. + * Use {@link createAzureReporter} to construct a variant with different + * defaults. + */ +export const AzureReporter = createAzureReporter(); + +/** + * Creates a new AzureReporter with the given options. + * @param options Options to customize the reporter's behavior. + * @returns A new AzureReporter instance. + */ +export function createAzureReporter(options?: ColorOptions): Reporter { + const noColors = options?.noColors ?? true; + const base: Pick = { + name: "azure", + noColors, + asciiOnly: false, + }; + + function formatMessage(severity: Severity, message: string): string { + return formatAzureMessage(severity, message, base); + } + + function formatFileMessage( + severity: Severity, + fileMessage: FileMessage + ): string { + return formatAzureFileMessage(severity, fileMessage, base); + } + + function formatGroup(header: string, children: string[]): string { + const groupStart = `##[group]${escapeAzureData(header)}`; + const groupEnd = "##[endgroup]"; + return [groupStart, ...children, groupEnd].join("\n"); + } + + return Object.assign(base, { formatMessage, formatFileMessage, formatGroup }); +} + +/** + * Format a plain (non-file) message for Azure Pipelines. For `error` and + * `warn`, emits a `##vso[task.logissue type=...]` logging command so the + * message is surfaced in the build summary. For `info` (which has no native + * Azure equivalent), falls back to the console formatter so the message at + * least appears in the log stream. + */ +function formatAzureMessage( + severity: Severity, + message: string, + options?: ColorOptions +): string { + const sevText = SEVERITY_TO_TEXT[severity]; + if (!sevText) { + return formatConsoleMessage(severity, message, options); + } + return `##vso[task.logissue type=${sevText}]${escapeAzureData(message)}`; +} + +/** + * Format a file-located message for Azure Pipelines. Errors and warnings are + * emitted as `##vso[task.logissue ...]` commands with `sourcepath`, + * `linenumber`, and `columnnumber` properties so the Pipelines UI can render + * the diagnostic against the source file. The `endLine`, `endCol`, and `title` + * fields are ignored — Azure has no equivalent properties. `info` severity + * falls back to the console formatter (no native Azure level). + */ +function formatAzureFileMessage( + severity: Severity, + fileMessage: FileMessage, + options?: ColorOptions +): string { + const sevText = SEVERITY_TO_TEXT[severity]; + if (!sevText) { + return formatConsoleFileMessage(severity, fileMessage, options); + } + const { message, file, root, line, col } = fileMessage; + const filePath = normalizePath(file, root); + let msg = `##vso[task.logissue type=${sevText};sourcepath=${escapeAzureProp(filePath)}`; + if (line !== undefined) { + msg += `;linenumber=${line}`; + } + if (col !== undefined) { + msg += `;columnnumber=${col}`; + } + msg += `]${escapeAzureData(message)}`; + return msg; +} + +/** + * Escape Azure DevOps logging-command "data" (the portion after the `]`). + * Uses `%AZP25` for `%` so the agent can distinguish encoded text from + * user-supplied `%` characters, and percent-encodes CR / LF so multi-line + * messages don't break the single-line command format. + */ +function escapeAzureData(s: string): string { + return s.replace(/%/g, "%AZP25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); +} + +/** + * Escape Azure DevOps logging-command "property" values (the parts between + * `[task.logissue ...]`). In addition to the data escapes, also escapes the + * property separator `;` and the command terminator `]`. + */ +function escapeAzureProp(s: string): string { + return escapeAzureData(s).replace(/;/g, "%3B").replace(/\]/g, "%5D"); +} diff --git a/incubator/tools-formatting/src/const.ts b/incubator/tools-formatting/src/const.ts index 1cb2e7071..05c95c821 100644 --- a/incubator/tools-formatting/src/const.ts +++ b/incubator/tools-formatting/src/const.ts @@ -1,11 +1,25 @@ import type { TableViewParts, TreeViewParts } from "./types.ts"; +export const REPORTER_ENV_KEY = "RNX_TARGET_REPORTER"; export const ELLIPSIS = "..."; export const SRC_DIRS = ["src", "lib", "dist", "bin"]; export const SEPARATORS = ["/", "\\"]; type StyleKeys = "default" | "ascii"; +export const SEVERITY_LEVELS = { + info: 0, + warn: 1, + error: 2, +} as const; + +export const BUILTIN_REPORTERS = [ + "github", + "azure", + "console", + "file", +] as const; + export const TREE_STYLES: Record = { default: { row: ["├── ", "│ "], diff --git a/incubator/tools-formatting/src/core.ts b/incubator/tools-formatting/src/core.ts new file mode 100644 index 000000000..dac4f6f6f --- /dev/null +++ b/incubator/tools-formatting/src/core.ts @@ -0,0 +1,53 @@ +import { getReporter } from "./reporters.ts"; +import type { FileMessage, ReporterOption, Severity } from "./types.ts"; + +/** + * Format a message for output using standard formatting rules. The output format will depend on the reporter + * being used, which can be specified or will be determined automatically based on the environment. + * @param severity what level the message is (e.g. error, warn, info) + * @param message the message to format + * @param reporter the reporter to use for formatting, omit to use the default reporter + * @returns the formatted message + */ +export function formatMessage( + severity: Severity, + message: string, + reporter?: ReporterOption +): string { + reporter = getReporter(reporter); + return reporter.formatMessage(severity, message); +} + +/** + * Format a file message for output using standard formatting rules. When running under github or azure this will attempt + * to format the output such that file links are resolvable. + * @param severity what level the message is (e.g. error, warn, info) + * @param fileMessage the file message to format + * @param reporter the reporter to use for formatting, omit to use the default reporter + * @returns the formatted file message + */ +export function formatFileMessage( + severity: Severity, + fileMessage: FileMessage, + reporter?: ReporterOption +): string { + reporter = getReporter(reporter); + return reporter.formatFileMessage(severity, fileMessage); +} + +/** + * Format grouped output for display using standard formatting rules. For normal console output this will format the + * group in a tree-like structure. When running under github or azure this will attempt to use collapsible groups in the UI. + * @param header the header to display for the group + * @param children the messages to include in the group + * @param reporter the reporter to use for formatting, omit to use the default reporter + * @returns the formatted group of messages + */ +export function formatGroup( + header: string, + children: string[], + reporter?: ReporterOption +): string { + reporter = getReporter(reporter); + return reporter.formatGroup(header, children); +} diff --git a/incubator/tools-formatting/src/github.ts b/incubator/tools-formatting/src/github.ts new file mode 100644 index 000000000..97fd25a4d --- /dev/null +++ b/incubator/tools-formatting/src/github.ts @@ -0,0 +1,110 @@ +import { normalizePath } from "./paths.ts"; +import type { + Reporter, + Severity, + FileMessage, + ReporterPropOverrides, +} from "./types.ts"; + +/** + * Create a GitHub reporter instance. + * @param options Reporter property overrides. These are ignored by the methods for now. + * @returns A GitHub reporter instance. + */ +export function createGitHubReporter( + options?: ReporterPropOverrides +): Reporter { + const { + name = "github", + noColors = false, + asciiOnly = false, + } = options || {}; + return { + name, + noColors, + asciiOnly, + formatMessage: formatGitHubMessage, + formatFileMessage: formatGitHubFileMessage, + formatGroup: formatGitHubGroup, + }; +} + +const MSG_TO_PREFIX: Record = { + error: "::error", + warn: "::warning", + info: "::notice", +}; + +const FILE_PROP_KEYS: [keyof FileMessage, string][] = [ + ["line", "line"], + ["col", "col"], + ["endLine", "endLine"], + ["endCol", "endColumn"], + ["title", "title"], +]; + +/** + * Format a plain (non-file) message as a GitHub Actions workflow command: + * `::error::message` / `::warning::message` / `::notice::message`. The message + * text is percent-encoded so CR / LF / `%` don't break the command. + */ +function formatGitHubMessage(severity: Severity, message: string): string { + const prefix = MSG_TO_PREFIX[severity]; + return `${prefix}::${escapeGitHubData(message)}`; +} + +/** + * Format a file-located message as a GitHub Actions annotation. Emits a + * workflow command with properties for `file`, `line`, `col`, `endLine`, + * `endColumn`, and `title` so the annotation renders inline on the PR / build + * page with a clickable file link. Property values are percent-encoded for + * the property delimiter set (`%` / CR / LF / `:` / `,`); message data uses + * the lighter data escape (no `:` / `,`). + */ +function formatGitHubFileMessage( + severity: Severity, + fileMessage: FileMessage +): string { + const prefix = MSG_TO_PREFIX[severity]; + const { message, file, root } = fileMessage; + const filePath = normalizePath(file, root); + let propStr = `file=${escapeGitHubProp(filePath)}`; + for (const [key, propName] of FILE_PROP_KEYS) { + const value = fileMessage[key]; + if (value !== undefined) { + propStr += `,${propName}=${typeof value === "string" ? escapeGitHubProp(value) : String(value)}`; + } + } + return `${prefix} ${propStr}::${escapeGitHubData(message)}`; +} + +/** + * Wrap a list of child lines in a GitHub Actions collapsible group. The + * header is percent-encoded so embedded CR / LF / `%` don't break the + * `::group::` workflow command. Returns a single string with the group + * start / children / `::endgroup::` joined by newlines. + */ +function formatGitHubGroup(header: string, children: string[]): string { + const escapedHeader = escapeGitHubData(header); + const groupStart = `::group::${escapedHeader}`; + const groupEnd = "::endgroup::"; + return [groupStart, ...children, groupEnd].join("\n"); +} + +/** + * Escape data for the message portion of a GitHub Actions workflow command. + * Per the workflow-command spec, message data only needs `%`, CR, and LF + * encoded; `:` and `,` are safe to leave alone. + */ +function escapeGitHubData(s: string): string { + return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); +} + +/** + * Escape values used in workflow-command property positions (e.g. `file=...`). + * Adds `:` and `,` to the data escape set since those characters are the + * property delimiters. + */ +function escapeGitHubProp(s: string): string { + return escapeGitHubData(s).replace(/:/g, "%3A").replace(/,/g, "%2C"); +} diff --git a/incubator/tools-formatting/src/index.ts b/incubator/tools-formatting/src/index.ts index adf5f1e44..cbf73cb74 100644 --- a/incubator/tools-formatting/src/index.ts +++ b/incubator/tools-formatting/src/index.ts @@ -1,14 +1,49 @@ -export { shortenPath } from "./paths.ts"; +export { createAzureReporter } from "./azure.ts"; + +export { SEVERITY_LEVELS, TREE_STYLES, TABLE_STYLES } from "./const.ts"; + +export { formatMessage, formatFileMessage, formatGroup } from "./core.ts"; + +export { createGitHubReporter } from "./github.ts"; + +export { + formatConsoleMessage, + formatConsoleFileMessage, + compareSeverity, + colorText, +} from "./messages.ts"; + +export { shortenPath, normalizePath } from "./paths.ts"; + +export { + ReporterRegistry, + createConsoleOrFileReporter, + createReporter, + getDefaultReporter, + getDefaultReporterType, + getReporter, + getReporterRegistry, + isAzurePipelines, + isGitHubActions, + setReporterRegistry, +} from "./reporters.ts"; export type { TableOptions, ColumnOptions } from "./table.ts"; export { formatAsTable } from "./table.ts"; export type { + BuiltinReporter, ColorOptions, + FileMessage, + Reporter, + ReporterOption, + ReporterPropOverrides, + Severity, StyleValue, TableViewParts, TextOptions, TreeFormattingOptions, TreeViewParts, } from "./types.ts"; + export { formatAsTree } from "./trees.ts"; diff --git a/incubator/tools-formatting/src/messages.ts b/incubator/tools-formatting/src/messages.ts new file mode 100644 index 000000000..9482dd304 --- /dev/null +++ b/incubator/tools-formatting/src/messages.ts @@ -0,0 +1,84 @@ +import { styleText as nodeStyleText } from "node:util"; +import { SEVERITY_LEVELS } from "./const.ts"; +import { normalizePath } from "./paths.ts"; +import type { ColorOptions, FileMessage, Severity } from "./types.ts"; + +const SEVERITY_TO_COLOR: Record[0]> = + { + error: "red", + warn: "yellow", + info: "cyan", + }; + +/** + * Compare two severity levels and return a number indicating their relative order. + * @param a The first severity level + * @param b The second severity level + * @returns A negative number if `a` is less severe than `b`, a positive number if `a` is more severe than `b`, or 0 if they are equal + */ +export function compareSeverity(a: Severity, b: Severity): number { + return SEVERITY_LEVELS[a] - SEVERITY_LEVELS[b]; +} + +/** + * A wrapper around node's styleText that will respect the color options (if passed in) + * @param style The style to apply, as accepted by node's styleText function + * @param text The text to style + * @param options Optional color options to determine whether to apply styling or not + * @returns The styled text if colors are enabled, otherwise the original text + */ +export function colorText( + style: Parameters[0], + text: string, + options?: ColorOptions +): string { + return options?.noColors ? text : nodeStyleText(style, text); +} + +/** + * Format a console message with the given severity and message, applying color options if provided. + * @param severity The severity of the message (error, warn, info) + * @param message The message to format + * @param options Optional color options for styling the message + * @returns The formatted console message string + */ +export function formatConsoleMessage( + severity: Severity, + message: string, + options?: ColorOptions +): string { + const prefixColor = SEVERITY_TO_COLOR[severity]; + if (!severity || !prefixColor) { + return message; + } + return `${colorText(prefixColor, severity, options)}: ${message}`; +} + +/** + * Format a file message for console output, with or without colors depending on the options. The resulting message will be: + * severity: filePath:line:col: [title] message + * + * @param severity The severity of the message (error, warn, info) + * @param fileMsg The file message object containing details about the message + * @param options Optional color options for styling the message + * @returns The formatted file message string + */ +export function formatConsoleFileMessage( + severity: Severity, + fileMsg: FileMessage, + options?: ColorOptions +): string { + const { message, file, root, line, col, title } = fileMsg; + const filePath = normalizePath(file, root); + let fileMsgPart = colorText("magenta", filePath, options) + ":"; + if (line !== undefined) { + fileMsgPart += colorText("dim", `${line}:`, options); + if (col !== undefined) { + fileMsgPart += colorText("dim", `${col}:`, options); + } + } + if (title) { + fileMsgPart += ` [${colorText("bold", title, options)}]`; + } + return formatConsoleMessage(severity, `${fileMsgPart} ${message}`, options); +} diff --git a/incubator/tools-formatting/src/paths.ts b/incubator/tools-formatting/src/paths.ts index a284a37e2..4383a5f88 100644 --- a/incubator/tools-formatting/src/paths.ts +++ b/incubator/tools-formatting/src/paths.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { ELLIPSIS, SEPARATORS, SRC_DIRS } from "./const.ts"; /** @@ -36,3 +37,19 @@ export function shortenPath(path: string, segments = 3): string { } return path; } + +/** + * Normalize a path for display in messages so that links work correctly in GitHub and Azure DevOpts. This + * also makes them readable in normal logs as well as it normalizes them to forward slashes and makes them + * relative to the repo root if a root is provided. + * @param file The file path to normalize + * @param root The root directory to make the file path relative to, will default to process.cwd which may be less correct + * @returns The normalized file path + */ +export function normalizePath( + file: string, + root: string = process.cwd() +): string { + const filePath = path.relative(root, file).replaceAll("\\", "/"); + return path.posix.normalize(filePath); +} diff --git a/incubator/tools-formatting/src/reporters.ts b/incubator/tools-formatting/src/reporters.ts new file mode 100644 index 000000000..2c53d7d88 --- /dev/null +++ b/incubator/tools-formatting/src/reporters.ts @@ -0,0 +1,282 @@ +import { createAzureReporter } from "./azure.ts"; +import { REPORTER_ENV_KEY } from "./const.ts"; +import { createGitHubReporter } from "./github.ts"; +import { formatConsoleFileMessage, formatConsoleMessage } from "./messages.ts"; +import { formatAsTree } from "./trees.ts"; +import type { + BuiltinReporter, + FileMessage, + Reporter, + ReporterOption, + ReporterPropOverrides, + Severity, +} from "./types.ts"; + +/** + * Registry of built-in reporters and the default-reporter resolution logic. + * + * The package exposes a process-wide singleton (see {@link getReporterRegistry} + * and {@link setReporterRegistry}) which is what the top-level helpers like + * {@link getReporter} and {@link getDefaultReporter} delegate to. + * + * `ReporterRegistry` is intended to be subclassed when a consuming tool needs + * to: + * - register additional built-in reporter types — override {@link createReporter} + * and dispatch new names before falling through to `super.createReporter`, + * - add new CI provider detection — override {@link getDefaultReporterType} + * and fall through to `super.getDefaultReporterType` for the standard chain, + * - use a different environment-variable key for the explicit override — + * override the {@link envKey} property. + * + * Install your subclass with {@link setReporterRegistry} (typically at the + * entry point of your tool, before any reporting happens). + */ +export class ReporterRegistry { + /** + * Environment variable consulted by {@link getDefaultReporterType} to allow + * an explicit override of the auto-detected reporter. Subclasses may set + * this to a tool-specific key (e.g. `"MYTOOL_REPORTER"`). + */ + protected envKey: string = REPORTER_ENV_KEY; + + /** + * Cache of reporter instances keyed by their built-in name. Populated by + * {@link getReporter} when a name is resolved for the first time. Cleared + * by {@link reset}. + */ + protected readonly reporterCache = new Map(); + + /** + * Cached default reporter, populated by the first call to + * {@link getDefaultReporter}. Cleared by {@link reset}. + */ + protected cachedDefault: Reporter | null = null; + + /** + * Resolve a {@link ReporterOption} into a concrete {@link Reporter}: + * - a string is treated as a built-in name and looked up via + * {@link createReporter}; the resulting instance is cached so repeat lookups + * return the same object, + * - a `Reporter` object is returned as-is (never cached, since callers may + * pass anonymous instances), + * - `undefined` falls through to {@link getDefaultReporter}. + * + * Throws when a string name isn't recognized by {@link createReporter} or + * when the value isn't a string, reporter, or `undefined`. + */ + getReporter(reporter?: ReporterOption): Reporter { + if (reporter) { + if (typeof reporter === "string") { + let cached = this.reporterCache.get(reporter); + if (!cached) { + cached = this.createReporter(reporter); + this.reporterCache.set(reporter, cached); + } + return cached; + } + if (typeof reporter === "object" && "formatMessage" in reporter) { + return reporter; + } + throw new Error(`Invalid reporter option: ${String(reporter)}`); + } + return this.getDefaultReporter(); + } + + /** + * Get the default reporter for the current environment. The default is + * computed by passing the result of {@link getDefaultReporterType} through + * {@link getReporter}; the resulting instance is cached, so subsequent calls + * return the same object until {@link reset} is called. + */ + getDefaultReporter(): Reporter { + return (this.cachedDefault ??= this.getReporter( + this.getDefaultReporterType() + )); + } + + /** + * Construct a {@link Reporter} for the given built-in name. Subclasses + * should override this to add new reporter types; dispatch the new names + * first and call `super.createReporter(type, options)` for anything the + * subclass doesn't handle so the standard built-ins still resolve. + * + * @param type Reporter name. Built-in values are `"github"`, `"azure"`, + * `"console"`, and `"file"`. + * @param options Optional overrides applied to the resulting reporter + * (name, color, ASCII-only). + * @throws when `type` is not a recognized name. + */ + createReporter(type: string, options?: ReporterPropOverrides): Reporter { + switch (type) { + case "github": + return createGitHubReporter(options); + case "azure": + return createAzureReporter(options); + case "console": + case "file": + return createConsoleOrFileReporter(type, options); + default: + throw new Error(`Unknown reporter type: ${type}`); + } + } + + /** + * Determine which built-in reporter name to use when no explicit reporter + * is provided. The default implementation, in order: + * 1. returns the value of `process.env[this.envKey]` if set, + * 2. returns `"github"` when {@link isGitHubActions} is true, + * 3. returns `"azure"` when {@link isAzurePipelines} is true, + * 4. returns `"console"`. + * + * Subclasses can add additional CI providers by overriding this method and + * falling through to `super.getDefaultReporterType()` for the standard chain. + */ + getDefaultReporterType(): string { + const envReporter = process.env[this.envKey]; + if (envReporter) { + return envReporter; + } + if (isGitHubActions()) { + return "github"; + } + if (isAzurePipelines()) { + return "azure"; + } + return "console"; + } + + /** + * Drop every cached reporter, including the cached default. The next call + * to {@link getReporter} or {@link getDefaultReporter} will rebuild from + * scratch. Useful in tests that mutate `process.env`, or when a tool + * dynamically registers a new reporter after the default has already been + * resolved. + */ + reset(): void { + this.reporterCache.clear(); + this.cachedDefault = null; + } +} + +let singletonRegistry: ReporterRegistry | undefined; + +/** + * Get the process-wide singleton {@link ReporterRegistry}, constructing a + * default instance on first access. All top-level helpers (`getReporter`, + * `getDefaultReporter`, `formatMessage`, `formatFileMessage`, `formatGroup`) + * delegate to whichever instance this returns. + */ +export function getReporterRegistry(): ReporterRegistry { + return (singletonRegistry ??= new ReporterRegistry()); +} + +/** + * Replace the process-wide singleton {@link ReporterRegistry} — typically + * called once at the entry point of a tool that wants to register additional + * reporters or extend the default-resolution logic via a `ReporterRegistry` + * subclass. + * + * Pass `undefined` to drop the current instance; the next call to + * {@link getReporterRegistry} will create a fresh default. + * + * @param registry Registry to install as the singleton, or `undefined` to + * discard the current one. + */ +export function setReporterRegistry( + registry: ReporterRegistry | undefined +): void { + singletonRegistry = registry; +} + +/** + * Resolve a reporter via the singleton registry. See + * {@link ReporterRegistry.getReporter}. + */ +export function getReporter(reporter?: ReporterOption): Reporter { + return getReporterRegistry().getReporter(reporter); +} + +/** + * Get the cached default reporter via the singleton registry. See + * {@link ReporterRegistry.getDefaultReporter}. + */ +export function getDefaultReporter(): Reporter { + return getReporterRegistry().getDefaultReporter(); +} + +/** + * Determine the default reporter name for the current environment via the + * singleton registry. See {@link ReporterRegistry.getDefaultReporterType}. + */ +export function getDefaultReporterType(): string { + return getReporterRegistry().getDefaultReporterType(); +} + +/** + * Construct a fresh reporter for the given built-in name via the singleton + * registry. See {@link ReporterRegistry.createReporter}. Unlike + * {@link getReporter}, this never consults the registry's cache, so each + * call produces a new instance. + */ +export function createReporter( + type: string, + options?: ReporterPropOverrides +): Reporter { + return getReporterRegistry().createReporter(type, options); +} + +/** + * Build the shared implementation used by the `"console"` and `"file"` + * built-in reporters. Both delegate to the same formatters from + * `messages.ts` and `trees.ts`; they only differ in their default + * `noColors` value (`file` defaults to no colors so log files don't + * accumulate ANSI escape codes). + * + * @param type Either `"console"` or `"file"`. + * @param options Optional overrides for name / color / ASCII behavior. + * @returns A reporter that formats output for plain-text streams. + */ +export function createConsoleOrFileReporter( + type: Extract, + options?: ReporterPropOverrides +): Reporter { + const name = options?.name ?? type; + const noColors = options?.noColors ?? type === "file"; + const asciiOnly = options?.asciiOnly ?? false; + const base = { name, noColors, asciiOnly }; + + function formatMessage(severity: Severity, message: string): string { + return formatConsoleMessage(severity, message, base); + } + + function formatFileMessage( + severity: Severity, + fileMessage: FileMessage + ): string { + return formatConsoleFileMessage(severity, fileMessage, base); + } + + function formatGroup(header: string, children: string[]): string { + return formatAsTree(header, children, base); + } + + return Object.assign(base, { formatMessage, formatFileMessage, formatGroup }); +} + +/** + * Returns `true` when the current process is running inside a GitHub Actions + * job. Detected via the standard `GITHUB_ACTIONS` environment variable that + * the GitHub Actions runner sets to `"true"`. + */ +export function isGitHubActions(): boolean { + return process.env.GITHUB_ACTIONS === "true"; +} + +/** + * Returns `true` when the current process is running inside an Azure + * Pipelines job. Detected via the standard `TF_BUILD` environment variable + * that the Azure Pipelines agent sets to `"True"`. + */ +export function isAzurePipelines(): boolean { + return process.env.TF_BUILD === "True"; +} diff --git a/incubator/tools-formatting/src/table.ts b/incubator/tools-formatting/src/table.ts index a0d80b526..6cd55b4b3 100644 --- a/incubator/tools-formatting/src/table.ts +++ b/incubator/tools-formatting/src/table.ts @@ -106,6 +106,14 @@ type CellEntry = { key: string | number; }; +/** + * Resolve a user-provided {@link ColumnOptions} into a {@link ResolvedColumnOptions} + * with a label, an initial width (from the label), and a cached `Intl.NumberFormat` + * when locale-aware fixed-digit formatting was requested. + * @param config The column configuration as provided by the caller. + * @param index The 0-based column index, used to synthesize a label when one is missing. + * @returns The resolved column data used by subsequent layout passes. + */ function toColumnData( config: ColumnOptions, index: number @@ -123,6 +131,17 @@ function toColumnData( return { ...config, label, width, intlFormatter }; } +/** + * Convert a single cell value into a {@link CellEntry}: a styled text string, + * its visible width, and a sort key. Numeric values honor `digits` / + * `intlFormatter` from the column config; over-long text is truncated to + * `maxWidth` with an ellipsis. The column's running max width is updated + * in-place so downstream layout passes see the widest cell. + * @param value The raw value to render. + * @param config The resolved column config (mutated to track max width). + * @param noColors When true, ANSI control characters are stripped after styling. + * @returns The text/width/key entry for this cell. + */ function toCellData( value: unknown, config: ResolvedColumnOptions, @@ -159,6 +178,14 @@ function toCellData( return { text, width, key }; } +/** + * Convert a row of raw values into an array of {@link CellEntry}s by applying + * {@link toCellData} to each column. + * @param values The raw row values, in column order. + * @param columns The resolved column configurations. + * @param noColors When true, strip ANSI styling from each cell after rendering. + * @returns The processed row, ready for layout. + */ function toRowData( values: unknown[], columns: ResolvedColumnOptions[], diff --git a/incubator/tools-formatting/src/trees.ts b/incubator/tools-formatting/src/trees.ts index 8c63b49be..1fcc50bba 100644 --- a/incubator/tools-formatting/src/trees.ts +++ b/incubator/tools-formatting/src/trees.ts @@ -2,12 +2,11 @@ import { TREE_STYLES } from "./const.ts"; import type { TreeFormattingOptions } from "./types.ts"; /** - * Format a header and a list of rows into a tree-like string representation using the - * specified tree formatting options. + * Format grouped content for console (or file) output, using the specified tree formatting options. * @param header header text to display at the top of the tree * @param rows array of strings representing each row to display under the header * @param options tree formatting options to control the appearance of the tree - * @returns a string representing the formatted tree. There will be no trailing newline. + * @returns a multiline string representing the formatted tree. There will be no trailing newline. */ export function formatAsTree( header: string, @@ -16,21 +15,20 @@ export function formatAsTree( ): string { const { asciiOnly, treeParts } = options; const indent = resolveIndent(options.indent); + const result: string[] = [header]; const treeStyle = treeParts ?? (asciiOnly ? TREE_STYLES.ascii : TREE_STYLES.default); - let result = header; - for (let i = 0; i < rows.length; i++) { const isLast = i === rows.length - 1; const [branch, cont] = isLast ? treeStyle.last : treeStyle.row; - const lines = rows[i].split("\n"); + const lines = rows[i].split(/\r?\n/); for (let j = 0; j < lines.length; j++) { - result += "\n" + indent + (j === 0 ? branch : cont) + lines[j]; + result.push(indent + (j === 0 ? branch : cont) + lines[j]); } } - return result; + return result.join("\n"); } /** diff --git a/incubator/tools-formatting/src/types.ts b/incubator/tools-formatting/src/types.ts index a6df20562..d74f89805 100644 --- a/incubator/tools-formatting/src/types.ts +++ b/incubator/tools-formatting/src/types.ts @@ -1,4 +1,10 @@ import type { styleText } from "node:util"; +import type { BUILTIN_REPORTERS, SEVERITY_LEVELS } from "./const.ts"; + +/** + * The severity level of a message which is used to determine how it should be formatted and displayed. + */ +export type Severity = keyof typeof SEVERITY_LEVELS; /** * The type of value that can be passed to `styleText` to apply styling to text output. @@ -25,6 +31,74 @@ export type TextOptions = { asciiOnly?: boolean; }; +/** + * Payload for a file-related message + */ +export type FileMessage = { + /** + * Message to display for the given file. + */ + message: string; + + /** + * File path related to the message. Will be displayed as-is unless root is also set, in which case + * it will be made relative to the root path and normalized into the correct format for the environment. + */ + file: string; + + /** + * Repo root path. If provided file paths will be made relative to the repo root and normalized + * into the correct format such that links will work in GitHub and Azure DevOps. If not provided, + * for links to work correctly the file path should be: + * - relative to the repo root + * - posix normalized (forward slashes) + */ + root?: string; + + /** 1-based line number. */ + line?: number; + /** 1-based column number. */ + col?: number; + + /** 1-based end line number. Ignored on Azure Pipelines */ + endLine?: number; + /** 1-based end column number. Ignored on Azure Pipelines */ + endCol?: number; + /** Optional short title shown above the message in the GitHub Actions UI. */ + title?: string; +}; + +export type BuiltinReporter = (typeof BUILTIN_REPORTERS)[number]; + +/** + * A stylistic set of options for handling output formatting for particular targets. + */ +export type Reporter = ColorOptions & + TextOptions & { + /** name of the reporter, for convenience */ + readonly name: string; + + /** format an annotation message */ + formatMessage(severity: Severity, message: string): string; + + /** format a file-related message */ + formatFileMessage(severity: Severity, fileMessage: FileMessage): string; + + /** format a group of messages */ + formatGroup(header: string, children: string[]): string; + }; + +export type ReporterPropOverrides = Partial< + Pick +>; + +/** + * Specify a built-in reporter by name or a custom reporter instance to use for formatting output. + * If not specified, the default reporter will be used, which is determined based on environment variables + * and CI detection. + */ +export type ReporterOption = BuiltinReporter | Reporter | (string & {}); + /** * Tree formatting options */ diff --git a/incubator/tools-formatting/test/azure.test.ts b/incubator/tools-formatting/test/azure.test.ts new file mode 100644 index 000000000..516f71c2b --- /dev/null +++ b/incubator/tools-formatting/test/azure.test.ts @@ -0,0 +1,106 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createAzureReporter } from "../src/azure.ts"; + +const reporter = createAzureReporter(); + +describe("AzureReporter", () => { + describe("formatMessage", () => { + it("formats error and warning as Azure log issues", () => { + deepEqual( + [ + reporter.formatMessage("error", "message"), + reporter.formatMessage("warn", "message"), + ], + [ + "##vso[task.logissue type=error]message", + "##vso[task.logissue type=warning]message", + ] + ); + }); + + it("formats info as a console message", () => { + equal(reporter.formatMessage("info", "message"), "info: message"); + }); + + it("escapes Azure data", () => { + equal( + reporter.formatMessage("error", "100%\r\nmessage"), + "##vso[task.logissue type=error]100%AZP25%0D%0Amessage" + ); + }); + }); + + describe("formatFileMessage", () => { + const root = "/repo"; + const file = "/repo/src/file.ts"; + + it("formats a file-only annotation", () => { + equal( + reporter.formatFileMessage("error", { message: "message", file, root }), + "##vso[task.logissue type=error;sourcepath=src/file.ts]message" + ); + }); + + it("formats a file and line annotation", () => { + equal( + reporter.formatFileMessage("warn", { + message: "message", + file, + root, + line: 10, + }), + "##vso[task.logissue type=warning;sourcepath=src/file.ts;linenumber=10]message" + ); + }); + + it("formats a file, line, and column annotation", () => { + equal( + reporter.formatFileMessage("error", { + message: "message", + file, + root, + line: 10, + col: 2, + }), + "##vso[task.logissue type=error;sourcepath=src/file.ts;linenumber=10;columnnumber=2]message" + ); + }); + + it("ignores end location and title properties", () => { + equal( + reporter.formatFileMessage("error", { + message: "message", + file, + root, + line: 10, + col: 2, + endLine: 12, + endCol: 4, + title: "Title", + }), + "##vso[task.logissue type=error;sourcepath=src/file.ts;linenumber=10;columnnumber=2]message" + ); + }); + + it("escapes Azure data and properties", () => { + equal( + reporter.formatFileMessage("error", { + message: "100%\r\nmessage]", + file: "/repo/src/a;b].ts", + root, + }), + "##vso[task.logissue type=error;sourcepath=src/a%3Bb%5D.ts]100%AZP25%0D%0Amessage]" + ); + }); + }); + + describe("formatGroup", () => { + it("escapes special characters in the header", () => { + equal( + reporter.formatGroup("100%\r\nheader", ["a", "b"]), + "##[group]100%AZP25%0D%0Aheader\na\nb\n##[endgroup]" + ); + }); + }); +}); diff --git a/incubator/tools-formatting/test/core.test.ts b/incubator/tools-formatting/test/core.test.ts new file mode 100644 index 000000000..f9d140e92 --- /dev/null +++ b/incubator/tools-formatting/test/core.test.ts @@ -0,0 +1,75 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { formatFileMessage, formatGroup, formatMessage } from "../src/core.ts"; +import { getDefaultReporter } from "../src/reporters.ts"; +import type { FileMessage, Reporter, Severity } from "../src/types.ts"; + +const customReporter: Reporter = { + name: "custom", + noColors: true, + asciiOnly: true, + formatMessage: (severity: Severity, message: string) => + `message:${severity}:${message}`, + formatFileMessage: (severity: Severity, fileMessage: FileMessage) => + `file:${severity}:${fileMessage.file}:${fileMessage.message}`, + formatGroup: (header: string, children: string[]) => + `group:${header}:${children.join("|")}`, +}; + +describe("core", () => { + it("dispatches to the default reporter when no reporter is provided", () => { + const reporter = getDefaultReporter(); + const fileMessage = { message: "message", file: "src/file.ts", root: "" }; + + equal( + formatMessage("info", "message"), + reporter.formatMessage("info", "message") + ); + equal( + formatFileMessage("info", fileMessage), + reporter.formatFileMessage("info", fileMessage) + ); + equal( + formatGroup("Header", ["a", "b"]), + reporter.formatGroup("Header", ["a", "b"]) + ); + }); + + it("dispatches to a built-in reporter by name", () => { + deepEqual( + [ + formatMessage("error", "message", "azure"), + formatFileMessage( + "error", + { message: "message", file: "/repo/src/file.ts", root: "/repo" }, + "azure" + ), + formatGroup("Header", ["message"], "azure"), + ], + [ + "##vso[task.logissue type=error]message", + "##vso[task.logissue type=error;sourcepath=src/file.ts]message", + "##[group]Header\nmessage\n##[endgroup]", + ] + ); + }); + + it("dispatches to a custom reporter instance", () => { + deepEqual( + [ + formatMessage("warn", "message", customReporter), + formatFileMessage( + "warn", + { message: "message", file: "src/file.ts" }, + customReporter + ), + formatGroup("Header", ["a", "b"], customReporter), + ], + [ + "message:warn:message", + "file:warn:src/file.ts:message", + "group:Header:a|b", + ] + ); + }); +}); diff --git a/incubator/tools-formatting/test/github.test.ts b/incubator/tools-formatting/test/github.test.ts new file mode 100644 index 000000000..32369a2e7 --- /dev/null +++ b/incubator/tools-formatting/test/github.test.ts @@ -0,0 +1,101 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createGitHubReporter } from "../src/github.ts"; + +const reporter = createGitHubReporter(); + +describe("GitHubReporter", () => { + describe("formatMessage", () => { + it("formats every severity", () => { + deepEqual( + [ + reporter.formatMessage("error", "message"), + reporter.formatMessage("warn", "message"), + reporter.formatMessage("info", "message"), + ], + ["::error::message", "::warning::message", "::notice::message"] + ); + }); + + it("escapes data", () => { + equal( + reporter.formatMessage("error", "100%\r\nmessage"), + "::error::100%25%0D%0Amessage" + ); + }); + }); + + describe("formatFileMessage", () => { + const root = "/repo"; + const file = "/repo/src/file.ts"; + + it("formats a file-only annotation", () => { + equal( + reporter.formatFileMessage("error", { message: "message", file, root }), + "::error file=src/file.ts::message" + ); + }); + + it("formats a file and line annotation", () => { + equal( + reporter.formatFileMessage("warn", { + message: "message", + file, + root, + line: 10, + }), + "::warning file=src/file.ts,line=10::message" + ); + }); + + it("formats a file, line, and column annotation", () => { + equal( + reporter.formatFileMessage("info", { + message: "message", + file, + root, + line: 10, + col: 2, + }), + "::notice file=src/file.ts,line=10,col=2::message" + ); + }); + + it("formats all supported GitHub annotation properties", () => { + equal( + reporter.formatFileMessage("error", { + message: "message", + file, + root, + line: 10, + col: 2, + endLine: 12, + endCol: 4, + title: "Title", + }), + "::error file=src/file.ts,line=10,col=2,endLine=12,endColumn=4,title=Title::message" + ); + }); + + it("escapes data and properties", () => { + equal( + reporter.formatFileMessage("error", { + message: "100%\r\nmessage", + file: "/repo/src/a:b,c.ts", + root, + title: "a:b,c", + }), + "::error file=src/a%3Ab%2Cc.ts,title=a%3Ab%2Cc::100%25%0D%0Amessage" + ); + }); + }); + + describe("formatGroup", () => { + it("escapes special characters in the header", () => { + equal( + reporter.formatGroup("100%\r\nheader", ["a", "b"]), + "::group::100%25%0D%0Aheader\na\nb\n::endgroup::" + ); + }); + }); +}); diff --git a/incubator/tools-formatting/test/messages.test.ts b/incubator/tools-formatting/test/messages.test.ts new file mode 100644 index 000000000..360fa8ad6 --- /dev/null +++ b/incubator/tools-formatting/test/messages.test.ts @@ -0,0 +1,121 @@ +import { deepEqual, equal, notEqual } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { styleText as nodeStyleText } from "node:util"; +import { + colorText as styleText, + compareSeverity, + formatConsoleFileMessage, + formatConsoleMessage, +} from "../src/messages.ts"; +import type { Severity } from "../src/types.ts"; + +describe("messages", () => { + describe("formatConsoleMessage", () => { + it("formats messages without colors", () => { + equal( + formatConsoleMessage("error", "message", { noColors: true }), + "error: message" + ); + }); + + it("formats messages with colors", () => { + equal( + formatConsoleMessage("warn", "message", { noColors: false }), + `${nodeStyleText("yellow", "warn")}: message` + ); + }); + + it("passes unknown severities through", () => { + equal( + formatConsoleMessage("debug" as Severity, "message", { + noColors: true, + }), + "message" + ); + }); + }); + + describe("formatConsoleFileMessage", () => { + const root = "/repo"; + const file = "/repo/src/file.ts"; + + it("formats file-only messages", () => { + equal( + formatConsoleFileMessage( + "info", + { message: "message", file, root }, + { noColors: true } + ), + "info: src/file.ts: message" + ); + }); + + it("formats file and line messages", () => { + equal( + formatConsoleFileMessage( + "info", + { message: "message", file, root, line: 10 }, + { noColors: true } + ), + "info: src/file.ts:10: message" + ); + }); + + it("formats file, line, and column messages", () => { + equal( + formatConsoleFileMessage( + "info", + { message: "message", file, root, line: 10, col: 2 }, + { noColors: true } + ), + "info: src/file.ts:10:2: message" + ); + }); + + it("formats titled messages and ignores end positions", () => { + equal( + formatConsoleFileMessage( + "info", + { + message: "message", + file, + root, + line: 10, + col: 2, + endLine: 12, + endCol: 4, + title: "Title", + }, + { noColors: true } + ), + "info: src/file.ts:10:2: [Title] message" + ); + }); + }); + + describe("compareSeverity", () => { + it("compares all severity pairs", () => { + const severities: Severity[] = ["info", "warn", "error"]; + const results = severities.map((a) => + severities.map((b) => Math.sign(compareSeverity(a, b))) + ); + deepEqual(results, [ + [0, -1, -1], + [1, 0, -1], + [1, 1, 0], + ]); + }); + }); + + describe("styleText", () => { + it("respects noColors", () => { + equal(styleText("red", "message", { noColors: true }), "message"); + }); + + it("applies styles when colors are enabled", () => { + const styled = styleText("red", "message", { noColors: false }); + equal(styled, nodeStyleText("red", "message")); + notEqual(styled, ""); + }); + }); +}); diff --git a/incubator/tools-formatting/test/paths.test.ts b/incubator/tools-formatting/test/paths.test.ts index cde4a11fc..70e7a4766 100644 --- a/incubator/tools-formatting/test/paths.test.ts +++ b/incubator/tools-formatting/test/paths.test.ts @@ -1,6 +1,7 @@ import { equal } from "node:assert/strict"; +import path from "node:path"; import { describe, it } from "node:test"; -import { shortenPath } from "../src/paths.ts"; +import { normalizePath, shortenPath } from "../src/paths.ts"; describe("shortenPath", () => { it("shortens a long unix path to 3 segments", () => { @@ -105,3 +106,40 @@ describe("shortenPath", () => { ); }); }); + +describe("normalizePath", () => { + function expected(file: string, root?: string): string { + return path.posix.normalize( + path.relative(root ?? process.cwd(), file).replaceAll("\\", "/") + ); + } + + it("normalizes a POSIX path with a root", () => { + equal(normalizePath("/repo/src/file.ts", "/repo"), "src/file.ts"); + }); + + it("normalizes a POSIX path that is already relative", () => { + equal(normalizePath("src/file.ts", ""), "src/file.ts"); + }); + + it("normalizes paths with dot-dot segments", () => { + equal(normalizePath("/repo/src/../test/file.ts", "/repo"), "test/file.ts"); + }); + + it("normalizes Windows backslash paths without a root", () => { + const file = path.win32.join("src", "nested", "file.ts"); + equal(normalizePath(file, ""), expected(file, "")); + }); + + it("normalizes Windows backslash paths with a root", () => { + const root = path.win32.join("C:\\repo"); + const file = path.win32.join(root, "src", "file.ts"); + equal(normalizePath(file, root), expected(file, root)); + }); + + it("normalizes drive-letter paths", () => { + const root = path.win32.join("D:\\dev", "rnx-kit"); + const file = path.win32.join(root, "packages", "pkg", "src", "index.ts"); + equal(normalizePath(file, root), expected(file, root)); + }); +}); diff --git a/incubator/tools-formatting/test/reporters.test.ts b/incubator/tools-formatting/test/reporters.test.ts new file mode 100644 index 000000000..c463ebebe --- /dev/null +++ b/incubator/tools-formatting/test/reporters.test.ts @@ -0,0 +1,264 @@ +import { equal, ok, strictEqual, throws } from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { + ReporterRegistry, + createReporter, + getDefaultReporter, + getDefaultReporterType, + getReporter, + getReporterRegistry, + isAzurePipelines, + isGitHubActions, + setReporterRegistry, +} from "../src/reporters.ts"; +import type { Reporter, ReporterPropOverrides } from "../src/types.ts"; + +const savedEnv: Record = {}; +const envKeys = ["RNX_TARGET_REPORTER", "GITHUB_ACTIONS", "TF_BUILD"]; + +const customReporter: Reporter = { + name: "custom", + noColors: true, + asciiOnly: true, + formatMessage: (_severity, message) => message, + formatFileMessage: (_severity, fileMessage) => fileMessage.message, + formatGroup: (header, children) => [header, ...children].join("\n"), +}; + +describe("reporters", () => { + beforeEach(() => { + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + // Drop any cached singleton so env changes are observed by each test + setReporterRegistry(undefined); + }); + + afterEach(() => { + for (const key of envKeys) { + const value = savedEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + setReporterRegistry(undefined); + }); + + describe("getReporter", () => { + it("resolves a built-in reporter name", () => { + equal(getReporter("github").name, "github"); + }); + + it("returns a custom reporter instance as-is", () => { + strictEqual(getReporter(customReporter), customReporter); + }); + + it("uses the default reporter when undefined", () => { + strictEqual(getReporter(undefined), getDefaultReporter()); + }); + + it("throws for unknown reporter names", () => { + throws(() => getReporter("unknown"), /Unknown reporter type: unknown/); + }); + + it("caches built-in lookups so repeated calls return the same instance", () => { + strictEqual(getReporter("github"), getReporter("github")); + }); + }); + + describe("default reporter resolution", () => { + it("uses RNX_TARGET_REPORTER when set", () => { + process.env.RNX_TARGET_REPORTER = "azure"; + equal(getDefaultReporterType(), "azure"); + }); + + it("detects GitHub Actions", () => { + process.env.GITHUB_ACTIONS = "true"; + equal(getDefaultReporterType(), "github"); + }); + + it("detects Azure Pipelines", () => { + process.env.TF_BUILD = "True"; + equal(getDefaultReporterType(), "azure"); + }); + + it("falls back to console", () => { + equal(getDefaultReporterType(), "console"); + }); + + it("returns a cached default reporter instance", () => { + const first = getDefaultReporter(); + const second = getDefaultReporter(); + strictEqual(second, first); + ok(["azure", "console", "file", "github"].includes(first.name)); + }); + }); + + describe("CI detection", () => { + it("detects GitHub Actions only when GITHUB_ACTIONS is true", () => { + equal(isGitHubActions(), false); + process.env.GITHUB_ACTIONS = "false"; + equal(isGitHubActions(), false); + process.env.GITHUB_ACTIONS = "true"; + equal(isGitHubActions(), true); + }); + + it("detects Azure Pipelines only when TF_BUILD is True", () => { + equal(isAzurePipelines(), false); + process.env.TF_BUILD = "true"; + equal(isAzurePipelines(), false); + process.env.TF_BUILD = "True"; + equal(isAzurePipelines(), true); + }); + }); + + describe("createReporter", () => { + it("creates a fresh instance each call (no cache)", () => { + const a = createReporter("console"); + const b = createReporter("console"); + ok(a !== b); + }); + + it("applies property overrides", () => { + const r = createReporter("console", { name: "verbose", noColors: true }); + equal(r.name, "verbose"); + equal(r.noColors, true); + }); + + it("throws for unknown built-in names", () => { + throws(() => createReporter("missing"), /Unknown reporter type: missing/); + }); + }); + + describe("ReporterRegistry", () => { + it("can be instantiated and used directly without affecting the singleton", () => { + const local = new ReporterRegistry(); + const singletonGH = getReporter("github"); + const localGH = local.getReporter("github"); + // Both are valid github reporters, but they come from different caches + equal(localGH.name, "github"); + equal(singletonGH.name, "github"); + }); + + it("reset() clears cached default and named reporters", () => { + const r = new ReporterRegistry(); + const a = r.getReporter("github"); + const cached = r.getReporter("github"); + strictEqual(cached, a); + r.reset(); + const b = r.getReporter("github"); + ok(a !== b, "reporter cache should be cleared after reset"); + }); + + it("supports subclassing to register additional reporter types", () => { + const tcReporter: Reporter = { + name: "teamcity", + noColors: true, + asciiOnly: true, + formatMessage: (sev, msg) => + `##teamcity[message text='${msg}' status='${sev.toUpperCase()}']`, + formatFileMessage: (_sev, m) => m.message, + formatGroup: (h, children) => [h, ...children].join("\n"), + }; + + class CustomRegistry extends ReporterRegistry { + override createReporter( + type: string, + options?: ReporterPropOverrides + ): Reporter { + if (type === "teamcity") return tcReporter; + return super.createReporter(type, options); + } + } + + const reg = new CustomRegistry(); + strictEqual(reg.getReporter("teamcity"), tcReporter); + // Built-ins still resolve through super + equal(reg.getReporter("github").name, "github"); + }); + + it("supports subclassing to extend default-type resolution", () => { + class GitLabAwareRegistry extends ReporterRegistry { + override getDefaultReporterType(): string { + if (process.env.GITLAB_CI === "true") return "console"; // pretend mapping + return super.getDefaultReporterType(); + } + } + + const reg = new GitLabAwareRegistry(); + process.env.GITHUB_ACTIONS = "true"; + equal(reg.getDefaultReporterType(), "github"); + delete process.env.GITHUB_ACTIONS; + process.env.GITLAB_CI = "true"; + try { + equal(reg.getDefaultReporterType(), "console"); + } finally { + delete process.env.GITLAB_CI; + } + }); + + it("respects a custom envKey override on a subclass", () => { + class MyToolRegistry extends ReporterRegistry { + protected override envKey = "MYTOOL_REPORTER"; + } + + const reg = new MyToolRegistry(); + process.env.MYTOOL_REPORTER = "azure"; + // Standard key is ignored + process.env.RNX_TARGET_REPORTER = "github"; + try { + equal(reg.getDefaultReporterType(), "azure"); + } finally { + delete process.env.MYTOOL_REPORTER; + } + }); + }); + + describe("singleton management", () => { + it("getReporterRegistry lazily constructs a default instance", () => { + setReporterRegistry(undefined); + const a = getReporterRegistry(); + const b = getReporterRegistry(); + strictEqual(a, b); + ok(a instanceof ReporterRegistry); + }); + + it("setReporterRegistry overrides the singleton used by top-level helpers", () => { + const teamcityReporter: Reporter = { + name: "teamcity", + noColors: true, + asciiOnly: true, + formatMessage: (_s, m) => `[tc] ${m}`, + formatFileMessage: (_s, m) => m.message, + formatGroup: (h, c) => [h, ...c].join("\n"), + }; + + class CustomRegistry extends ReporterRegistry { + override createReporter( + type: string, + options?: ReporterPropOverrides + ): Reporter { + if (type === "teamcity") return teamcityReporter; + return super.createReporter(type, options); + } + } + + const custom = new CustomRegistry(); + setReporterRegistry(custom); + strictEqual(getReporterRegistry(), custom); + strictEqual(getReporter("teamcity"), teamcityReporter); + }); + + it("setReporterRegistry(undefined) restores a freshly-constructed default", () => { + const custom = new ReporterRegistry(); + setReporterRegistry(custom); + strictEqual(getReporterRegistry(), custom); + setReporterRegistry(undefined); + const next = getReporterRegistry(); + ok(next !== custom, "a new default instance should be created"); + }); + }); +}); diff --git a/incubator/tools-formatting/test/trees.test.ts b/incubator/tools-formatting/test/trees.test.ts index 67211c192..f70413b86 100644 --- a/incubator/tools-formatting/test/trees.test.ts +++ b/incubator/tools-formatting/test/trees.test.ts @@ -88,4 +88,15 @@ describe("formatAsTree", () => { // 2 spaces of indent + " " (last-row continuation) = 6 spaces before "b" equal(lines[2], " b"); }); + + it("handles CRLF line endings within a row without leaving stray \\r", () => { + const result = formatAsTree("Header", ["first\r\nsecond", "only"]); + const lines = result.split("\n"); + equal(lines[0], "Header"); + equal(lines[1], "├── first"); + equal(lines[2], "│ second"); + equal(lines[3], "└── only"); + // No stray carriage returns in the output + equal(result.includes("\r"), false); + }); });