From 1ad47b8fa1780b9eb545518e4380f6ce64111d8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Mar 2026 22:46:37 +0000 Subject: [PATCH] test: harden path resolution test helpers --- .github/workflows/workflow-sanity.yml | 3 + package.json | 3 +- scripts/check-no-conflict-markers.mjs | 80 +++++++++++++++++++ ...command-secret-resolution.coverage.test.ts | 33 +------- src/cli/command-source.test-helpers.test.ts | 63 +++++++++++++++ src/cli/command-source.test-helpers.ts | 50 ++++++++++++ src/config/sessions/targets.test.ts | 16 +++- src/plugins/source-display.test.ts | 22 ++--- src/test-utils/env.test.ts | 64 ++++++++++++++- src/test-utils/env.ts | 66 +++++++++++++++ .../scripts/check-no-conflict-markers.test.ts | 64 +++++++++++++++ 11 files changed, 412 insertions(+), 52 deletions(-) create mode 100644 scripts/check-no-conflict-markers.mjs create mode 100644 src/cli/command-source.test-helpers.test.ts create mode 100644 src/cli/command-source.test-helpers.ts create mode 100644 test/scripts/check-no-conflict-markers.test.ts diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index a2e6bb74e01..5c6bee04a14 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -72,6 +72,9 @@ jobs: - name: Disallow direct inputs interpolation in composite run blocks run: python3 scripts/check-composite-action-input-interpolation.py + - name: Disallow tracked merge conflict markers + run: node scripts/check-no-conflict-markers.mjs + generated-doc-baselines: if: github.event_name == 'workflow_dispatch' runs-on: blacksmith-16vcpu-ubuntu-2404 diff --git a/package.json b/package.json index b76d3174301..6b8d2a00c3b 100644 --- a/package.json +++ b/package.json @@ -571,13 +571,14 @@ "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm check:host-env-policy:swift && pnpm check:base-config-schema && pnpm check:bundled-plugin-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:plugins:plugin-sdk-subpaths-exported && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:extensions:no-relative-outside-package && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", + "check": "pnpm check:host-env-policy:swift && pnpm check:base-config-schema && pnpm check:bundled-plugin-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm check:no-conflict-markers && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:plugins:plugin-sdk-subpaths-exported && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:extensions:no-relative-outside-package && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", "check:base-config-schema": "node --import tsx scripts/generate-base-config-schema.ts --check", "check:bundled-plugin-metadata": "node scripts/generate-bundled-plugin-metadata.mjs --check", "check:bundled-provider-auth-env-vars": "node scripts/generate-bundled-provider-auth-env-vars.mjs --check", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", + "check:no-conflict-markers": "node scripts/check-no-conflict-markers.mjs", "config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check", "config:docs:gen": "node --import tsx scripts/generate-config-doc-baseline.ts --write", "config:schema:check": "node --import tsx scripts/generate-base-config-schema.ts --check", diff --git a/scripts/check-no-conflict-markers.mjs b/scripts/check-no-conflict-markers.mjs new file mode 100644 index 00000000000..2e540bfbf93 --- /dev/null +++ b/scripts/check-no-conflict-markers.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { runAsScript } from "./lib/ts-guard-utils.mjs"; + +function isBinaryBuffer(buffer) { + return buffer.includes(0); +} + +export function findConflictMarkerLines(content) { + const lines = content.split(/\r?\n/u); + const matches = []; + for (const [index, line] of lines.entries()) { + if ( + line.startsWith("<<<<<<< ") || + line.startsWith("||||||| ") || + line === "=======" || + line.startsWith(">>>>>>> ") + ) { + matches.push(index + 1); + } + } + return matches; +} + +export function listTrackedFiles(cwd = process.cwd()) { + const output = execFileSync("git", ["ls-files", "-z"], { + cwd, + encoding: "utf8", + }); + return output + .split("\0") + .filter(Boolean) + .map((relativePath) => path.join(cwd, relativePath)); +} + +export function findConflictMarkersInFiles(filePaths, readFile = fs.readFileSync) { + const violations = []; + for (const filePath of filePaths) { + let content; + try { + content = readFile(filePath); + } catch { + continue; + } + if (!Buffer.isBuffer(content)) { + content = Buffer.from(String(content)); + } + if (isBinaryBuffer(content)) { + continue; + } + const lines = findConflictMarkerLines(content.toString("utf8")); + if (lines.length > 0) { + violations.push({ + filePath, + lines, + }); + } + } + return violations; +} + +export async function main() { + const cwd = process.cwd(); + const violations = findConflictMarkersInFiles(listTrackedFiles(cwd)); + if (violations.length === 0) { + return; + } + + console.error("Found unresolved merge conflict markers:"); + for (const violation of violations) { + const relativePath = path.relative(cwd, violation.filePath) || violation.filePath; + console.error(`- ${relativePath}:${violation.lines.join(",")}`); + } + process.exitCode = 1; +} + +runAsScript(import.meta.url, main); diff --git a/src/cli/command-secret-resolution.coverage.test.ts b/src/cli/command-secret-resolution.coverage.test.ts index a3a08f794e2..192a48ab3b6 100644 --- a/src/cli/command-secret-resolution.coverage.test.ts +++ b/src/cli/command-secret-resolution.coverage.test.ts @@ -1,6 +1,5 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { describe, expect, it } from "vitest"; +import { readCommandSource } from "./command-source.test-helpers.js"; const SECRET_TARGET_CALLSITES = [ "src/cli/memory-cli.ts", @@ -14,36 +13,6 @@ const SECRET_TARGET_CALLSITES = [ "src/commands/status.scan.ts", ] as const; -async function readCommandSource(relativePath: string): Promise { - const absolutePath = path.join(process.cwd(), relativePath); - const source = await fs.readFile(absolutePath, "utf8"); - const reexportMatch = source.match(/^export \* from "(?[^"]+)";$/m)?.groups?.target; - const runtimeImportMatch = source.match(/import\("(?\.[^"]+\.runtime\.js)"\)/m)?.groups - ?.target; - if (runtimeImportMatch) { - const resolvedTarget = path.join(path.dirname(absolutePath), runtimeImportMatch); - const tsResolvedTarget = resolvedTarget.replace(/\.js$/u, ".ts"); - const runtimeSource = await fs.readFile(tsResolvedTarget, "utf8"); - return `${source}\n${runtimeSource}`; - } - if (!reexportMatch) { - if (source.includes("resolveCommandSecretRefsViaGateway")) { - return source; - } - const runtimeImportMatch = source.match(/import\("(?\.[^"]+\.runtime\.js)"\)/m)?.groups - ?.target; - if (!runtimeImportMatch) { - return source; - } - const resolvedTarget = path.join(path.dirname(absolutePath), runtimeImportMatch); - const tsResolvedTarget = resolvedTarget.replace(/\.js$/u, ".ts"); - return await fs.readFile(tsResolvedTarget, "utf8"); - } - const resolvedTarget = path.join(path.dirname(absolutePath), reexportMatch); - const tsResolvedTarget = resolvedTarget.replace(/\.js$/u, ".ts"); - return await fs.readFile(tsResolvedTarget, "utf8"); -} - function hasSupportedTargetIdsWiring(source: string): boolean { return ( /targetIds:\s*get[A-Za-z0-9_]+\(\)/m.test(source) || diff --git a/src/cli/command-source.test-helpers.test.ts b/src/cli/command-source.test-helpers.test.ts new file mode 100644 index 00000000000..f4857527ffd --- /dev/null +++ b/src/cli/command-source.test-helpers.test.ts @@ -0,0 +1,63 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readCommandSource } from "./command-source.test-helpers.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-command-source-")); + tempDirs.push(dir); + return dir; +} + +describe("readCommandSource", () => { + it("follows re-export shims and runtime boundaries", async () => { + const rootDir = makeTempDir(); + const cliDir = path.join(rootDir, "src", "cli"); + fs.mkdirSync(cliDir, { recursive: true }); + fs.writeFileSync(path.join(cliDir, "index.ts"), 'export * from "./command.js";\n'); + fs.writeFileSync( + path.join(cliDir, "command.ts"), + [ + "async function loadRuntime() {", + ' return await import("./command.runtime.js");', + "}", + "export { loadRuntime };", + ].join("\n"), + ); + fs.writeFileSync( + path.join(cliDir, "command.runtime.ts"), + 'export const marker = "resolveCommandSecretRefsViaGateway";\n', + ); + + const source = await readCommandSource("src/cli/index.ts", rootDir); + + expect(source).toContain('export * from "./command.js";'); + expect(source).toContain('import("./command.runtime.js")'); + expect(source).toContain("resolveCommandSecretRefsViaGateway"); + }); + + it("dedupes repeated runtime imports", async () => { + const rootDir = makeTempDir(); + const cliDir = path.join(rootDir, "src", "cli"); + fs.mkdirSync(cliDir, { recursive: true }); + fs.writeFileSync( + path.join(cliDir, "command.ts"), + ['await import("./shared.runtime.js");', 'await import("./shared.runtime.js");'].join("\n"), + ); + fs.writeFileSync(path.join(cliDir, "shared.runtime.ts"), "export const shared = true;\n"); + + const source = await readCommandSource("src/cli/command.ts", rootDir); + const occurrences = source.match(/export const shared = true;/gu) ?? []; + + expect(occurrences).toHaveLength(1); + }); +}); diff --git a/src/cli/command-source.test-helpers.ts b/src/cli/command-source.test-helpers.ts new file mode 100644 index 00000000000..c8eef939928 --- /dev/null +++ b/src/cli/command-source.test-helpers.ts @@ -0,0 +1,50 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +function resolveImportedTypeScriptPath(importerPath: string, target: string): string { + const resolvedTarget = path.join(path.dirname(importerPath), target); + return resolvedTarget.replace(/\.js$/u, ".ts"); +} + +async function readModuleSource(modulePath: string, seen: Set): Promise { + const resolvedPath = path.resolve(modulePath); + if (seen.has(resolvedPath)) { + return ""; + } + seen.add(resolvedPath); + + const source = await fs.readFile(resolvedPath, "utf8"); + if (source.includes("resolveCommandSecretRefsViaGateway")) { + return source; + } + const nestedTargets = new Set(); + + for (const match of source.matchAll(/^export \* from "(?[^"]+)";$/gmu)) { + const target = match.groups?.target; + if (target) { + nestedTargets.add(resolveImportedTypeScriptPath(resolvedPath, target)); + } + } + + for (const match of source.matchAll(/import\("(?\.[^"]+\.runtime\.js)"\)/gmu)) { + const target = match.groups?.target; + if (target) { + nestedTargets.add(resolveImportedTypeScriptPath(resolvedPath, target)); + } + } + + const nestedSources = ( + await Promise.all( + [...nestedTargets].map(async (targetPath) => await readModuleSource(targetPath, seen)), + ) + ).filter(Boolean); + + return nestedSources.length > 0 ? [source, ...nestedSources].join("\n") : source; +} + +export async function readCommandSource( + relativePath: string, + cwd = process.cwd(), +): Promise { + return await readModuleSource(path.join(cwd, relativePath), new Set()); +} diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index 8355c221667..67366634b0a 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { withTempHome } from "../../../test/helpers/temp-home.js"; +import { withPathResolutionEnv } from "../../test-utils/env.js"; import type { OpenClawConfig } from "../config.js"; import { resolveStorePath } from "./paths.js"; import { @@ -87,16 +88,25 @@ describe("resolveSessionStoreTargets", () => { }, }; - const targets = resolveSessionStoreTargets(cfg, { allAgents: true }); + const homeDir = path.resolve(path.sep, "tmp", "openclaw-home"); + const targets = withPathResolutionEnv(homeDir, {}, (env) => + resolveSessionStoreTargets(cfg, { allAgents: true }, { env }), + ); expect(targets).toEqual([ { agentId: "main", - storePath: resolveStorePath(cfg.session?.store, { agentId: "main", env: process.env }), + storePath: resolveStorePath(cfg.session?.store, { + agentId: "main", + env: { HOME: homeDir }, + }), }, { agentId: "work", - storePath: resolveStorePath(cfg.session?.store, { agentId: "work", env: process.env }), + storePath: resolveStorePath(cfg.session?.store, { + agentId: "work", + env: { HOME: homeDir }, + }), }, ]); }); diff --git a/src/plugins/source-display.test.ts b/src/plugins/source-display.test.ts index b925fc0f670..1edd4abb2df 100644 --- a/src/plugins/source-display.test.ts +++ b/src/plugins/source-display.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { withEnv } from "../test-utils/env.js"; +import { withPathResolutionEnv } from "../test-utils/env.js"; import { formatPluginSourceForTable, resolvePluginSourceRoots } from "./source-display.js"; function createPluginSourceRoots() { @@ -63,24 +63,16 @@ describe("formatPluginSourceForTable", () => { }); it("resolves source roots from an explicit env override", () => { - const ignoredHome = path.resolve(path.sep, "tmp", "ignored-home"); const homeDir = path.resolve(path.sep, "tmp", "openclaw-home"); - const roots = withEnv( + const roots = withPathResolutionEnv( + homeDir, { - OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(ignoredHome, "ignored-bundled"), - OPENCLAW_STATE_DIR: path.join(ignoredHome, "ignored-state"), - OPENCLAW_HOME: undefined, - HOME: ignoredHome, + OPENCLAW_BUNDLED_PLUGINS_DIR: "~/bundled", + OPENCLAW_STATE_DIR: "~/state", }, - () => + (env) => resolvePluginSourceRoots({ - env: { - ...process.env, - HOME: homeDir, - OPENCLAW_HOME: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "~/bundled", - OPENCLAW_STATE_DIR: "~/state", - }, + env, workspaceDir: "~/ws", }), ); diff --git a/src/test-utils/env.test.ts b/src/test-utils/env.test.ts index 514eb9783d3..83eb2951c15 100644 --- a/src/test-utils/env.test.ts +++ b/src/test-utils/env.test.ts @@ -1,5 +1,13 @@ +import path from "node:path"; import { describe, expect, it } from "vitest"; -import { captureEnv, captureFullEnv, withEnv, withEnvAsync } from "./env.js"; +import { + captureEnv, + captureFullEnv, + createPathResolutionEnv, + withEnv, + withEnvAsync, + withPathResolutionEnv, +} from "./env.js"; function restoreEnvKey(key: string, previous: string | undefined): void { if (previous === undefined) { @@ -109,4 +117,58 @@ describe("env test utils", () => { expect(process.env[key]).toBe("outer"); restoreEnvKey(key, prev); }); + + it("createPathResolutionEnv clears leaked path overrides before applying explicit ones", () => { + const homeDir = path.join(path.sep, "tmp", "openclaw-home"); + const previousOpenClawHome = process.env.OPENCLAW_HOME; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + process.env.OPENCLAW_HOME = "/srv/openclaw-home"; + process.env.OPENCLAW_STATE_DIR = "/srv/openclaw-state"; + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/srv/openclaw-bundled"; + + try { + const env = createPathResolutionEnv(homeDir, { + OPENCLAW_STATE_DIR: "~/state", + }); + + expect(env.HOME).toBe(homeDir); + expect(env.OPENCLAW_HOME).toBeUndefined(); + expect(env.OPENCLAW_BUNDLED_PLUGINS_DIR).toBeUndefined(); + expect(env.OPENCLAW_STATE_DIR).toBe("~/state"); + } finally { + restoreEnvKey("OPENCLAW_HOME", previousOpenClawHome); + restoreEnvKey("OPENCLAW_STATE_DIR", previousStateDir); + restoreEnvKey("OPENCLAW_BUNDLED_PLUGINS_DIR", previousBundledDir); + } + }); + + it("withPathResolutionEnv only applies the explicit path env inside the callback", () => { + const homeDir = path.join(path.sep, "tmp", "openclaw-home"); + const previousOpenClawHome = process.env.OPENCLAW_HOME; + process.env.OPENCLAW_HOME = "/srv/openclaw-home"; + + try { + const seen = withPathResolutionEnv( + homeDir, + { OPENCLAW_BUNDLED_PLUGINS_DIR: "~/bundled" }, + (env) => ({ + processHome: process.env.HOME, + processOpenClawHome: process.env.OPENCLAW_HOME, + processBundledDir: process.env.OPENCLAW_BUNDLED_PLUGINS_DIR, + envBundledDir: env.OPENCLAW_BUNDLED_PLUGINS_DIR, + }), + ); + + expect(seen).toEqual({ + processHome: homeDir, + processOpenClawHome: undefined, + processBundledDir: "~/bundled", + envBundledDir: "~/bundled", + }); + expect(process.env.OPENCLAW_HOME).toBe("/srv/openclaw-home"); + } finally { + restoreEnvKey("OPENCLAW_HOME", previousOpenClawHome); + } + }); }); diff --git a/src/test-utils/env.ts b/src/test-utils/env.ts index fab379c7ad9..a6b3e00e547 100644 --- a/src/test-utils/env.ts +++ b/src/test-utils/env.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + export function captureEnv(keys: string[]) { const snapshot = new Map(); for (const key of keys) { @@ -27,6 +29,70 @@ function applyEnvValues(env: Record): void { } } +const PATH_RESOLUTION_ENV_KEYS = [ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_HOME", + "OPENCLAW_STATE_DIR", + "CLAWDBOT_STATE_DIR", + "OPENCLAW_BUNDLED_PLUGINS_DIR", +] as const; + +function resolveWindowsHomeParts(homeDir: string): { homeDrive?: string; homePath?: string } { + if (process.platform !== "win32") { + return {}; + } + const match = homeDir.match(/^([A-Za-z]:)(.*)$/); + if (!match) { + return {}; + } + return { + homeDrive: match[1], + homePath: match[2] || "\\", + }; +} + +export function createPathResolutionEnv( + homeDir: string, + env: Record = {}, +): NodeJS.ProcessEnv { + const resolvedHome = path.resolve(homeDir); + const nextEnv: NodeJS.ProcessEnv = { + ...process.env, + HOME: resolvedHome, + USERPROFILE: resolvedHome, + OPENCLAW_HOME: undefined, + OPENCLAW_STATE_DIR: undefined, + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + }; + + const windowsHome = resolveWindowsHomeParts(resolvedHome); + nextEnv.HOMEDRIVE = windowsHome.homeDrive; + nextEnv.HOMEPATH = windowsHome.homePath; + + for (const [key, value] of Object.entries(env)) { + nextEnv[key] = value; + } + + return nextEnv; +} + +export function withPathResolutionEnv( + homeDir: string, + env: Record, + fn: (resolvedEnv: NodeJS.ProcessEnv) => T, +): T { + const resolvedEnv = createPathResolutionEnv(homeDir, env); + const scopedEnv: Record = {}; + for (const key of new Set([...PATH_RESOLUTION_ENV_KEYS, ...Object.keys(env)])) { + scopedEnv[key] = resolvedEnv[key]; + } + return withEnv(scopedEnv, () => fn(resolvedEnv)); +} + export function captureFullEnv() { const snapshot: Record = { ...process.env }; diff --git a/test/scripts/check-no-conflict-markers.test.ts b/test/scripts/check-no-conflict-markers.test.ts new file mode 100644 index 00000000000..37bcecf3203 --- /dev/null +++ b/test/scripts/check-no-conflict-markers.test.ts @@ -0,0 +1,64 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + findConflictMarkerLines, + findConflictMarkersInFiles, +} from "../../scripts/check-no-conflict-markers.mjs"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-conflict-markers-")); + tempDirs.push(dir); + return dir; +} + +describe("check-no-conflict-markers", () => { + it("finds git conflict markers at the start of lines", () => { + expect( + findConflictMarkerLines( + [ + "const ok = true;", + "<<<<<<< HEAD", + "value = left;", + "=======", + "value = right;", + ">>>>>>> main", + ].join("\n"), + ), + ).toEqual([2, 4, 6]); + }); + + it("ignores marker-like text when it is indented or inline", () => { + expect( + findConflictMarkerLines( + ["Example:", " <<<<<<< HEAD", "const text = '======= not a conflict';"].join("\n"), + ), + ).toEqual([]); + }); + + it("scans text files and skips binary files", () => { + const rootDir = makeTempDir(); + const textFile = path.join(rootDir, "CHANGELOG.md"); + const binaryFile = path.join(rootDir, "image.png"); + fs.writeFileSync(textFile, "<<<<<<< HEAD\nconflict\n>>>>>>> main\n"); + fs.writeFileSync(binaryFile, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])); + + const violations = findConflictMarkersInFiles([textFile, binaryFile]); + + expect(violations).toEqual([ + { + filePath: textFile, + lines: [1, 3], + }, + ]); + }); +});