From c8269ca49722859de040c1263b43e95d631dc363 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:45:47 +0800 Subject: [PATCH] fix(ci): own mock isolation and normalize Git patch directories Convert native directory separators only at Git patch argument production. Exercise fresh, applied and invalid dependency patches in private copies, preserving protectNTFS, and select the regression for Windows CI. Replace the spelling-based mock inventory with one binding-aware Oxlint rule. Canonical test projects own isolation for both Vitest and lint; external temporary CLI probes avoid concurrent test discovery. Validated actual local Git and Oxlint execution, resolved-policy parity, CI selection and report tests. Native Windows verification remains with CI. Refs: #7050 --- .github/workflows/ci.yml | 1 + .oxlintrc.json | 3 +- docs/testing.md | 7 + scripts/ci/scopes.mjs | 3 + scripts/lib/release-proposal.mjs | 10 +- scripts/lib/test-projects.d.mts | 1 + scripts/lib/test-projects.mjs | 7 + scripts/lint/index.mjs | 2 + .../lint/rules/no-shared-module-mock.d.mts | 4 + scripts/lint/rules/no-shared-module-mock.mjs | 116 +++++++++++++++ .../boundary/filesystem/ciChangeScope.test.ts | 6 + .../filesystem/ciTestProjects.test.ts | 122 +++++++++++++--- .../filesystem/releaseProposal.test.ts | 135 +++++++++++++++--- .../evaluation/noSharedModuleMockRule.test.ts | 80 +++++++++++ .../process-global/curatedWindowsLane.test.ts | 6 + vitest.config.ts | 8 +- 16 files changed, 463 insertions(+), 48 deletions(-) create mode 100644 scripts/lint/rules/no-shared-module-mock.d.mts create mode 100644 scripts/lint/rules/no-shared-module-mock.mjs create mode 100644 tests/evaluation/noSharedModuleMockRule.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f0a39565a..0d5924c9c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -858,6 +858,7 @@ jobs: src/process/capture/FilesystemSnapshot.test.ts tests/process-global/filesystemSnapshotFallbackCancellation.test.ts tests/boundary/filesystem/typescriptCompiler.test.ts + tests/boundary/filesystem/releaseProposal.test.ts tests/boundary/filesystem/binaryTarget.test.ts src/contracts/sessionPathInputs.test.ts tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 5cc6ed8952..d41674db8a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -58,7 +58,8 @@ { "considerDefaultExhaustiveForUnions": true } - ] + ], + "rea/no-shared-module-mock": "error" }, "overrides": [ { diff --git a/docs/testing.md b/docs/testing.md index 1a0014e7f7..5a6fd931a0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -166,6 +166,13 @@ operation names, schemas or types; display-only selection remains allowed. `rea/no-fixture-root-cleanup` follows `const` fixture-root aliases into teardown deletion; mutable bindings and mixed root arrays remain outside its scope. +`rea/no-shared-module-mock` resolves Vitest import bindings before rejecting +module mocks in projects without per-file isolation. The test-project definitions +own isolation for Vitest and lint; the executable gate checks the resolved policy +and runs the same collector. Aliases and namespace imports count; comments, +shadowed locals, ordinary methods and `spyOn` do not. Negative CLI probes use an +external temporary project so concurrent repository checks cannot discover them. + Focused `.only` declarations, broken assertion promise chains, and unused disable directives are errors. `vitest/valid-expect` warns (`maxArgs: 2`) because valid stored and collected assertions can be awaited later. `import/no-duplicates` warns with diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 367a317901..9d6f7c62ce 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -144,6 +144,9 @@ export const SCOPES = { "scripts/windows-capabilities.mjs", "scripts/prepack.mjs", "scripts/prepare.mjs", + "scripts/lib/release-proposal.mjs", + "patches/release-please+17.3.0.patch", + "tests/boundary/filesystem/releaseProposal.test.ts", "scripts/rea.mjs", "scripts/lib/cli-process-environment.mjs", "install.sh", diff --git a/scripts/lib/release-proposal.mjs b/scripts/lib/release-proposal.mjs index 1db7cf973c..1bbb709c61 100644 --- a/scripts/lib/release-proposal.mjs +++ b/scripts/lib/release-proposal.mjs @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { dirname, relative } from "node:path"; +import { dirname, relative, sep } from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { requireCompleteReleaseHistory } from "./release-notes.mjs"; @@ -24,7 +24,13 @@ export async function applyReleasePleasePatch( "The release proposal patch requires release-please 17.3.0", ); } - const args = ["--directory", relative(project, directory), patch]; + // Git prefixes patch filenames with a slash-separated path, even on Windows. + // Split only native separators so POSIX backslashes remain filename bytes. + const args = [ + "--directory", + relative(project, directory).split(sep).join("/"), + patch, + ]; const check = async (reverse) => { try { await execute( diff --git a/scripts/lib/test-projects.d.mts b/scripts/lib/test-projects.d.mts index a9d6ae3563..debca42658 100644 --- a/scripts/lib/test-projects.d.mts +++ b/scripts/lib/test-projects.d.mts @@ -1,6 +1,7 @@ export interface TestProjectDefinition { readonly name: string; readonly group: "source" | "built"; + readonly isolate?: boolean; readonly include: readonly string[]; readonly exclude?: readonly string[]; } diff --git a/scripts/lib/test-projects.mjs b/scripts/lib/test-projects.mjs index 13026a798f..89273948d4 100644 --- a/scripts/lib/test-projects.mjs +++ b/scripts/lib/test-projects.mjs @@ -5,11 +5,13 @@ export const SOURCE_TEST_PROJECTS = [ { name: "domain", group: "source", + isolate: false, include: ["src/{contracts,domain}/**/*.test.ts"], }, { name: "services", group: "source", + isolate: false, include: ["src/application/**/*.test.ts"], }, { @@ -23,17 +25,20 @@ export const SOURCE_TEST_PROJECTS = [ { name: "composition", group: "source", + isolate: false, include: ["tests/composition/**/*.test.ts"], }, { name: "conformance", group: "source", + isolate: false, include: ["tests/conformance/**/*.test.ts"], exclude: ["tests/conformance/providers/hopper/realHopperSearch.test.ts"], }, { name: "evaluation", group: "source", + isolate: false, include: ["tests/evaluation/**/*.test.ts"], }, ]; @@ -55,6 +60,7 @@ export const BUILT_TEST_PROJECTS = [ { name: "cli-boundary", group: "built", + isolate: false, include: ["tests/boundary/cli/**/*.test.ts"], }, { @@ -65,6 +71,7 @@ export const BUILT_TEST_PROJECTS = [ { name: "mcp-boundary", group: "built", + isolate: false, include: ["tests/boundary/mcp/**/*.test.ts"], }, { diff --git a/scripts/lint/index.mjs b/scripts/lint/index.mjs index db0365acfd..66b7f47cd3 100644 --- a/scripts/lint/index.mjs +++ b/scripts/lint/index.mjs @@ -4,6 +4,7 @@ import cancellationCause from "./cancellation-cause.mjs"; import noEvidenceJsonPreadmission from "./rules/no-evidence-json-preadmission.mjs"; import noFixtureRootCleanup from "./rules/no-fixture-root-cleanup.mjs"; import noPositionalToolContract from "./rules/no-positional-tool-contract.mjs"; +import noSharedModuleMock from "./rules/no-shared-module-mock.mjs"; export default { meta: { name: "rea" }, @@ -13,5 +14,6 @@ export default { "no-evidence-json-preadmission": noEvidenceJsonPreadmission, "no-fixture-root-cleanup": noFixtureRootCleanup, "no-positional-tool-contract": noPositionalToolContract, + "no-shared-module-mock": noSharedModuleMock, }, }; diff --git a/scripts/lint/rules/no-shared-module-mock.d.mts b/scripts/lint/rules/no-shared-module-mock.d.mts new file mode 100644 index 0000000000..86d61729e2 --- /dev/null +++ b/scripts/lint/rules/no-shared-module-mock.d.mts @@ -0,0 +1,4 @@ +import type { RuleTester } from "oxlint/plugins-dev"; + +declare const rule: Parameters[1]; +export default rule; diff --git a/scripts/lint/rules/no-shared-module-mock.mjs b/scripts/lint/rules/no-shared-module-mock.mjs new file mode 100644 index 0000000000..c3245f41a2 --- /dev/null +++ b/scripts/lint/rules/no-shared-module-mock.mjs @@ -0,0 +1,116 @@ +import { relative, isAbsolute } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { findBinding } from "../bindings.mjs"; +import { projectForTest } from "../../lib/test-projects.mjs"; + +const repository = fileURLToPath(new URL("../../../", import.meta.url)); +const mockMethods = new Set(["mock", "doMock"]); + +const unwrap = (node) => + node?.type === "ChainExpression" ? node.expression : node; + +const propertyName = (member) => { + if (!member?.computed && member?.property?.type === "Identifier") + return member.property.name; + if (member?.computed && member.property?.type === "Literal") + return member.property.value; + return undefined; +}; + +const importedFromVitest = (sourceCode, identifier) => { + const binding = findBinding(sourceCode, identifier); + const definition = binding?.defs[0]; + if (definition?.type !== "ImportBinding") return undefined; + const declaration = definition.parent ?? definition.node.parent; + if (declaration?.source?.value !== "vitest") return undefined; + return definition.node; +}; + +const isViImport = (sourceCode, identifier) => { + const specifier = importedFromVitest(sourceCode, identifier); + return ( + specifier?.type === "ImportSpecifier" && + (specifier.imported.name ?? specifier.imported.value) === "vi" + ); +}; + +const isVitestNamespace = (sourceCode, identifier) => + importedFromVitest(sourceCode, identifier)?.type === + "ImportNamespaceSpecifier"; + +const isVitestViMember = (sourceCode, node) => { + const member = unwrap(node); + if ( + member?.type !== "MemberExpression" || + propertyName(member) !== "vi" || + member.object?.type !== "Identifier" + ) + return false; + return isVitestNamespace(sourceCode, member.object); +}; + +const isModuleMockCall = (sourceCode, callee) => { + const method = unwrap(callee); + if ( + method?.type !== "MemberExpression" || + !mockMethods.has(propertyName(method)) + ) + return false; + const receiver = unwrap(method.object); + return ( + (receiver?.type === "Identifier" && isViImport(sourceCode, receiver)) || + isVitestViMember(sourceCode, receiver) + ); +}; + +const isolationForFile = (filename, cwd) => { + if (typeof filename !== "string" || !filename.endsWith(".test.ts")) + return undefined; + const testPath = relative(cwd, filename).split("\\").join("/"); + if ( + testPath === "" || + isAbsolute(testPath) || + testPath === ".." || + testPath.startsWith("../") + ) + return undefined; + try { + return projectForTest(testPath).isolate !== false; + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("No test project owns ") + ) + return undefined; + throw error; + } +}; + +export default { + meta: { + type: "problem", + docs: { + description: + "Keep Vitest module mocks in projects with per-file module isolation.", + }, + schema: [], + messages: { + shared: + "Vitest module mocks need an isolated test project; move the test to an isolated project or replace the module mock with a scoped spy or injected dependency.", + }, + }, + create(context) { + const filename = context.physicalFilename ?? context.filename; + const sourceCode = context.sourceCode; + const cwd = + context.cwd ?? context.getCwd?.() ?? sourceCode.getCwd?.() ?? repository; + if (isolationForFile(filename, cwd) !== false) return {}; + return { + CallExpression(node) { + if (isModuleMockCall(sourceCode, node.callee)) + context.report({ node, messageId: "shared" }); + }, + }; + }, +}; diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 87b472c1fe..ffee09d45e 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -626,6 +626,12 @@ it.each([ "tests/module/example.test.ts", { code: true, runtime: false, browser: false, package: false }, ], + ["scripts/lib/release-proposal.mjs", { code: true, package: true }], + ["patches/release-please+17.3.0.patch", { code: true, package: true }], + [ + "tests/boundary/filesystem/releaseProposal.test.ts", + { code: true, package: true }, + ], ["website/src/index.ts", { code: false, website: true, package: false }], [ "website/public/examples/notes-electron/preload.js", diff --git a/tests/boundary/filesystem/ciTestProjects.test.ts b/tests/boundary/filesystem/ciTestProjects.test.ts index ac5c481dd1..34e9bc60c6 100644 --- a/tests/boundary/filesystem/ciTestProjects.test.ts +++ b/tests/boundary/filesystem/ciTestProjects.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -14,10 +14,55 @@ import { parseDevelopmentTestRequest, } from "../../../scripts/lib/development-tests.mjs"; -import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; +import { + execFileOutput, + execFileOutputFailure, +} from "../../../src/process/ExecFileOutput.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeRepositoryOxlintConfiguration } from "../../fixtures/oxlintConfiguration.js"; const repository = process.cwd(); +const oxlint = join(repository, "node_modules/oxlint/bin/oxlint"); +const oxlintConfig = join(repository, ".oxlintrc.json"); + +const runOxlint = async ( + cwd: string, + config: string, + files: readonly string[], +) => { + let stdout: string; + let exitCode: string | number = 0; + try { + ({ stdout } = await execFileOutput( + process.execPath, + [oxlint, "--threads=1", "--config", config, "--format", "json", ...files], + { + cwd, + timeout: 60_000, + maxBuffer: 8 * 1024 * 1024, + stopSignal: "SIGTERM", + }, + )); + } catch (cause) { + const failure = execFileOutputFailure(cause); + if ( + failure === undefined || + failure.code !== 1 || + failure.signal !== null || + failure.killed || + failure.outputTruncated + ) + throw cause; + stdout = failure.stdout; + exitCode = failure.code; + } + return { + exitCode, + report: JSON.parse(stdout) as { + diagnostics?: Array<{ code?: string; severity: string }>; + }, + }; +}; describe("canonical test-project ownership", () => { it("assigns every tracked test file exactly once to source or built", () => { @@ -154,17 +199,22 @@ describe("test module isolation", () => { expect(projectForTest(path)).toMatchObject({ name, group }); }); - it("keeps module mocks out of projects that share one module graph", async () => { - // A mock registered after another file in the same worker loaded the - // module never applies, so the test's outcome depends on file scheduling. - const root = await createTestTempDirectory("rea-vitest-mock-isolation-"); + it("uses one isolation policy for Vitest and binding-aware mock lint", async () => { + const files = execFileSync("git", ["ls-files", "*.test.ts"], { + cwd: repository, + encoding: "utf8", + }) + .trim() + .split("\n") + .filter(Boolean); + const root = await createTestTempDirectory("rea-vitest-project-policy-"); const script = join(root, "project-isolation.mjs"); await writeFile( script, ` import config from ${JSON.stringify(pathToFileURL(join(repository, "vitest.config.ts")).href)}; console.log(JSON.stringify(Object.fromEntries( - config.test.projects.map(project => [project.test.name, project.test.isolate !== false]), + config.test.projects.map(project => [project.test.name, project.test.isolate]), ))); `, ); @@ -179,17 +229,55 @@ describe("test module isolation", () => { }, ); const isolated = JSON.parse(result.stdout) as Record; - const mocking = execFileSync( - "git", - ["grep", "-l", "-E", String.raw`vi\.(mock|doMock)\(`, "--", "*.test.ts"], - { cwd: repository, encoding: "utf8" }, - ) - .trim() - .split("\n") - .filter(Boolean); - expect(mocking.length).toBeGreaterThan(0); + expect(files.length).toBeGreaterThan(0); expect( - mocking.filter((file) => isolated[projectForTest(file).name] !== true), + files.filter((file) => { + const project = projectForTest(file); + return isolated[project.name] !== (project.isolate !== false); + }), + ).toEqual([]); + + const { report } = await runOxlint(repository, oxlintConfig, files); + expect( + report.diagnostics?.filter( + ({ code, severity }) => + code === "rea(no-shared-module-mock)" && severity === "error", + ), + ).toEqual([]); + }); + + it("rejects actual Vitest module-mock aliases only in shared projects", async () => { + const root = await createTestTempDirectory("rea-vitest-mock-probe-"); + const sharedDirectory = join(root, "tests/evaluation"); + const isolatedDirectory = join(root, "tests/process-global"); + await mkdir(sharedDirectory, { recursive: true }); + await mkdir(isolatedDirectory, { recursive: true }); + const config = await writeRepositoryOxlintConfiguration(root); + const probe = async (directory: string, source: string) => { + const path = join(directory, "mock-probe.test.ts"); + await writeFile(path, source); + return runOxlint(root, config, [path]); + }; + const shared = await probe( + sharedDirectory, + 'import { vi as testing } from "vitest"; testing.mock("./dependency.js");', + ); + const isolated = await probe( + isolatedDirectory, + 'import * as testing from "vitest"; testing.vi.doMock("./dependency.js");', + ); + expect(shared.exitCode).toBe(1); + expect( + shared.report.diagnostics?.filter( + ({ code, severity }) => + code === "rea(no-shared-module-mock)" && severity === "error", + ), + ).toHaveLength(1); + expect(isolated.exitCode).toBe(0); + expect( + isolated.report.diagnostics?.filter( + ({ code }) => code === "rea(no-shared-module-mock)", + ), ).toEqual([]); }); }); diff --git a/tests/boundary/filesystem/releaseProposal.test.ts b/tests/boundary/filesystem/releaseProposal.test.ts index cfc6b02867..5f353e5472 100644 --- a/tests/boundary/filesystem/releaseProposal.test.ts +++ b/tests/boundary/filesystem/releaseProposal.test.ts @@ -1,7 +1,14 @@ import { execFile } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; +import { + copyFile, + mkdir, + readFile, + symlink, + writeFile, +} from "node:fs/promises"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { afterEach, beforeAll, expect, it, vi } from "vitest"; import { z } from "zod"; @@ -27,7 +34,6 @@ const silent = { }; beforeAll(async () => { - await applyReleasePleasePatch(); upstream = await import("release-please"); githubRuntime = require("release-please/build/src/github.js"); }); @@ -667,11 +673,93 @@ it("requires complete local history when sizing the upstream search", async () = ); }); -it("applies the exact dependency patch idempotently and rejects another version", async () => { - await expect(applyReleasePleasePatch()).resolves.toBeUndefined(); - const directory = dirname(require.resolve("release-please/package.json")); - const metadata = await readFile(join(directory, "package.json"), "utf8"); - expect(JSON.parse(metadata).version).toBe("17.3.0"); +const releasePatchFixture = async () => { + const root = await createTestTempDirectory("rea-release-git-patch-"); + const project = join(root, "project space Ω"); + const dependencyPath = "node_modules/release dependency space Ω"; + const directory = join(project, dependencyPath); + const upstreamDirectory = dirname( + require.resolve("release-please/package.json"), + ); + const source = fileURLToPath(new URL("../../../", import.meta.url)); + const patchPath = join(project, "patches/release-please+17.3.0.patch"); + const patch = await readFile( + join(source, "patches/release-please+17.3.0.patch"), + "utf8", + ); + const members = [...patch.matchAll(/^\+\+\+ b\/(.+)$/gmu)].map((match) => { + const member = match[1]; + if (member === undefined) throw new Error("Patch header omitted its path"); + return member; + }); + await mkdir(join(project, "scripts/lib"), { recursive: true }); + await mkdir(dirname(patchPath), { recursive: true }); + await mkdir(directory, { recursive: true }); + await writeFile(join(project, "package.json"), '{"type":"module"}'); + await writeFile(patchPath, patch); + for (const script of ["release-proposal.mjs", "release-notes.mjs"]) + await copyFile( + join(source, "scripts/lib", script), + join(project, "scripts/lib", script), + ); + for (const dependency of ["semver", "zod"]) + await symlink( + dirname(require.resolve(`${dependency}/package.json`)), + join(project, "node_modules", dependency), + "junction", + ); + for (const member of ["package.json", ...members]) { + await mkdir(dirname(join(directory, member)), { recursive: true }); + await copyFile(join(upstreamDirectory, member), join(directory, member)); + } + const environment = { + ...process.env, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.protectNTFS", + GIT_CONFIG_VALUE_0: "true", + }; + const git = (...args: string[]) => + execute("git", args, { cwd: project, env: environment }); + // Installation owns the patched dependency. Reverse only the private copy + // to obtain the exact upstream input for the fresh-application control. + await git("apply", "--reverse", "--directory", dependencyPath, patchPath); + const apply = () => + execute( + process.execPath, + [ + "--input-type=module", + "-e", + `import { applyReleasePleasePatch } from ${JSON.stringify(pathToFileURL(join(project, "scripts/lib/release-proposal.mjs")).href)}; await applyReleasePleasePatch(${JSON.stringify(directory)});`, + ], + { cwd: project, env: environment, timeout: 10_000 }, + ); + const contents = () => + Promise.all( + members.map((member) => readFile(join(directory, member), "utf8")), + ); + return { directory, apply, contents, git, dependencyPath, patchPath }; +}; + +it("applies the private dependency patch with protected Git paths and is idempotent", async () => { + const fixture = await releasePatchFixture(); + const original = await fixture.contents(); + await fixture.apply(); + const patched = await fixture.contents(); + expect(patched).not.toEqual(original); + expect(patched.join("\n")).toContain("const rootOnlyNode ="); + await fixture.git( + "apply", + "--check", + "--reverse", + "--directory", + fixture.dependencyPath, + fixture.patchPath, + ); + await fixture.apply(); + expect(await fixture.contents()).toEqual(patched); +}); + +it("rejects a different dependency version before patching", async () => { const fixture = await createTestTempDirectory("rea-release-patch-"); await writeFile( join(fixture, "package.json"), @@ -682,22 +770,27 @@ it("applies the exact dependency patch idempotently and rejects another version" ); }); -it("rejects a partial or mismatched dependency patch", async () => { - const directory = dirname(require.resolve("release-please/package.json")); - const manifest = join(directory, "build/src/manifest.js"); - const original = await readFile(manifest, "utf8"); - try { +it.each(["partial", "mismatched"])( + "rejects a %s private dependency patch without changing its files", + async (state) => { + const fixture = await releasePatchFixture(); + const manifest = join(fixture.directory, "build/src/manifest.js"); + const original = await readFile(manifest, "utf8"); + await fixture.apply(); + const patched = await readFile(manifest, "utf8"); await writeFile( manifest, - original.replace( - "const rootOnlyNode =", - "const mismatchedRootOnlyNode =", - ), + state === "partial" + ? original + : patched.replace( + "const rootOnlyNode =", + "const mismatchedRootOnlyNode =", + ), ); - await expect(applyReleasePleasePatch()).rejects.toThrow( + const before = await fixture.contents(); + await expect(fixture.apply()).rejects.toThrow( "does not match the installed source", ); - } finally { - await writeFile(manifest, original); - } -}); + expect(await fixture.contents()).toEqual(before); + }, +); diff --git a/tests/evaluation/noSharedModuleMockRule.test.ts b/tests/evaluation/noSharedModuleMockRule.test.ts new file mode 100644 index 0000000000..e5288b2f86 --- /dev/null +++ b/tests/evaluation/noSharedModuleMockRule.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { fileURLToPath } from "node:url"; +import { RuleTester } from "oxlint/plugins-dev"; + +import rule from "../../scripts/lint/rules/no-shared-module-mock.mjs"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const tester = new RuleTester({ + cwd: fileURLToPath(new URL("../../", import.meta.url)), + languageOptions: { parserOptions: { lang: "ts" } }, +}); +const sharedFilename = fileURLToPath( + new URL("./noSharedModuleMockRule.test.ts", import.meta.url), +); +const isolatedFilename = fileURLToPath( + new URL("../process-global/noSharedModuleMockRule.test.ts", import.meta.url), +); + +tester.run("no-shared-module-mock", rule, { + valid: [ + { + filename: isolatedFilename, + code: 'import { vi } from "vitest"; vi.mock("./dependency.js");', + }, + { + filename: isolatedFilename, + code: 'import * as testing from "vitest"; testing.vi.doMock("./dependency.js");', + }, + { + filename: sharedFilename, + code: 'import { vi } from "vitest"; vi.spyOn(target, "method");', + }, + { + filename: sharedFilename, + code: "const mock = { mock() {} }; mock.mock();", + }, + { + filename: sharedFilename, + code: 'const source = "vi.mock(\\"./dependency.js\\")"; // vi.mock("./dependency.js")', + }, + { + filename: sharedFilename, + code: 'import { vi as testing } from "vitest"; function local(testing: { mock(): void }) { testing.mock(); }', + }, + { + filename: sharedFilename, + code: 'import * as vitest from "vitest"; function local(vitest: { vi: { mock(): void } }) { vitest.vi.mock(); }', + }, + { + filename: fileURLToPath( + new URL("../../tests/unknown/ignored.test.ts", import.meta.url), + ), + code: 'import { vi } from "vitest"; vi.mock("./dependency.js");', + }, + ], + invalid: [ + { + filename: sharedFilename, + code: 'import { vi } from "vitest"; vi.mock("./dependency.js");', + errors: [{ messageId: "shared" }], + }, + { + filename: sharedFilename, + code: 'import { vi as testing } from "vitest"; testing.doMock("./dependency.js");', + errors: [{ messageId: "shared" }], + }, + { + filename: sharedFilename, + code: 'import * as testing from "vitest"; testing.vi.mock("./dependency.js");', + errors: [{ messageId: "shared" }], + }, + { + filename: sharedFilename, + code: 'import * as vitest from "vitest"; vitest.vi["doMock"]("./dependency.js");', + errors: [{ messageId: "shared" }], + }, + ], +}); diff --git a/tests/process-global/curatedWindowsLane.test.ts b/tests/process-global/curatedWindowsLane.test.ts index a926bf9b7f..7dfe059911 100644 --- a/tests/process-global/curatedWindowsLane.test.ts +++ b/tests/process-global/curatedWindowsLane.test.ts @@ -83,6 +83,12 @@ describe("curated Windows test lane", () => { ); }); + it("keeps real Git dependency-patch admission in the Windows lane", async () => { + expect(await explicitPaths("windows")).toContain( + "tests/boundary/filesystem/releaseProposal.test.ts", + ); + }); + it("keeps Windows environment identity and layered setup regressions in the lane", async () => { expect(await explicitPaths("windows")).toEqual( expect.arrayContaining([ diff --git a/vitest.config.ts b/vitest.config.ts index 92519037a8..2da24fbea8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -42,14 +42,12 @@ const PROJECT_POLICIES = { maxWorkers: MAX_TEST_WORKERS, // Domain and contract modules are pure and own no process-global state, so // per-file module isolation adds startup cost without protecting a seam. - isolate: false, }, services: { pool: "threads" as const, maxWorkers: MAX_TEST_WORKERS, // Service tests use recording ports and own no process-global state. Share // their module graph so file startup does not dominate the lane. - isolate: false, }, adapters: { pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS }, composition: { @@ -57,7 +55,6 @@ const PROJECT_POLICIES = { maxWorkers: MAX_TEST_WORKERS, // Composition tests use provider-neutral sessions and test-scoped cleanup; // sharing their immutable module graph avoids per-file startup work. - isolate: false, }, boundary: { pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS }, "cli-boundary": { @@ -65,7 +62,6 @@ const PROJECT_POLICIES = { maxWorkers: MAX_TEST_WORKERS, // CLI tests create fresh command instances or subprocesses and own their // cleanup. Reuse the immutable graph; module mocks belong in process-global. - isolate: false, // The shared source/contract graph also needs transient schema/help space. // Ordinary CI exhausted 384 MiB after earlier files warmed that graph. // Use the measured 512 MiB budget with the same bounded concurrency. @@ -84,7 +80,6 @@ const PROJECT_POLICIES = { maxWorkers: MAX_TEST_WORKERS, // Each file creates and closes an explicit in-memory MCP session. Reuse the // immutable server graph so module startup does not outweigh transport work. - isolate: false, }, acceptance: { pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS }, "process-global": { pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS }, @@ -92,13 +87,11 @@ const PROJECT_POLICIES = { pool: "threads" as const, maxWorkers: MAX_TEST_WORKERS, // Contract cases use recording providers and own no process-global state. - isolate: false, }, evaluation: { pool: "threads" as const, maxWorkers: MAX_TEST_WORKERS, // Evaluator tests inspect deterministic inputs without mutating shared state. - isolate: false, }, }; @@ -132,6 +125,7 @@ const projects = TEST_PROJECTS.map((project) => { name: project.name, include: [...project.include], ...(project.exclude ? { exclude: [...project.exclude] } : {}), + isolate: project.isolate !== false, ...policy, }, };