diff --git a/packages/core/src/build-json-report-error.ts b/packages/core/src/build-json-report-error.ts index 7b1f166b4..a1615c6e1 100644 --- a/packages/core/src/build-json-report-error.ts +++ b/packages/core/src/build-json-report-error.ts @@ -1,6 +1,7 @@ import type { JsonReport, JsonReportMode } from "./types/index.js"; import { formatReactDoctorError, isReactDoctorError } from "./errors.js"; import { getErrorChainMessages } from "./format-error-chain.js"; +import { anonymizeSensitiveText } from "./utils/anonymize-sensitive-text.js"; interface BuildJsonReportErrorInput { version: string; @@ -28,30 +29,37 @@ const safeGetErrorChain = (error: unknown): string[] => { } }; +const anonymizeMessages = (messages: string[]): string[] => messages.map(anonymizeSensitiveText); + export const buildJsonReportError = (input: BuildJsonReportErrorInput): JsonReport => { - const chain = safeGetErrorChain(input.error); + const chain = anonymizeMessages(safeGetErrorChain(input.error)); const sentryEventId = input.sentryEventId ?? null; const errorPayload = isReactDoctorError(input.error) ? { - message: formatReactDoctorError(input.error), + message: anonymizeSensitiveText(formatReactDoctorError(input.error)), name: `ReactDoctorError(${input.error.reason._tag})`, chain, sentryEventId, } : input.error instanceof Error ? { - message: input.error.message || input.error.name || "Error", + message: anonymizeSensitiveText(input.error.message || input.error.name || "Error"), name: input.error.name || "Error", chain, sentryEventId, } - : { message: safeStringify(input.error), name: "Error", chain, sentryEventId }; + : { + message: anonymizeSensitiveText(safeStringify(input.error)), + name: "Error", + chain, + sentryEventId, + }; return { schemaVersion: 1, version: input.version, ok: false, - directory: input.directory, + directory: anonymizeSensitiveText(input.directory), mode: input.mode ?? "full", diff: null, projects: [], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8067efbe0..34f8cebda 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -80,6 +80,7 @@ export * from "./run-oxlint.js"; export * from "./summarize-diagnostics.js"; export * from "./validate-config-types.js"; export * from "./utils/assign-fix-groups.js"; +export * from "./utils/anonymize-sensitive-text.js"; export * from "./utils/build-rule-docs-url.js"; export * from "./utils/classify-package-role.js"; export * from "./utils/compute-config-fingerprint.js"; @@ -100,6 +101,7 @@ export * from "./utils/resolve-github-actions-score-metadata.js"; export * from "./utils/resolve-lint-batch-ordering.js"; export * from "./utils/resolve-scan-concurrency.js"; export * from "./utils/sort-diagnostics-stable.js"; +export * from "./utils/scrub-sensitive-paths.js"; export * from "./utils/to-relative-path.js"; export * from "./utils/warn-config-issue.js"; export * from "./runners/oxlint/capabilities.js"; diff --git a/packages/core/src/runners/oxlint/parse-output.ts b/packages/core/src/runners/oxlint/parse-output.ts index c6b21a3aa..3d7132fed 100644 --- a/packages/core/src/runners/oxlint/parse-output.ts +++ b/packages/core/src/runners/oxlint/parse-output.ts @@ -13,7 +13,7 @@ import { isMinifiedSource } from "../../utils/is-minified-source.js"; import { OxlintOutputUnparseable, ReactDoctorError } from "../../errors.js"; import { buildNoSecretsRecommendation } from "../../utils/build-no-secrets-recommendation.js"; import { appendReanimatedSharedValueHint } from "../../utils/append-reanimated-shared-value-hint.js"; -import { redactSensitiveText } from "../../utils/redact-sensitive-text.js"; +import { anonymizeSensitiveText } from "../../utils/anonymize-sensitive-text.js"; import { shouldSuppressLocalUseHookDiagnostic } from "./should-suppress-local-use-hook-diagnostic.js"; const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/; @@ -142,8 +142,8 @@ const cleanDiagnosticMessage = ( // diagnostic flows through — so it reaches neither the terminal, the // JSON report, nor the score API. return { - message: redactSensitiveText(cleaned.message), - help: redactSensitiveText(cleaned.help), + message: anonymizeSensitiveText(cleaned.message), + help: anonymizeSensitiveText(cleaned.help), }; }; @@ -223,7 +223,7 @@ const buildRelatedLocations = ( column: label.span.column ?? 0, offset: label.span.offset, length: label.span.length, - message: label.label ?? "", + message: anonymizeSensitiveText(label.label ?? ""), }); } return related; @@ -262,7 +262,7 @@ export const parseOxlintOutput = ( } catch { throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ - preview: stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS), + preview: anonymizeSensitiveText(stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)), }), }); } @@ -270,7 +270,7 @@ export const parseOxlintOutput = ( if (!isOxlintOutput(parsed)) { throw new ReactDoctorError({ reason: new OxlintOutputUnparseable({ - preview: stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS), + preview: anonymizeSensitiveText(stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)), }), }); } diff --git a/packages/core/src/utils/anonymize-sensitive-text.ts b/packages/core/src/utils/anonymize-sensitive-text.ts new file mode 100644 index 000000000..ed146d5cf --- /dev/null +++ b/packages/core/src/utils/anonymize-sensitive-text.ts @@ -0,0 +1,5 @@ +import { redactSensitiveText } from "./redact-sensitive-text.js"; +import { scrubSensitivePaths } from "./scrub-sensitive-paths.js"; + +export const anonymizeSensitiveText = (text: string): string => + redactSensitiveText(scrubSensitivePaths(text)); diff --git a/packages/core/src/utils/scrub-sensitive-paths.ts b/packages/core/src/utils/scrub-sensitive-paths.ts new file mode 100644 index 000000000..147935704 --- /dev/null +++ b/packages/core/src/utils/scrub-sensitive-paths.ts @@ -0,0 +1,19 @@ +import os from "node:os"; + +const HOME_DIRECTORY = os.homedir(); + +const USER_HOME_PATTERNS: ReadonlyArray = [ + /[A-Za-z]:[\\/]Users[\\/][^\\/]+/gi, + /(?:\/Users\/|\/home\/)[^/\\]+/gi, +]; + +export const scrubSensitivePaths = (text: string): string => { + let scrubbed = text; + if (HOME_DIRECTORY.length > 1) { + scrubbed = scrubbed.split(HOME_DIRECTORY).join("~"); + } + for (const pattern of USER_HOME_PATTERNS) { + scrubbed = scrubbed.replace(pattern, "~"); + } + return scrubbed; +}; diff --git a/packages/core/tests/parse-oxlint-output-redaction.test.ts b/packages/core/tests/parse-oxlint-output-redaction.test.ts new file mode 100644 index 000000000..be874dcad --- /dev/null +++ b/packages/core/tests/parse-oxlint-output-redaction.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vite-plus/test"; +import { formatReactDoctorError, isReactDoctorError } from "../src/errors.js"; +import { parseOxlintOutput } from "../src/runners/oxlint/parse-output.js"; +import { buildProject, TEST_ROOT_DIRECTORY } from "./helpers/oxlint-parse-harness.js"; + +const TOKEN = `ghp_${"a".repeat(36)}`; + +const parseFailureMessage = (stdout: string): string => { + try { + parseOxlintOutput(stdout, buildProject(), TEST_ROOT_DIRECTORY); + } catch (error) { + if (isReactDoctorError(error)) return formatReactDoctorError(error); + throw error; + } + throw new Error("Expected parseOxlintOutput to fail"); +}; + +describe("parseOxlintOutput redaction", () => { + it("redacts sensitive text from unparseable-output previews", () => { + const message = parseFailureMessage(`not json /Users/jane/project ${TOKEN}`); + + expect(message).toContain("~/project"); + expect(message).not.toContain("/Users/jane"); + expect(message).not.toContain(TOKEN); + }); + + it("redacts related-location labels", () => { + const stdout = JSON.stringify({ + diagnostics: [ + { + message: "Primary message", + code: "react(no-danger)", + severity: "error", + causes: [], + url: "", + help: "", + filename: "src/components/widget.tsx", + labels: [ + { label: "", span: { offset: 0, length: 1, line: 12, column: 3 } }, + { + label: `related /Users/jane/project ${TOKEN}`, + span: { offset: 2, length: 3, line: 13, column: 5 }, + }, + ], + related: [], + }, + ], + number_of_files: 1, + number_of_rules: 1, + }); + + const [diagnostic] = parseOxlintOutput(stdout, buildProject(), TEST_ROOT_DIRECTORY); + + expect(diagnostic.relatedLocations?.[0]?.message).toContain("~/project"); + expect(diagnostic.relatedLocations?.[0]?.message).not.toContain("/Users/jane"); + expect(diagnostic.relatedLocations?.[0]?.message).not.toContain(TOKEN); + }); +}); diff --git a/packages/react-doctor/src/cli/utils/anonymize-text.ts b/packages/react-doctor/src/cli/utils/anonymize-text.ts index 677459677..7d3e75da2 100644 --- a/packages/react-doctor/src/cli/utils/anonymize-text.ts +++ b/packages/react-doctor/src/cli/utils/anonymize-text.ts @@ -1,5 +1,4 @@ -import { isPlainObject, redactSensitiveText } from "@react-doctor/core"; -import { scrubSensitivePaths } from "./scrub-sensitive-text.js"; +import { anonymizeSensitiveText, isPlainObject } from "@react-doctor/core"; /** * Free-text fields can carry both a home-directory path (the OS username) and a @@ -8,8 +7,7 @@ import { scrubSensitivePaths } from "./scrub-sensitive-text.js"; * event scrubber ({@link scrubSentryEvent}) and metric scrubber * ({@link scrubSentryMetric}). */ -export const anonymizeText = (text: string): string => - redactSensitiveText(scrubSensitivePaths(text)); +export const anonymizeText = (text: string): string => anonymizeSensitiveText(text); /** * Recursively rewrites every string within an arbitrary value (object / array / diff --git a/packages/react-doctor/src/cli/utils/ci/github-actions-provider.ts b/packages/react-doctor/src/cli/utils/ci/github-actions-provider.ts index 9ba4a7c59..ede5fb1ea 100644 --- a/packages/react-doctor/src/cli/utils/ci/github-actions-provider.ts +++ b/packages/react-doctor/src/cli/utils/ci/github-actions-provider.ts @@ -287,27 +287,32 @@ const findActionWorkflowFile = (projectRoot: string): CiWorkflowFile | null => { try { const content = fs.readFileSync(path.join(workflowsDir, entry), "utf8"); if (containsReactDoctor(content)) return { path: path.join(workflowsDir, entry), content }; - } catch {} + } catch { + continue; + } } return null; }; -// The canonical `react-doctor.yml` when present (it's ours, so edit it), else -// the first other workflow that wires up the action — so `ci config` / -// `ci upgrade` manage the step wherever the user put it. -const readWorkflow = (projectRoot: string): CiWorkflowFile | null => { +const readCanonicalWorkflow = (projectRoot: string): CiWorkflowFile | null => { const canonical = readReactDoctorWorkflow(projectRoot); - if (canonical) return { path: canonical.workflowPath, content: canonical.content }; - return findActionWorkflowFile(projectRoot); + if (canonical === null || !containsReactDoctor(canonical.content)) return null; + return { path: canonical.workflowPath, content: canonical.content }; +}; + +// Prefer the canonical workflow when it actually wires up React Doctor, else +// manage the action wherever the user put the step. +const readWorkflow = (projectRoot: string): CiWorkflowFile | null => { + return readCanonicalWorkflow(projectRoot) ?? findActionWorkflowFile(projectRoot); }; -// Reports "exists" when the action is already wired up anywhere (or our -// canonical file is present), so `ci install` never adds a second workflow. +// Reports "exists" only when the action is already wired up, so a stale empty +// canonical file doesn't mask the workflow users actually run. const scaffold = (projectRoot: string, defaultBranch: string, gate: CiGate): CiScaffoldResult => { const existing = readWorkflow(projectRoot); if (existing) return { status: "exists", path: existing.path }; const workflowPath = getReactDoctorWorkflowPath(projectRoot); - if (fs.existsSync(workflowPath)) return { status: "exists", path: workflowPath }; + if (fs.existsSync(workflowPath)) return { status: "failed", path: workflowPath }; try { fs.mkdirSync(path.dirname(workflowPath), { recursive: true }); fs.writeFileSync(workflowPath, buildGithubWorkflow(defaultBranch, gate, "v2")); diff --git a/packages/react-doctor/src/cli/utils/ci/manage-ci.ts b/packages/react-doctor/src/cli/utils/ci/manage-ci.ts index 7b5ca2843..784e72ce4 100644 --- a/packages/react-doctor/src/cli/utils/ci/manage-ci.ts +++ b/packages/react-doctor/src/cli/utils/ci/manage-ci.ts @@ -57,6 +57,11 @@ const resolveProjectRoot = (options: CiCommandOptions): string => { const setupDocsUrl = (provider: CiProvider): string => provider.id === "github-actions" ? GITHUB_ACTIONS_SETUP_URL : CI_URL; +const logMissingWorkflow = (provider: CiProvider): void => { + logger.error(`No ${provider.displayName} workflow found.`); + logger.dim(` Run ${highlighter.info("react-doctor ci install")} to add one first.`); +}; + // Resolves the CI backend: an explicit `--provider`, else autodetection, else a // prompt (or GitHub Actions when prompts are off). Returns null when the user // passed an unknown id (after reporting it) so the caller stops cleanly. @@ -295,6 +300,7 @@ export const runCiInstall = async (options: CiCommandOptions = {}): Promise const workflow = provider.readWorkflow(projectRoot); if (workflow === null) { - logger.error(`No ${provider.displayName} workflow found.`); - logger.dim(` Run ${highlighter.info("react-doctor ci install")} to add one first.`); + logMissingWorkflow(provider); process.exitCode = 1; return; } diff --git a/packages/react-doctor/src/cli/utils/handle-error.ts b/packages/react-doctor/src/cli/utils/handle-error.ts index 79a909d64..e71c91a00 100644 --- a/packages/react-doctor/src/cli/utils/handle-error.ts +++ b/packages/react-doctor/src/cli/utils/handle-error.ts @@ -13,6 +13,8 @@ import { VERSION } from "./version.js"; import { METRIC } from "./constants.js"; import { formatEnvironmentError, isEnvironmentError } from "./is-environment-error.js"; import { recordCount } from "./record-metric.js"; +import { anonymizeText } from "./anonymize-text.js"; +import { buildRunContext } from "./build-run-context.js"; // `shouldExit` is optional here (defaults to exiting) and the CLI adds a Sentry // event id, surfaced as a reference the user can quote so we can locate the @@ -37,17 +39,22 @@ interface ErrorReportContext { const formatErrorForReport = (error: unknown): string => isReactDoctorError(error) ? formatReactDoctorError(error) : formatErrorChain(error); +const formatErrorForIssue = (error: unknown): string => anonymizeText(formatErrorForReport(error)); + const formatSingleLine = (text: string): string => text.replaceAll(/\s+/g, " ").trim(); -const getErrorReportContext = (): ErrorReportContext => ({ - cwd: process.cwd(), - command: process.argv.join(" "), - nodeVersion: process.version, - platform: process.platform, - architecture: process.arch, - isOtlpEndpointConfigured: Boolean(process.env[OTLP_ENDPOINT_ENVIRONMENT_VARIABLE]), - isOtlpAuthHeaderConfigured: Boolean(process.env[OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE]), -}); +const getErrorReportContext = (): ErrorReportContext => { + const runContext = buildRunContext(); + return { + cwd: anonymizeText(runContext.cwd), + command: anonymizeText(runContext.argv), + nodeVersion: runContext.node, + platform: process.platform, + architecture: process.arch, + isOtlpEndpointConfigured: Boolean(process.env[OTLP_ENDPOINT_ENVIRONMENT_VARIABLE]), + isOtlpAuthHeaderConfigured: Boolean(process.env[OTLP_AUTH_HEADER_ENVIRONMENT_VARIABLE]), + }; +}; const formatConfiguredState = (isConfigured: boolean): string => (isConfigured ? "yes" : "no"); @@ -56,7 +63,7 @@ const buildErrorIssueBody = ( context: ErrorReportContext, sentryEventId: string | undefined, ): string => { - const formattedError = formatErrorForReport(error) || "(empty error)"; + const formattedError = formatErrorForIssue(error) || "(empty error)"; const isOtlpExporterEnabled = context.isOtlpEndpointConfigured && context.isOtlpAuthHeaderConfigured; @@ -90,7 +97,7 @@ const buildErrorIssueBody = ( }; export const buildErrorIssueUrl = (error: unknown, sentryEventId?: string): string => { - const formattedError = formatSingleLine(formatErrorForReport(error)); + const formattedError = formatSingleLine(formatErrorForIssue(error)); const issueUrl = new URL(`${CANONICAL_GITHUB_URL}/issues/new`); issueUrl.searchParams.set("title", formattedError ? `CLI error: ${formattedError}` : "CLI error"); issueUrl.searchParams.set("labels", "bug"); diff --git a/packages/react-doctor/src/cli/utils/scrub-sensitive-text.ts b/packages/react-doctor/src/cli/utils/scrub-sensitive-text.ts index 75f961f7f..a6a85a88c 100644 --- a/packages/react-doctor/src/cli/utils/scrub-sensitive-text.ts +++ b/packages/react-doctor/src/cli/utils/scrub-sensitive-text.ts @@ -1,37 +1 @@ -import os from "node:os"; - -// The current user's home directory, replaced wholesale so absolute paths in -// telemetry (cwd, argv, stack frames, span attributes) don't carry the OS -// username — the primary personal identifier that leaks through file paths. -const HOME_DIRECTORY = os.homedir(); - -// Generic user-home roots, matched case-insensitively with either slash so a -// path belonging to another user — or captured when `os.homedir()` resolution -// fails — is still anonymized. Each captures the `:\Users|/Users|/home` -// root plus the username segment and collapses the lot to `~`. The Windows -// pattern runs first so the POSIX `/Users/` rule doesn't partially rewrite a -// forward-slash Windows path (`C:/Users/`) into `C:~`. -const USER_HOME_PATTERNS: ReadonlyArray = [ - /[A-Za-z]:[\\/]Users[\\/][^\\/]+/gi, - /(?:\/Users\/|\/home\/)[^/\\]+/gi, -]; - -/** - * Replaces the user's home directory (and generic `/Users|/home|C:\Users\` - * roots) with `~` so absolute paths can't be tied back to an individual. Keeps - * the path's relative structure intact, which stays useful for debugging while - * dropping the personally-identifying prefix. Idempotent — re-running on an - * already-scrubbed `~/...` path is a no-op. - */ -export const scrubSensitivePaths = (text: string): string => { - let scrubbed = text; - // Exact home directory first — covers non-standard roots (e.g. `/root`, - // custom `$HOME`) that the generic patterns below don't anticipate. - if (HOME_DIRECTORY.length > 1) { - scrubbed = scrubbed.split(HOME_DIRECTORY).join("~"); - } - for (const pattern of USER_HOME_PATTERNS) { - scrubbed = scrubbed.replace(pattern, "~"); - } - return scrubbed; -}; +export { scrubSensitivePaths } from "@react-doctor/core"; diff --git a/packages/react-doctor/src/cli/utils/scrub-sentry-event.ts b/packages/react-doctor/src/cli/utils/scrub-sentry-event.ts index dd78729c1..fa78a6e69 100644 --- a/packages/react-doctor/src/cli/utils/scrub-sentry-event.ts +++ b/packages/react-doctor/src/cli/utils/scrub-sentry-event.ts @@ -1,6 +1,5 @@ import type { Event } from "@sentry/node"; import { anonymizeInPlace, anonymizeText } from "./anonymize-text.js"; -import { scrubSensitivePaths } from "./scrub-sensitive-text.js"; /** * Anonymizes a Sentry event (error or transaction) before it leaves the @@ -42,11 +41,9 @@ export const scrubSentryEvent = (event: T): T | null => { // Local variables can hold arbitrary user data we can't reliably // anonymize, so drop them outright rather than risk a leak. delete frame.vars; - if (typeof frame.filename === "string") - frame.filename = scrubSensitivePaths(frame.filename); - if (typeof frame.abs_path === "string") - frame.abs_path = scrubSensitivePaths(frame.abs_path); - if (typeof frame.module === "string") frame.module = scrubSensitivePaths(frame.module); + if (typeof frame.filename === "string") frame.filename = anonymizeText(frame.filename); + if (typeof frame.abs_path === "string") frame.abs_path = anonymizeText(frame.abs_path); + if (typeof frame.module === "string") frame.module = anonymizeText(frame.module); } } diff --git a/packages/react-doctor/tests/build-json-report.test.ts b/packages/react-doctor/tests/build-json-report.test.ts index 992c44dec..96da8dd7c 100644 --- a/packages/react-doctor/tests/build-json-report.test.ts +++ b/packages/react-doctor/tests/build-json-report.test.ts @@ -181,6 +181,26 @@ describe("buildJsonReportError", () => { }); }); + it("scrubs sensitive paths and secrets from error reports", () => { + const token = `ghp_${"a".repeat(36)}`; + const report = buildJsonReportError({ + version: "1.0.0", + directory: "/Users/jane/project", + error: new Error(`failed in /Users/jane/project with ${token}`, { + cause: new Error(`root at /Users/jane/project/src/app.ts ${token}`), + }), + elapsedMilliseconds: 50, + }); + + expect(report.directory).toBe("~/project"); + expect(report.error?.message).toContain("~/project"); + expect(report.error?.message).not.toContain("/Users/jane"); + expect(report.error?.message).not.toContain(token); + expect(report.error?.chain.join("\n")).toContain("~/project"); + expect(report.error?.chain.join("\n")).not.toContain("/Users/jane"); + expect(report.error?.chain.join("\n")).not.toContain(token); + }); + it("preserves the cause chain of nested errors", () => { const root = new Error("root cause"); const middle = new Error("middle layer", { cause: root }); diff --git a/packages/react-doctor/tests/ci-providers.test.ts b/packages/react-doctor/tests/ci-providers.test.ts index 559d57170..af2c4f5dc 100644 --- a/packages/react-doctor/tests/ci-providers.test.ts +++ b/packages/react-doctor/tests/ci-providers.test.ts @@ -254,6 +254,33 @@ describe("githubActionsProvider scaffold", () => { "exists", ); }); + + it("prefers a real action step over a stale empty canonical workflow", () => { + const workflowsDir = path.join(project.root, ".github", "workflows"); + fs.mkdirSync(workflowsDir, { recursive: true }); + fs.writeFileSync(path.join(workflowsDir, "react-doctor.yml"), "name: React Doctor\njobs: {}\n"); + fs.writeFileSync( + path.join(workflowsDir, "ci.yml"), + ["jobs:", " build:", " steps:", " - uses: millionco/react-doctor@v2", ""].join("\n"), + ); + + const found = githubActionsProvider.readWorkflow(project.root); + expect(found?.path.endsWith("ci.yml")).toBe(true); + expect(githubActionsProvider.scaffold(project.root, "main", ADVISORY_GATE).status).toBe( + "exists", + ); + }); + + it("does not report an empty canonical workflow as configured", () => { + const workflowsDir = path.join(project.root, ".github", "workflows"); + fs.mkdirSync(workflowsDir, { recursive: true }); + fs.writeFileSync(path.join(workflowsDir, "react-doctor.yml"), "name: React Doctor\njobs: {}\n"); + + expect(githubActionsProvider.readWorkflow(project.root)).toBeNull(); + expect(githubActionsProvider.scaffold(project.root, "main", ADVISORY_GATE).status).toBe( + "failed", + ); + }); }); describe("gitlabCiProvider", () => { @@ -407,7 +434,13 @@ describe("detectCiProvider", () => { fs.mkdirSync(path.join(project.root, ".github", "workflows"), { recursive: true }); fs.writeFileSync( path.join(project.root, ".github", "workflows", "react-doctor.yml"), - "name: React Doctor\n", + [ + "jobs:", + " react-doctor:", + " steps:", + " - uses: millionco/react-doctor@v2", + "", + ].join("\n"), ); expect(await detectCiProvider(project.root, runner(succeed("git@gitlab.com:o/r.git")))).toBe( "github-actions", diff --git a/packages/react-doctor/tests/handle-error.test.ts b/packages/react-doctor/tests/handle-error.test.ts index 3901f54a0..e21a9ec21 100644 --- a/packages/react-doctor/tests/handle-error.test.ts +++ b/packages/react-doctor/tests/handle-error.test.ts @@ -57,6 +57,39 @@ describe("handleError", () => { expect(body).toContain("Sentry reference: evt-abc123"); }); + it("scrubs home paths and secrets from prefilled issue text", () => { + const originalArgv = process.argv; + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/Users/jane/project"); + const token = `ghp_${"a".repeat(36)}`; + process.argv = [ + "/usr/local/bin/node", + "/usr/local/bin/react-doctor", + "inspect", + "/Users/jane/project", + "--token", + token, + ]; + + try { + const issueUrl = new URL( + buildErrorIssueUrl(new Error(`failed in /Users/jane/project ${token}`)), + ); + const body = issueUrl.searchParams.get("body") ?? ""; + const title = issueUrl.searchParams.get("title") ?? ""; + + expect(title).toContain("~/project"); + expect(title).not.toContain("/Users/jane"); + expect(title).not.toContain(token); + expect(body).toContain("- cwd: ~/project"); + expect(body).toContain("inspect ~/project --token"); + expect(body).not.toContain("/Users/jane"); + expect(body).not.toContain(token); + } finally { + process.argv = originalArgv; + cwdSpy.mockRestore(); + } + }); + it("omits the Sentry reference line when no event id is provided", () => { const body = new URL(buildErrorIssueUrl(new Error("boom"))).searchParams.get("body") ?? ""; expect(body).not.toContain("Sentry reference:"); diff --git a/packages/react-doctor/tests/manage-ci.test.ts b/packages/react-doctor/tests/manage-ci.test.ts index 93ff656cc..e035b8536 100644 --- a/packages/react-doctor/tests/manage-ci.test.ts +++ b/packages/react-doctor/tests/manage-ci.test.ts @@ -78,6 +78,18 @@ describe("runCiInstall", () => { expect(githubActionsProvider.readWorkflow(project.root)).toBeNull(); }); + it("exits non-zero when the canonical workflow path is occupied by another file", async () => { + fs.mkdirSync(path.dirname(githubActionsProvider.workflowPath(project.root)), { + recursive: true, + }); + fs.writeFileSync(githubActionsProvider.workflowPath(project.root), "name: Not React Doctor\n"); + + await runCiInstall(baseOptions({ provider: "github-actions" })); + + expect(process.exitCode).toBe(1); + expect(githubActionsProvider.readWorkflow(project.root)).toBeNull(); + }); + it("scaffolds a GitLab merge-request job", async () => { await runCiInstall(baseOptions({ provider: "gitlab-ci" })); const content = fs.readFileSync(path.join(project.root, ".gitlab-ci.yml"), "utf8");