diff --git a/src/commands/manifest/cmd-manifest-dotnet.test.mts b/src/commands/manifest/cmd-manifest-dotnet.test.mts new file mode 100644 index 000000000..56a605371 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-dotnet.test.mts @@ -0,0 +1,85 @@ +import { describe, expect } from 'vitest' + +import constants, { + FLAG_CONFIG, + FLAG_DRY_RUN, + FLAG_HELP, +} from '../../../src/constants.mts' +import { cmdit, spawnSocketCli } from '../../../test/utils.mts' + +describe('socket manifest dotnet', async () => { + const { binCliPath } = constants + + cmdit( + ['manifest', 'dotnet', FLAG_HELP, FLAG_CONFIG, '{}'], + `should support ${FLAG_HELP}`, + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(` + "[beta] Generate Socket facts files from .NET solutions and projects + + Usage + $ socket manifest dotnet [options] [CWD=.|FILE] + + Options + --bin Location of the dotnet binary to use, default: dotnet on PATH + --dotnet-opts Additional options to pass on to \`dotnet restore\`, e.g. \`-p:Configuration=Release --configfile nuget.config\`. MSBuild properties among them also apply to project evaluation + --exclude-configs Comma-separated glob patterns; target frameworks matching any pattern are skipped (applied after --include-configs) + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. An excluded JVM build module is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module outside CWD cannot be excluded. + --ignore-unresolved Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) + --include-configs Comma-separated glob patterns matched against target frameworks (case-sensitive; \`*\`, \`?\`, and \`[...]\` wildcards). Only target frameworks matching at least one pattern are read, e.g. \`net8.0\`. A runtime-specific target (\`net8.0/win-x64\`) also matches its framework. Default: every target framework + --verbose Print debug messages + + Emits a \`.socket.facts.json\` per solution or project describing its + resolved NuGet dependency graph. Given a directory, it builds every + solution (.sln, .slnx) and project (.csproj, .fsproj, .vbproj) at its top + level, solutions first, skipping projects a solution already covers; given + a file, just that one (a .slnf solution filter too). + + Dependencies come from your own \`dotnet restore\` (same SDK, global.json, + nuget.config and credential providers), so a build that restores for you + restores here. PackageReference projects (central package management and + lock files included) and packages.config projects are supported. An + unresolved dependency is a fatal error unless --ignore-unresolved. + + Support is beta. Please report issues or give us feedback on what's missing. + + Examples + + $ socket manifest dotnet . + $ socket manifest dotnet ./App.sln + $ socket manifest dotnet --dotnet-opts="-p:Configuration=Release" ." + `) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | CLI: + |__ | * | _| '_| -_| _| | token: , org: + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dotnet\`, cwd: " + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket manifest dotnet`', + ) + }, + ) + + cmdit( + ['manifest', 'dotnet', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], + 'should require args with just dry-run', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | CLI: + |__ | * | _| '_| -_| _| | token: , org: + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dotnet\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/manifest/detect-manifest-actions.test.mts b/src/commands/manifest/detect-manifest-actions.test.mts index 950916265..208064aff 100644 --- a/src/commands/manifest/detect-manifest-actions.test.mts +++ b/src/commands/manifest/detect-manifest-actions.test.mts @@ -1,32 +1,31 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { detectManifestActions } from './detect-manifest-actions.mts' +import { + detectManifestActions, + findDotnetEntries, +} from './detect-manifest-actions.mts' import type { SocketJson } from '../../utils/socket-json.mts' -function mkTmp(): string { - return mkdtempSync(path.join(os.tmpdir(), 'detect-manifest-bazel-')) -} +let cwd: string + +beforeEach(() => { + cwd = mkdtempSync(path.join(os.tmpdir(), 'detect-manifest-')) +}) + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) +}) function touch(dir: string, name: string): void { writeFileSync(path.join(dir, name), '') } describe('detectManifestActions — bazel detector', () => { - let cwd: string - - beforeEach(() => { - cwd = mkTmp() - }) - - afterEach(() => { - rmSync(cwd, { recursive: true, force: true }) - }) - it('skips bazel when defaults.manifest.bazel.disabled is true', async () => { touch(cwd, 'MODULE.bazel') const result = await detectManifestActions( @@ -75,16 +74,6 @@ describe('detectManifestActions — bazel detector', () => { }) describe('detectManifestActions — gradle detector', () => { - let cwd: string - - beforeEach(() => { - cwd = mkTmp() - }) - - afterEach(() => { - rmSync(cwd, { recursive: true, force: true }) - }) - it('detects build.gradle', async () => { touch(cwd, 'build.gradle') const result = await detectManifestActions(null, cwd) @@ -140,16 +129,6 @@ describe('detectManifestActions — gradle detector', () => { }) describe('detectManifestActions — conda detector', () => { - let cwd: string - - beforeEach(() => { - cwd = mkTmp() - }) - - afterEach(() => { - rmSync(cwd, { recursive: true, force: true }) - }) - it('detects environment.yml and reports it as the conda file', async () => { touch(cwd, 'environment.yml') const result = await detectManifestActions(null, cwd) @@ -193,3 +172,50 @@ describe('detectManifestActions — conda detector', () => { expect(result.count).toBe(0) }) }) + +describe('detectManifestActions — dotnet detector', () => { + it.each([ + 'App.sln', + 'App.slnx', + 'App.csproj', + 'App.fsproj', + 'App.vbproj', + 'APP.SLN', + ])('detects %s', async name => { + touch(cwd, name) + expect((await detectManifestActions(null, cwd)).dotnet).toBe(true) + }) + + it('ignores a solution filter, other files and directories named like projects', async () => { + touch(cwd, 'App.slnf') + touch(cwd, 'App.sqlproj') + mkdirSync(path.join(cwd, 'Lib.csproj')) + expect((await detectManifestActions(null, cwd)).dotnet).toBe(false) + }) + + it('skips dotnet when defaults.manifest.dotnet.disabled is true', async () => { + touch(cwd, 'App.sln') + const sockJson = { + defaults: { manifest: { dotnet: { disabled: true } } }, + } as SocketJson + expect((await detectManifestActions(sockJson, cwd)).dotnet).toBe(false) + }) + + it('lists solutions before projects, each sorted by name', () => { + for (const name of [ + 'b.csproj', + 'A.vbproj', + 'Z.sln', + 'M.slnx', + 'notes.txt', + ]) { + touch(cwd, name) + } + expect(findDotnetEntries(cwd)).toEqual([ + 'M.slnx', + 'Z.sln', + 'A.vbproj', + 'b.csproj', + ]) + }) +}) diff --git a/src/commands/manifest/discover-manifest-roots.test.mts b/src/commands/manifest/discover-manifest-roots.test.mts index 2b1c64a1e..4579512aa 100644 --- a/src/commands/manifest/discover-manifest-roots.test.mts +++ b/src/commands/manifest/discover-manifest-roots.test.mts @@ -2,12 +2,13 @@ import { promises as fs } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { buildRootPath, findBuildToolCandidates, isBuildRootCovered, + isRootCovered, sortByDepthThenPath, } from './discover-manifest-roots.mts' import { testPath } from '../../../test/utils.mts' @@ -223,34 +224,39 @@ describe('findBuildToolCandidates', () => { }) describe('isBuildRootCovered', () => { - it('covers a POM only by its own file', () => { - const covered = new Set(['/repo/service/pom.xml']) - expect( - isBuildRootCovered(covered, 'maven', { - dir: '/repo/service', - entryFile: 'pom.xml', - }), - ).toBe(true) - expect( - isBuildRootCovered(covered, 'maven', { - dir: '/repo/service', - entryFile: 'app-dev.pom.xml', - }), - ).toBe(false) - }) - - it('covers a root without an entry file through a marker file', () => { - expect( - isBuildRootCovered(new Set(['/repo/lib/settings.gradle.kts']), 'gradle', { - dir: '/repo/lib', - }), - ).toBe(true) - expect( - isBuildRootCovered(new Set(['/repo/lib/sub/build.gradle']), 'gradle', { - dir: '/repo/lib', - }), - ).toBe(false) - }) + it.for([ + [ + 'maven', + '/repo/service/pom.xml', + { dir: '/repo/service', entryFile: 'pom.xml' }, + true, + ], + [ + 'maven', + '/repo/service/pom.xml', + { dir: '/repo/service', entryFile: 'app-dev.pom.xml' }, + false, + ], + [ + 'dotnet', + '/repo/src/App/App.csproj', + { dir: '/repo/src/App', entryFile: 'App.csproj' }, + true, + ], + [ + 'dotnet', + '/repo/src/App/App.csproj', + { dir: '/repo/src/App', entryFile: 'App.sln' }, + false, + ], + ['gradle', '/repo/lib/settings.gradle.kts', { dir: '/repo/lib' }, true], + ['gradle', '/repo/lib/sub/build.gradle', { dir: '/repo/lib' }, false], + ] as const)( + '%s: %s covers %o: %s', + ({ 0: tool, 1: file, 2: root, 3: expected }) => { + expect(isBuildRootCovered(new Set([file]), tool, root)).toBe(expected) + }, + ) }) describe('sortByDepthThenPath', () => { @@ -274,4 +280,108 @@ describe('sortByDepthThenPath', () => { { dir: '/repo/b/nested' }, ]) }) + + it('puts the solutions of a directory before its projects', () => { + expect( + sortByDepthThenPath( + [ + { dir: '/repo', entryFile: 'App.csproj' }, + { dir: '/repo', entryFile: 'Zed.slnx' }, + { dir: '/repo', entryFile: 'App.sln' }, + ], + '/repo', + 'dotnet', + ), + ).toEqual([ + { dir: '/repo', entryFile: 'App.sln' }, + { dir: '/repo', entryFile: 'Zed.slnx' }, + { dir: '/repo', entryFile: 'App.csproj' }, + ]) + }) +}) + +function listing(members: string[]) { + return async () => members +} + +async function failToList(): Promise { + throw new Error('listed') +} + +async function slnxUnreadable({ + entryFile, +}: { + entryFile?: string | undefined +}): Promise { + return entryFile?.endsWith('.slnx') ? undefined : [] +} + +async function unreadable(): Promise { + return undefined +} + +describe('isRootCovered', () => { + const root = { dir: '/repo', entryFile: 'App.slnx' } + const covers = ( + covered: string[], + list: Parameters[4], + r: BuildRoot = root, + ) => isRootCovered(new Set(covered), 'dotnet', r, {}, list) + + it('covers a solution once every project it builds is covered', async () => { + const covered = ['/repo/src/A/A.csproj', '/repo/src/B/B.csproj'] + expect(await covers(covered, listing(covered))).toBe(true) + expect( + await covers( + covered, + listing(['/repo/src/A/A.csproj', '/repo/src/C/C.csproj']), + ), + ).toBe(false) + }) + + it('lists no members while nothing is covered and no twin exists, or for a project or a POM', async () => { + expect(await covers([], failToList)).toBe(false) + expect( + await covers(['/x'], failToList, { dir: '/repo', entryFile: 'A.csproj' }), + ).toBe(false) + expect( + await isRootCovered( + new Set(['/x']), + 'maven', + { dir: '/repo', entryFile: 'pom.xml' }, + {}, + failToList, + ), + ).toBe(false) + }) + + describe('with an App.sln and App.slnx pair', () => { + let dir = '' + const at = (entryFile: string) => ({ dir, entryFile }) + + beforeAll(async () => { + dir = await fs.mkdtemp(path.join(tmpdir(), 'entry-covered-')) + await fs.writeFile(path.join(dir, 'App.sln'), '') + await fs.writeFile(path.join(dir, 'App.slnx'), '') + }) + + afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it('takes a solution the SDK cannot read as covered by its twin in the other format', async () => { + const covered = ['/repo/src/A/A.csproj'] + expect(await covers(covered, slnxUnreadable, at('App.slnx'))).toBe(true) + // Also before anything is covered, e.g. when the twin built nothing. + expect(await covers([], slnxUnreadable, at('App.slnx'))).toBe(true) + expect(await covers(covered, slnxUnreadable, at('Other.slnx'))).toBe( + false, + ) + }) + + it('builds an unreadable solution whose twin is unreadable too, so its failure shows', async () => { + expect(await covers([], unreadable, at('App.sln'))).toBe(false) + expect(await covers([], unreadable, at('App.slnx'))).toBe(false) + }) + }) }) diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts index 80c7778ab..f472e5703 100644 --- a/src/commands/manifest/generate-recursive-manifests.test.mts +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -15,10 +15,19 @@ vi.mock('../../utils/socket-json.mts', () => ({ vi.mock('./run-manifest-facts.mts', () => ({ runManifestFacts: vi.fn(), })) +vi.mock('./scripts/run.mts', async importOriginal => ({ + ...(await importOriginal()), + listDotnetEntryProjects: vi.fn(async () => undefined), + skipWithoutDotnetSdk: vi.fn(async () => false), +})) import { buildRootPath } from './discover-manifest-roots.mts' import { generateRecursiveManifests } from './generate-recursive-manifests.mts' import { runManifestFacts } from './run-manifest-facts.mts' +import { + listDotnetEntryProjects, + skipWithoutDotnetSdk, +} from './scripts/run.mts' import { testPath } from '../../../test/utils.mts' import { readOrDefaultSocketJson, @@ -518,6 +527,67 @@ describe('generateRecursiveManifests', () => { }) }) + it('skips .NET builds, without failing, where no .NET SDK runs', async () => { + const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-no-sdk-')) + try { + await fs.writeFile(path.join(dir, 'pom.xml'), '') + await fs.mkdir(path.join(dir, 'samples')) + await fs.writeFile(path.join(dir, 'samples', 'Stray.csproj'), '') + vi.mocked(skipWithoutDotnetSdk).mockResolvedValueOnce(true) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, 'pom.xml.socket.facts.json'), + projects: [], + })) + const outcomes = await generateRecursiveManifests({ + cwd: dir, + verbose: false, + }) + + expect( + outcomes.map(o => `${o.ecosystem}:${o.entryFile}:${o.status}`).sort(), + ).toEqual(['dotnet:Stray.csproj:skippedNoSdk', 'maven:pom.xml:generated']) + expect( + vi.mocked(runManifestFacts).mock.calls.map(([o]) => o.ecosystem), + ).toEqual(['maven']) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + it("does not list a solution's members for coverage another tool's builds recorded", async () => { + const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-per-tool-')) + try { + await fs.writeFile(path.join(dir, 'pom.xml'), '') + await fs.writeFile(path.join(dir, 'App.sln'), '') + vi.mocked(listDotnetEntryProjects).mockClear() + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, entryFile }) => ({ + factsPath: path.join(cwd, `${entryFile}.socket.facts.json`), + projects: [ + { + type: 'maven', + name: 'app', + subprojectDir: '', + manifestFiles: [{ file: 'pom.xml' }], + children: [], + }, + ], + }), + ) + const outcomes = await generateRecursiveManifests({ + cwd: dir, + verbose: false, + }) + + expect( + outcomes.map(o => `${o.ecosystem}:${o.entryFile}:${o.status}`).sort(), + ).toEqual(['dotnet:App.sln:generated', 'maven:pom.xml:generated']) + expect(listDotnetEntryProjects).not.toHaveBeenCalled() + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + it('resolves each build root its own nearest socket.json instead of only the root config', async () => { vi.mocked(readSocketJsonCascade).mockImplementation( (dir, _boundaryDir, fallback) => diff --git a/src/commands/manifest/generate_auto_manifest.test.mts b/src/commands/manifest/generate_auto_manifest.test.mts index 47c5b8e9d..78de7def7 100644 --- a/src/commands/manifest/generate_auto_manifest.test.mts +++ b/src/commands/manifest/generate_auto_manifest.test.mts @@ -16,6 +16,9 @@ vi.mock('./convert_gradle_to_maven.mts', () => ({ vi.mock('./convert_sbt_to_maven.mts', () => ({ convertSbtToMaven: vi.fn(async () => undefined), })) +vi.mock('./convert-dotnet-to-facts.mts', () => ({ + convertDotnetToFacts: vi.fn(async () => undefined), +})) vi.mock('./convert-gradle-to-facts.mts', () => ({ convertGradleToFacts: vi.fn(async () => undefined), })) @@ -25,6 +28,10 @@ vi.mock('./convert-sbt-to-facts.mts', () => ({ vi.mock('./handle-manifest-conda.mts', () => ({ handleManifestConda: vi.fn(async () => undefined), })) +vi.mock('./scripts/run.mts', async importOriginal => ({ + ...(await importOriginal()), + skipWithoutDotnetSdk: vi.fn(async () => false), +})) vi.mock('../../utils/socket-json.mts', () => ({ readOrDefaultSocketJson: vi.fn(() => ({})), })) @@ -32,10 +39,12 @@ vi.mock('../../utils/socket-json.mts', () => ({ import { logger } from '@socketsecurity/registry/lib/logger' import { extractBazelToMaven } from './bazel/extract_bazel_to_maven.mts' +import { convertDotnetToFacts } from './convert-dotnet-to-facts.mts' import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' import { generateAutoManifest } from './generate_auto_manifest.mts' import { handleManifestConda } from './handle-manifest-conda.mts' +import { skipWithoutDotnetSdk } from './scripts/run.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import type { SocketJson } from '../../utils/socket-json.mts' @@ -46,6 +55,7 @@ const baseDetected = { conda: false, condaFile: '', count: 0, + dotnet: false, gradle: false, maven: false, sbt: false, @@ -401,3 +411,73 @@ describe('generateAutoManifest — conda branch', () => { process.exitCode = undefined }) }) + +function generateForDotnet() { + return generateAutoManifest({ + cwd: '/tmp/repo', + detected: { ...baseDetected, dotnet: true, count: 1 }, + outputKind: 'text', + verbose: false, + }) +} + +describe('generateAutoManifest — dotnet branch', () => { + beforeEach(() => { + vi.mocked(convertDotnetToFacts).mockClear() + vi.mocked(skipWithoutDotnetSdk).mockReset() + vi.mocked(readOrDefaultSocketJson).mockReturnValue({} as SocketJson) + }) + + it('generates facts with the socket.json dotnet defaults', async () => { + vi.mocked(readOrDefaultSocketJson).mockReturnValue({ + defaults: { + manifest: { + dotnet: { + bin: '/opt/dotnet/dotnet', + dotnetOpts: '-p:Configuration=Release', + includeConfigs: 'net8.0', + ignoreUnresolved: true, + }, + }, + }, + } as SocketJson) + await generateForDotnet() + expect(skipWithoutDotnetSdk).toHaveBeenCalledWith( + '/opt/dotnet/dotnet', + '/tmp/repo', + ) + expect(convertDotnetToFacts).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + bin: '/opt/dotnet/dotnet', + buildOpts: ['-p:Configuration=Release'], + ignoreUnresolved: true, + includeConfigs: 'net8.0', + }), + cwd: '/tmp/repo', + }), + ) + }) + + it('skips the build where no .NET SDK runs', async () => { + vi.mocked(skipWithoutDotnetSdk).mockResolvedValue(true) + await generateForDotnet() + expect(convertDotnetToFacts).not.toHaveBeenCalled() + }) + + it('does not run when defaults.manifest.dotnet.disabled is true', async () => { + vi.mocked(readOrDefaultSocketJson).mockReturnValue({ + defaults: { manifest: { dotnet: { disabled: true } } }, + } as SocketJson) + await generateForDotnet() + expect(skipWithoutDotnetSdk).not.toHaveBeenCalled() + expect(convertDotnetToFacts).not.toHaveBeenCalled() + }) + + it('aborts the run when the facts generator fails', async () => { + vi.mocked(convertDotnetToFacts).mockResolvedValueOnce(null) + await expect(generateForDotnet()).rejects.toThrow( + /Auto-manifest generation failed for the dotnet project/, + ) + }) +}) diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts index 45b45cf77..e55a8892a 100644 --- a/src/commands/manifest/run-manifest-facts.test.mts +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -52,19 +52,24 @@ const baseArgs = { verbose: false, } -describe('runManifestFacts - javaHome', () => { - let cwd = '' +let cwd = '' - beforeEach(async () => { - cwd = await fs.mkdtemp(path.join(tmpdir(), 'run-manifest-facts-')) - vi.mocked(runManifestScript).mockReset() +beforeEach(async () => { + cwd = await fs.mkdtemp(path.join(tmpdir(), 'run-manifest-facts-')) + vi.mocked(runManifestScript).mockReset() + process.exitCode = undefined +}) +afterEach(async () => { + await fs.rm(cwd, { recursive: true, force: true }) + process.exitCode = undefined +}) + +describe('runManifestFacts - javaHome', () => { + beforeEach(() => { delete process.env[ENV_VAR] - process.exitCode = undefined }) - afterEach(async () => { - await fs.rm(cwd, { recursive: true, force: true }) + afterEach(() => { delete process.env[ENV_VAR] - process.exitCode = undefined }) it('passes a literal javaHome straight through as JAVA_HOME', async () => { @@ -107,18 +112,6 @@ describe('runManifestFacts - javaHome', () => { }) describe('runManifestFacts - sidecar', () => { - let cwd = '' - - beforeEach(async () => { - cwd = await fs.mkdtemp(path.join(tmpdir(), 'run-manifest-facts-')) - vi.mocked(runManifestScript).mockReset() - process.exitCode = undefined - }) - afterEach(async () => { - await fs.rm(cwd, { recursive: true, force: true }) - process.exitCode = undefined - }) - it('keys the sidecar by the symlink-resolved factsPath, not the raw cwd-joined one', async () => { const result = okResult(cwd) result.facts.projects = [ @@ -166,18 +159,6 @@ describe('runManifestFacts - sidecar', () => { }) describe('runManifestFacts - facts file naming', () => { - let cwd = '' - - beforeEach(async () => { - cwd = await fs.mkdtemp(path.join(tmpdir(), 'run-manifest-facts-')) - vi.mocked(runManifestScript).mockReset() - process.exitCode = undefined - }) - afterEach(async () => { - await fs.rm(cwd, { recursive: true, force: true }) - process.exitCode = undefined - }) - it('gives builds sharing a directory distinct facts files', async () => { const sidecarAcc: SidecarAccumulator = new Map() const outcomes = [] @@ -278,18 +259,6 @@ describe('runManifestFacts - facts file naming', () => { }) describe('runManifestFacts - sbt build detection', () => { - let cwd = '' - - beforeEach(async () => { - cwd = await fs.mkdtemp(path.join(tmpdir(), 'run-manifest-facts-')) - vi.mocked(runManifestScript).mockReset() - process.exitCode = undefined - }) - afterEach(async () => { - await fs.rm(cwd, { recursive: true, force: true }) - process.exitCode = undefined - }) - it('fails without running sbt when the directory has no sbt build', async () => { const outcome = await runManifestFacts({ ...baseArgs, @@ -321,3 +290,39 @@ describe('runManifestFacts - sbt build detection', () => { }, ) }) + +describe('runManifestFacts - crashed build', () => { + it('fails closed on a dotnet tool exit, whatever it recorded before', async () => { + vi.mocked(runManifestScript).mockResolvedValue({ + ...okResult(cwd), + code: 1, + factsFileName: 'App.sln.socket.facts.json', + stderr: 'socket-facts-dotnet: System.InvalidOperationException', + }) + + const outcome = await runManifestFacts({ + ...baseArgs, + bin: 'dotnet', + cwd, + ecosystem: 'dotnet', + entryFile: 'App.sln', + ignoreUnresolved: true, + }) + + expect(outcome).toBeNull() + expect(process.exitCode).toBe(1) + expect(await fs.readdir(cwd)).toEqual([]) + }) + + it('keeps what a JVM build resolved before a non-zero exit', async () => { + vi.mocked(runManifestScript).mockResolvedValue({ + ...okResult(cwd), + code: 1, + }) + + const outcome = await runManifestFacts({ ...baseArgs, cwd }) + + expect(outcome?.factsPath).toBe(path.join(cwd, 'pom.xml.socket.facts.json')) + expect(process.exitCode).toBeUndefined() + }) +}) diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index b78766fc5..53a717c23 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -7,6 +7,16 @@ import { accumulateSidecar, serializeSidecar } from './sidecar.mts' import type { SocketFactsSbom } from './facts.mts' import type { SidecarAccumulator } from './sidecar.mts' +function sidecarEntry( + facts: SocketFactsSbom, + artifactPaths: ReturnType['artifactPaths'], + factsFile: string, +) { + const acc: SidecarAccumulator = new Map() + accumulateSidecar(acc, facts, artifactPaths, factsFile) + return serializeSidecar(acc)[factsFile]! +} + // Each project's tree, written out with component ids; a repeat on the current // path (a cycle) is written without its children. function trees(facts: SocketFactsSbom): Record { @@ -81,10 +91,7 @@ describe('records → assemble → sidecar', () => { expect(facts.metadata).not.toHaveProperty('contentHash') expect(facts.metadata).not.toHaveProperty('schemaVersion') - const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, artifactPaths, '/abs/.socket.facts.json') - const resolved = serializeSidecar(acc) - const bucket = resolved['/abs/.socket.facts.json']! + const bucket = sidecarEntry(facts, artifactPaths, '/abs/.socket.facts.json') const byName = new Map(bucket.components.map(r => [r.name, r])) // First-party module: project-only (not a node), yet its source/output @@ -162,17 +169,12 @@ describe('records → assemble → sidecar', () => { const { artifactPaths, facts } = assembleFacts(parseRecords(records), { fileExists: p => p !== '/abs/gone.jar', }) - const acc: SidecarAccumulator = new Map() - accumulateSidecar( - acc, - facts, - artifactPaths, - '/abs/pom.xml.socket.facts.json', - ) const byId = new Map( - serializeSidecar(acc)['/abs/pom.xml.socket.facts.json']!.components.map( - c => [c.id, c], - ), + sidecarEntry( + facts, + artifactPaths, + '/abs/pom.xml.socket.facts.json', + ).components.map(c => [c.id, c]), ) expect(byId.get('g:ok:jar:1')?.targets).toEqual(['/abs/ok.jar']) @@ -254,13 +256,12 @@ describe('records → assemble → sidecar', () => { ':b': ['g:junit:jar:4', 'g:lib:jar:1(g:dep:jar:2)'], }) - const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, artifactPaths, '/abs/.socket.facts.json') const byName = new Map( - serializeSidecar(acc)['/abs/.socket.facts.json']!.projects.map(p => [ - p.name, - p, - ]), + sidecarEntry( + facts, + artifactPaths, + '/abs/.socket.facts.json', + ).projects.map(p => [p.name, p]), ) expect(byName.get('a')?.classpath).toEqual(['g:dep:jar:1', 'g:lib:jar:1']) expect(byName.get('b')?.classpath).toEqual([ @@ -400,14 +401,11 @@ describe('records → assemble → sidecar', () => { ':b': [':a(g:ext:jar:2)', 'g:b:jar:0.9'], }) - const acc: SidecarAccumulator = new Map() - accumulateSidecar( - acc, + const bucket = sidecarEntry( facts, artifactPaths, '/abs/gradle.socket.facts.json', ) - const bucket = serializeSidecar(acc)['/abs/gradle.socket.facts.json']! expect(bucket.components.find(c => c.id === ':a')?.firstParty).toBe(true) for (const project of bucket.projects) { expect(project).not.toHaveProperty('firstParty') @@ -544,6 +542,237 @@ describe('records → assemble → sidecar', () => { }) }) +describe('dotnet records → assemble', () => { + const DOTNET = [ + 'meta\tdotnet\t8.0.414\t', + 'buildRoot\t/abs', + 'entry\tApp.sln', + 'project\tsrc/App/App.csproj\t\tApp\t1.0.0\tsrc/App', + 'projectBuild\tsrc/App/App.csproj\tsrc/App/App.csproj', + 'projectBuild\tsrc/App/App.csproj\tDirectory.Packages.props', + 'project\tsrc/Lib/Lib.csproj\t\tAcme.Lib\t2.0.0\tsrc/Lib', + 'root\tsrc/App/App.csproj|net8.0|prod\tsrc/App/App.csproj\tnet8.0\t1', + 'node\tsrc/App/App.csproj|net8.0|prod\tacme.lib:2.0.0\t\tAcme.Lib\t2.0.0\t\t\t1\tsrc/Lib/Lib.csproj', + 'node\tsrc/App/App.csproj|net8.0|prod\tnewtonsoft.json:13.0.3\t\tNewtonsoft.Json\t13.0.3\t\t\t0\t', + 'edge\tsrc/App/App.csproj|net8.0|prod\tacme.lib:2.0.0\tnewtonsoft.json:13.0.3', + 'root\tsrc/Lib/Lib.csproj|net8.0|prod\tsrc/Lib/Lib.csproj\tnet8.0\t1', + 'node\tsrc/Lib/Lib.csproj|net8.0|prod\tnewtonsoft.json:13.0.3\t\tNewtonsoft.Json\t13.0.3\t\t\t1\t', + 'scanned\tnet8.0', + ].join('\r\n') + + it('assembles nuget purls without a namespace, with project-path ids', () => { + const { facts } = assembleFacts(parseRecords(DOTNET)) + expect(facts.metadata?.tool).toBe('dotnet') + expect(facts.components).toEqual([ + { + type: 'nuget', + name: 'Newtonsoft.Json', + version: '13.0.3', + id: 'newtonsoft.json:13.0.3', + direct: true, + }, + { + type: 'nuget', + name: 'Acme.Lib', + version: '2.0.0', + id: 'src/Lib/Lib.csproj', + direct: true, + firstParty: true, + }, + ]) + expect(facts.projects?.map(p => ({ ...p, children: undefined }))).toEqual([ + { + id: 'src/App/App.csproj', + type: 'nuget', + name: 'App', + version: '1.0.0', + subprojectDir: 'src/App', + manifestFiles: [ + { file: 'Directory.Packages.props' }, + { file: 'src/App/App.csproj' }, + ], + children: undefined, + }, + { + id: 'src/Lib/Lib.csproj', + type: 'nuget', + name: 'Acme.Lib', + version: '2.0.0', + subprojectDir: 'src/Lib', + children: undefined, + }, + ]) + expect(trees(facts)).toEqual({ + 'src/App/App.csproj': ['src/Lib/Lib.csproj(newtonsoft.json:13.0.3)'], + 'src/Lib/Lib.csproj': ['newtonsoft.json:13.0.3'], + }) + }) +}) + +describe('dotnet records → sidecar', () => { + it('carries assemblies, compile items and unresolved packages per nuget purl', () => { + const records = [ + 'meta\tdotnet\t8.0.414\t', + 'project\tApp.csproj\t\tApp\t1.0.0\t.', + 'projectSrc\tApp.csproj\t/abs/Program.cs', + 'projectTgt\tApp.csproj\t/abs/bin/App.dll', + 'root\tr\tApp.csproj\tnet8.0\t1', + 'node\tr\tlib:1.0.0\t\tLib\t1.0.0\t\t\t1\t', + 'file\tr\tlib:1.0.0\t/pkgs/lib/1.0.0/lib/net8.0/Lib.dll', + 'node\tr\tanalyzer:1.0.0\t\tAnalyzer\t1.0.0\t\t\t1\t', + 'node\tr\tgone:1.0.0\t\tGone\t1.0.0\t\t\t1\t', + 'unfetched\tr\tgone:1.0.0', + ].join('\n') + const { artifactPaths, facts } = assembleFacts(parseRecords(records), { + fileExists: () => true, + }) + const entry = sidecarEntry( + facts, + artifactPaths, + '/abs/App.csproj.socket.facts.json', + ) + expect(entry.components.map(c => [c.type, c.name, c.targets])).toEqual([ + ['nuget', 'Analyzer', []], + ['nuget', 'Gone', undefined], + ['nuget', 'Lib', ['/pkgs/lib/1.0.0/lib/net8.0/Lib.dll']], + ]) + expect(entry.projects).toMatchObject([ + { + id: 'App.csproj', + type: 'nuget', + sources: ['/abs/Program.cs'], + targets: ['/abs/bin/App.dll'], + dependencies: ['analyzer:1.0.0', 'gone:1.0.0', 'lib:1.0.0'], + }, + ]) + expect(entry.projects[0]).not.toHaveProperty('namespace') + }) +}) + +describe('dotnet records → per-framework sidecar', () => { + const records = [ + 'meta\tdotnet\t8.0.414\t', + 'project\tLib.csproj\t\tLib\t1.0.0\t.', + 'projectSrc\tLib.csproj\t/abs/L.cs\tnet8.0', + 'projectSrc\tLib.csproj\t/abs/obj/Lib.AssemblyInfo.cs\tnet8.0', + 'projectSrc\tLib.csproj\t/abs/Legacy.cs\tnetstandard2.0', + 'projectInfo\tLib.csproj\tnetstandard2.0\tBuildError\tMSB3644: reference assemblies not found', + 'projectTgt\tLib.csproj\t/abs/bin/net8.0/Lib.dll\tnet8.0', + 'projectInfo\tLib.csproj\tnet8.0\tTargetFrameworkMoniker\t.NETCoreApp,Version=v8.0', + 'projectInfo\tLib.csproj\tnet8.0\tDefineConstant\tNET8_0_OR_GREATER', + 'projectInfo\tLib.csproj\tnet8.0\tFrameworkReference\tMicrosoft.NETCore.App', + 'projectInfo\tLib.csproj\tnet8.0\tNullable\tenable', + 'projectInfo\tLib.csproj\tnet8.0\tReference\t/packs/ref/net8.0/System.Runtime.dll', + 'projectInfo\tLib.csproj\tnet8.0\tReference\t/pkgs/x/ref/net8.0/X.dll', + 'projectInfo\tLib.csproj\tnet8.0\tAnalyzer\t/packs/analyzers/Gen.dll', + 'projectUsing\tLib.csproj\tnet8.0\tSystem.Linq\t\t0', + 'projectUsing\tLib.csproj\tnet8.0\tSystem.Math\t\t1', + 'root\ta\tLib.csproj\tnet8.0\t0\tnet8.0\t\tnet8.0', + 'node\ta\tx:1.0.0\t\tX\t1.0.0\t\t\t1\t', + 'file\ta\tx:1.0.0\t/pkgs/x/lib/net8.0/X.dll', + 'compileFile\ta\tx:1.0.0\t/pkgs/x/ref/net8.0/X.dll', + 'node\ta\tgone:1.0.0\t\tGone\t1.0.0\t\t\t1\t', + 'unfetched\ta\tgone:1.0.0', + 'root\tp\tLib.csproj\tnet8.0\t1\tnet8.0\t\tnet8.0', + 'node\tp\tx:1.0.0\t\tX\t1.0.0\t\t\t1\t', + 'node\tp\tgone:1.0.0\t\tGone\t1.0.0\t\t\t1\t', + 'root\ts\tLib.csproj\tnetstandard2.0\t0\tnetstandard2.0\t\tnetstandard2.0', + 'node\ts\tx:1.0.0\t\tX\t1.0.0\t\t\t1\t', + 'file\ts\tx:1.0.0\t/pkgs/x/lib/netstandard2.0/X.dll', + ].join('\n') + + it('gives each project build its own context and each package its assemblies per framework', () => { + const { artifactPaths, facts } = assembleFacts(parseRecords(records), { + fileExists: () => true, + }) + const entry = sidecarEntry( + facts, + artifactPaths, + '/abs/Lib.csproj.socket.facts.json', + ) + expect(entry.projects[0]!.frameworks).toEqual([ + { + alias: 'net8.0', + framework: 'net8.0', + targetFrameworkMoniker: '.NETCoreApp,Version=v8.0', + language: undefined, + langVersion: undefined, + nullable: 'enable', + assemblyName: undefined, + buildError: undefined, + frameworkReferences: ['Microsoft.NETCore.App'], + defineConstants: ['NET8_0_OR_GREATER'], + usings: [ + { namespace: 'System.Linq' }, + { namespace: 'System.Math', static: true }, + ], + // Compiler order, not sorted. + sources: ['/abs/L.cs', '/abs/obj/Lib.AssemblyInfo.cs'], + references: [ + '/packs/ref/net8.0/System.Runtime.dll', + '/pkgs/x/ref/net8.0/X.dll', + ], + analyzers: ['/packs/analyzers/Gen.dll'], + targets: ['/abs/bin/net8.0/Lib.dll'], + dependencies: ['gone:1.0.0', 'x:1.0.0'], + classpath: ['gone:1.0.0', 'x:1.0.0'], + }, + expect.objectContaining({ + alias: 'netstandard2.0', + buildError: 'MSB3644: reference assemblies not found', + sources: ['/abs/Legacy.cs'], + references: [], + targets: [], + classpath: ['x:1.0.0'], + }), + ]) + const byName = Object.fromEntries( + entry.components.map(c => [c.name, c.frameworks]), + ) + expect(byName['X']).toEqual([ + { + framework: 'net8.0', + targets: ['/pkgs/x/lib/net8.0/X.dll'], + compile: ['/pkgs/x/ref/net8.0/X.dll'], + }, + { + framework: 'netstandard2.0', + targets: ['/pkgs/x/lib/netstandard2.0/X.dll'], + compile: [], + }, + ]) + // A root without files (the prod one) does not count as resolving it. + expect(byName['Gone']).toEqual([{ framework: 'net8.0' }]) + }) +}) + +describe('dotnet build without dependencies', () => { + it('still gets its compile context in the sidecar', () => { + const records = [ + 'meta\tdotnet\t8.0.414\t', + 'project\tLeaf.csproj\t\tLeaf\t1.0.0\t.', + 'projectSrc\tLeaf.csproj\t/abs/Leaf.cs\tnet8.0', + 'projectInfo\tLeaf.csproj\tnet8.0\tFramework\tnet8.0', + 'projectInfo\tLeaf.csproj\tnet8.0\tFrameworkReference\tMicrosoft.NETCore.App', + ].join('\n') + const { artifactPaths } = assembleFacts(parseRecords(records), { + fileExists: () => true, + }) + expect(artifactPaths.frameworksByProject?.get('Leaf.csproj')).toMatchObject( + [ + { + alias: 'net8.0', + framework: 'net8.0', + frameworkReferences: ['Microsoft.NETCore.App'], + sources: ['/abs/Leaf.cs'], + dependencies: [], + classpath: [], + }, + ], + ) + }) +}) + describe('parseRecords', () => { it('reads the build root the build reports', () => { expect( diff --git a/src/commands/manifest/scripts/resolution-report-nuget.test.mts b/src/commands/manifest/scripts/resolution-report-nuget.test.mts new file mode 100644 index 000000000..3cbc6b566 --- /dev/null +++ b/src/commands/manifest/scripts/resolution-report-nuget.test.mts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { classifyNugetFailure } from './resolution-report-nuget.mts' + +import type { FailureCategory } from './resolution-report-render.mts' + +const DETAILS_BY_CATEGORY = { + 'config-problem': [ + 'NU1004: The package references have changed for net8.0. Lock file is not consistent.', + 'NU1109: Detected package downgrade: Polly from 8.4.2 to centrally defined 7.2.4.', + 'NU1201: Project Fs is not compatible with netstandard2.0.', + 'NETSDK1147: To build this project, the following workloads must be installed: maui', + 'the design-time build failed: MSB3644: The reference assemblies for .NETFramework,Version=v4.8 were not found.', + ], + 'not-found': [ + 'NU1101: Unable to find package Foo. No packages exist with this id in source(s): nuget.org', + 'NU1102: Unable to find package Foo with version (>= 9.0.0)', + ], + other: [ + 'could not evaluate the project: The imported project "x.props" was not found.', + 'obj/project.assets.json belongs to B.csproj: the projects share an intermediate output directory, so restore output cannot be attributed', + 'runtime assemblies restore listed are missing from the package folders (lib/net8.0/Foo.dll); re-run `dotnet restore`', + ], + 'repository-or-network': [ + 'NU1301: Unable to load the service index for source https://feed/index.json.', + 'NU1301: Unable to load the service index for source https://feed/index.json. Response status code does not indicate success: 401 (Unauthorized).', + ], +} satisfies Partial> + +describe('classifyNugetFailure', () => { + it.for( + Object.entries(DETAILS_BY_CATEGORY).flatMap(({ 0: category, 1: details }) => + details.map(detail => [category, detail] as const), + ), + )('%s: %s', ({ 0: category, 1: detail }) => { + expect(classifyNugetFailure(detail)).toBe(category) + }) +}) diff --git a/src/commands/manifest/scripts/resolution-report-render.test.mts b/src/commands/manifest/scripts/resolution-report-render.test.mts index df3f42fd1..76293bca5 100644 --- a/src/commands/manifest/scripts/resolution-report-render.test.mts +++ b/src/commands/manifest/scripts/resolution-report-render.test.mts @@ -513,6 +513,36 @@ describe('failures checked against what resolved', () => { expect(r.summary).toContain(' - com.example:x:1.0 (in `:b`)') }) + it('blocks on a failure placed in no project', () => { + const r = renderResolutionErrorReport( + [ + f( + 'App.sln', + 'NU1201: Project Fs is not compatible with net48.', + '', + '', + ), + ], + ['net48'], + 'dotnet', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain(' - App.sln') + }) + + it('drops a bare module failure placed in no project behind its versioned form', () => { + const r = renderResolutionErrorReport( + [ + f('com.example:x', 'Could not find com.example:x.', '', ''), + f('com.example:x:1.0', 'Could not find com.example:x:1.0.', '', ''), + ], + ['compile'], + 'gradle', + ) + expect(r.summary).toContain(' - com.example:x:1.0') + expect(r.summary).not.toMatch(/- com\.example:x\s/) + }) + it('still reminds a reachability scan of install errors once failures are ignored', () => { const r = renderResolutionErrorReport( [f('com.example:missing:1.0', 'Could not find com.example:missing:1.0.')], diff --git a/src/commands/manifest/scripts/run.test.mts b/src/commands/manifest/scripts/run.test.mts index 05c727562..683c86fb0 100644 --- a/src/commands/manifest/scripts/run.test.mts +++ b/src/commands/manifest/scripts/run.test.mts @@ -2,24 +2,39 @@ import { promises as fs } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { logger } from '@socketsecurity/registry/lib/logger' import { spawn } from '@socketsecurity/registry/lib/spawn' -import { enumerateWorkspaces, runManifestScript } from './run.mts' +import { + enumerateWorkspaces, + listDotnetEntryProjects, + runManifestScript, + skipWithoutDotnetSdk, +} from './run.mts' vi.mock('@socketsecurity/registry/lib/spawn', () => ({ spawn: vi.fn(async () => ({ code: 0, stderr: '', stdout: '' })), })) -// The Maven extension jar exists only in a built checkout. +// The Maven extension jar and the dotnet tool exist only in a built checkout. +// Each dotnetDir() case pins its own SDK, so cases never share a probe. vi.mock('node:fs', async importOriginal => { const actual = await importOriginal() return { ...actual, - existsSync: (p: string) => p.endsWith('.jar') || actual.existsSync(p), + existsSync: (p: string) => + p.endsWith('.jar') || + p.endsWith('.dll') || + /[\\/]repo[\\/]dotnet-\d+[\\/]global\.json$/.test(p) || + actual.existsSync(p), } }) +beforeEach(() => { + vi.mocked(spawn).mockReset() +}) + describe('build tool output', () => { it.each([ ['inherit', ['inherit', 2, 'inherit']], @@ -128,3 +143,195 @@ describe('--exclude-paths anchoring', () => { ]) }) }) + +// The SDK version is cached per bin and directory, so each case gets its own. +let dotnetCase = 0 +function dotnetDir(): string { + dotnetCase += 1 + return path.join(path.sep, 'repo', `dotnet-${dotnetCase}`) +} + +// Answers `dotnet --version` with `sdk`, then runs the tool, which writes +// `records` to its --records file. +function mockDotnet(sdk: string, records = '', code = 0): void { + vi.mocked(spawn).mockImplementation((async ( + _bin: string, + args: readonly string[], + ) => { + if (args[0] === '--version') { + return { code: 0, stderr: '', stdout: `${sdk}\n` } + } + const at = args.indexOf('--records') + if (at < 0) { + throw new Error('the dotnet tool was run without --records') + } + await fs.writeFile(args[at + 1]!, records) + return { code, stderr: '', stdout: '' } + }) as any) +} + +function solutionOpts(projectDir: string) { + return { bin: 'dotnet', entryFile: 'App.sln', projectDir } +} + +function toolCall() { + return toolCalls()[0] +} + +function toolCalls() { + return vi.mocked(spawn).mock.calls.filter(c => c[1]?.[0] !== '--version') +} + +describe('dotnet tool invocation', () => { + it('runs the bundled tool on the build entry with the given options', async () => { + const projectDir = dotnetDir() + mockDotnet('8.0.414', 'buildRoot\t/repo\nentry\tApp.sln\n') + const result = await runManifestScript('dotnet', { + bin: '/opt/dotnet/dotnet', + entryFile: 'App.sln', + excludeBase: path.join(path.sep, 'repo'), + excludeConfigs: '*/*', + excludePaths: ['test'], + includeConfigs: 'net8.0', + projectDir, + stdio: 'pipe', + toolOpts: ['-p:Configuration=Release', '--locked-mode'], + withFiles: true, + }) + const { 0: bin, 1: args = [], 2: opts } = toolCall()! + expect(bin).toBe('/opt/dotnet/dotnet') + expect(args[0]).toMatch(/socket-facts-dotnet\.dll$/) + expect(args.slice(args.indexOf('--entry'))).toEqual([ + '--entry', + 'App.sln', + '--dotnet', + '/opt/dotnet/dotnet', + '--with-files', + '--include-configs', + 'net8.0', + '--exclude-configs', + '*/*', + '--exclude-paths', + '^(?:test)(?:/.*)?$', + '--exclude-base', + path.join(path.sep, 'repo'), + '--', + '-p:Configuration=Release', + '--locked-mode', + ]) + expect(opts?.cwd).toBe(projectDir) + expect(opts?.env).toBeUndefined() + expect(result.factsFileName).toBe('App.sln.socket.facts.json') + }) + + it('lets a preview SDK roll the tool forward onto a preview runtime', async () => { + mockDotnet('11.0.100-rc.3.26522.148') + await runManifestScript('dotnet', { + bin: 'dotnet', + entryFile: 'App.csproj', + env: { NUGET_PACKAGES: '/cache', PATH: '/bin' }, + projectDir: dotnetDir(), + stdio: 'pipe', + }) + expect(toolCall()?.[2]?.env).toEqual({ + DOTNET_ROLL_FORWARD_TO_PRERELEASE: '1', + NUGET_PACKAGES: '/cache', + PATH: '/bin', + }) + }) + + it('lists workspaces in the restore-free mode', async () => { + mockDotnet('8.0.414') + await enumerateWorkspaces('dotnet', { + bin: 'dotnet', + entryFile: 'App.sln', + projectDir: dotnetDir(), + stdio: 'pipe', + }) + expect(toolCall()?.[1]).toContain('--workspaces') + }) + + it('needs an entry file', async () => { + await expect( + runManifestScript('dotnet', { bin: 'dotnet', projectDir: dotnetDir() }), + ).rejects.toThrow(/solution or project file/) + }) +}) + +describe('skipWithoutDotnetSdk', () => { + it('skips, warning once per reason, where no SDK runs', async () => { + // Warnings are deduplicated for the whole run, so the reason is unique + // to this case. + const reason = `No .NET SDK matches ${dotnetDir()}.` + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + vi.mocked(spawn).mockImplementation((async () => { + throw Object.assign(new Error('exit 145'), { + code: 145, + stderr: `${reason}\nRequested SDK version: 99.0.100`, + stdout: '', + }) + }) as any) + try { + expect(await skipWithoutDotnetSdk('dotnet', dotnetDir())).toBe(true) + expect(await skipWithoutDotnetSdk('dotnet', dotnetDir())).toBe(true) + expect(warn).toHaveBeenCalledOnce() + expect(warn.mock.calls[0]?.[0]).toContain(reason) + } finally { + warn.mockRestore() + } + }) + + it('runs the build where an SDK answers', async () => { + mockDotnet('8.0.414') + expect(await skipWithoutDotnetSdk('dotnet', dotnetDir())).toBe(false) + }) + + it('probes the SDK once per global.json', async () => { + const pinned = dotnetDir() + mockDotnet('8.0.414') + for (const dir of ['a', path.join('b', 'c')]) { + // eslint-disable-next-line no-await-in-loop + expect(await skipWithoutDotnetSdk('dotnet', path.join(pinned, dir))).toBe( + false, + ) + } + expect( + vi.mocked(spawn).mock.calls.filter(c => c[1]?.[0] === '--version'), + ).toHaveLength(1) + }) +}) + +describe('listDotnetEntryProjects', () => { + it('resolves the members against the build directory', async () => { + const projectDir = dotnetDir() + mockDotnet('8.0.414', 'member\tsrc/A/A.csproj\nmember\tsrc/B/B.csproj\n') + expect(await listDotnetEntryProjects(solutionOpts(projectDir))).toEqual([ + path.join(projectDir, 'src', 'A', 'A.csproj'), + path.join(projectDir, 'src', 'B', 'B.csproj'), + ]) + }) + + it('cannot tell when the SDK cannot read the solution', async () => { + mockDotnet( + '8.0.414', + 'failure\tApp.slnx\tcould not read the solution\t\t\n', + ) + expect( + await listDotnetEntryProjects(solutionOpts(dotnetDir())), + ).toBeUndefined() + }) + + it('lists nothing when the tool exits non-zero', async () => { + mockDotnet('8.0.414', 'member\tsrc/A/A.csproj\n', 1) + expect(await listDotnetEntryProjects(solutionOpts(dotnetDir()))).toEqual([]) + }) + + it('lists an entry once for the same options', async () => { + const opts = solutionOpts(dotnetDir()) + mockDotnet('8.0.414', 'member\tsrc/A/A.csproj\n') + await listDotnetEntryProjects(opts) + await listDotnetEntryProjects({ ...opts }) + await listDotnetEntryProjects({ ...opts, excludePaths: ['src/A'] }) + expect(toolCalls()).toHaveLength(2) + }) +}) diff --git a/src/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index 412819d19..6eae56a04 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -6,6 +6,7 @@ import { hasSidecarEntries, mergeResolvedPathsSidecars, serializeSidecar, + useOnlySocketFactsArtifactsFlags, } from './sidecar.mts' import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts' @@ -421,3 +422,28 @@ describe('compute-artifacts sidecar', () => { expect(hasResolvedPathsSidecarEntries({})).toBe(false) }) }) + +function entryOfType(type: string) { + return { projects: [], components: [{ type, name: 'x', id: 'x' }] } +} + +describe('useOnlySocketFactsArtifactsFlags', () => { + it('keeps the Maven-only flag every Coana release knows for JVM facts', () => { + expect(useOnlySocketFactsArtifactsFlags(undefined)).toEqual([ + '--maven-use-only-socket-facts', + ]) + expect( + useOnlySocketFactsArtifactsFlags({ + '/a/pom.xml.socket.facts.json': entryOfType('maven'), + }), + ).toEqual(['--maven-use-only-socket-facts']) + }) + + it('names every ecosystem once another one has facts', () => { + expect( + useOnlySocketFactsArtifactsFlags({ + '/a/App.sln.socket.facts.json': entryOfType('nuget'), + }), + ).toEqual(['--use-only-socket-facts-artifacts', 'maven,nuget']) + }) +}) diff --git a/src/commands/manifest/scripts/test/README.md b/src/commands/manifest/scripts/test/README.md index bb6c6ffdb..4a07aafe2 100644 --- a/src/commands/manifest/scripts/test/README.md +++ b/src/commands/manifest/scripts/test/README.md @@ -1,4 +1,4 @@ -# JVM manifest-script compatibility tests +# Manifest-script compatibility tests These exercise the bundled build-tool scripts — the Gradle init script (`socket-facts.init.gradle`), the sbt plugin (`socket-facts.plugin.scala`), the @@ -22,7 +22,7 @@ Actions home. Run it locally whenever you change one of the scripts or the Maven extension: ```sh -src/commands/manifest/scripts/test/run-compat.sh [gradle|sbt|maven|all] +src/commands/manifest/scripts/test/run-compat.sh [dotnet|gradle|sbt|maven|all] ``` The matrix needs several JDKs. Point `JDK8` / `JDK11` / `JDK17` / `JDK21` at JDK @@ -71,3 +71,27 @@ first run after clearing it pays for the download. The *stub* artifacts are evic from it before every run, so a run can never pass on a stale copy of the thing under test. Set `SOCKET_COMPAT_CACHE` to a fresh `mktemp -d` for a completely cold run. + +## dotnet + +`dotnet-compat/smoke-test.sh` runs the built CLI's `socket manifest dotnet` +over fixtures covering solutions (`.sln`, `.slnx`), multi-targeting, +target-framework-conditional project references, test projects, +`PrivateAssets` and `developmentDependency` packages, floating versions, +central package management with transitive pinning, `VersionOverride` and +`GlobalPackageReference`, lock files and locked mode, runtime-specific packages, +packages.config next to SDK projects, F# and VB, several builds in one +directory, `--exclude-paths`, `--include-configs` and failures. Each +PackageReference project's tree is compared with what +`dotnet list package --include-transitive` reports for the same restore. +`smoke-test-workspaces.sh` checks the restore-free `--workspaces` and +`--members` listings, `smoke-test-exclude-base.sh` scan-root-relative +`--exclude-paths`. + +- Rows are SDK versions, each pinned with a `global.json`; install them side by + side under `$SOCKET_COMPAT_CACHE/dotnet` with `dotnet-install.sh`. +- Dependencies are stub packages `make-stub-feed.py` writes into a local feed, + routed there by the fixtures' `nuget.config` package source mapping. Only the + SDK's own packs (targeting packs, `NETStandard.Library`) come from nuget.org. +- `NUGET_PACKAGES` and the NuGet HTTP cache live under the cache root, and the + stubs are evicted from it before every run. diff --git a/src/commands/manifest/scripts/test/dotnet-compat/.gitignore b/src/commands/manifest/scripts/test/dotnet-compat/.gitignore new file mode 100644 index 000000000..8923a28ce --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +packages/ +stub-feed/ +packages.lock.json +*.socket.facts.json diff --git a/src/commands/manifest/scripts/test/dotnet-compat/assert-dotnet-list.py b/src/commands/manifest/scripts/test/dotnet-compat/assert-dotnet-list.py new file mode 100644 index 000000000..d98117e10 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/assert-dotnet-list.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Compare a dotnet facts file with NuGet's own view of the same restore. + +`dotnet list package --include-transitive --format json` reports, per +PackageReference project and target framework, the top-level and transitive +packages restore resolved. Each project's tree in the facts file must hold +exactly those packages, at those versions, with the top-level ones direct. + +Runtime-specific targets are not part of `dotnet list`, so the facts file must +come from a run that skipped them (--exclude-configs '*/*'). SDK 7.x lists +ProjectReferences among the transitive packages; with it, entries named like +one of the build's own projects are left out. + +Usage: assert-dotnet-list.py [dotnet] +""" +import json +import os +import subprocess +import sys + + +def nuget_view(entry, dotnet): + cwd = os.path.dirname(os.path.abspath(entry)) + # Where available, read the restore under test instead of restoring again + # (a restore of its own runs NuGet audit, which a project may fail on). + help_text = subprocess.run( + [dotnet, 'list', 'package', '--help'], capture_output=True, text=True, cwd=cwd, + ).stdout + no_restore = ['--no-restore'] if '--no-restore' in help_text else [] + # Exits non-zero, still printing its JSON, when a solution member is not + # restored, as one the solution's configuration does not build. + out = subprocess.run( + [dotnet, 'list', entry, 'package', '--include-transitive', '--format', 'json', *no_restore], + check=False, + capture_output=True, + text=True, + cwd=cwd, + ).stdout + view = {} + for project in json.loads(out).get('projects', []): + if 'frameworks' not in project: + continue + direct, every = set(), set() + for framework in project.get('frameworks', []): + for pkg in framework.get('topLevelPackages', []): + key = (pkg['id'].lower(), pkg['resolvedVersion']) + direct.add(key) + every.add(key) + for pkg in framework.get('transitivePackages', []): + every.add((pkg['id'].lower(), pkg['resolvedVersion'])) + view[os.path.realpath(project['path'])] = (direct, every) + return view + + +def facts_view(facts, build_root): + components = facts['components'] + deps = facts.get('dependencies', []) + first_party = {p['id'] for p in facts.get('projects', [])} + + def key(index): + c = components[index] + return None if c['id'] in first_party else (c['name'].lower(), c.get('version', '')) + + view = {} + for project in facts.get('projects', []): + direct = {key(deps[i]['component']) for i in project['children']} - {None} + every, stack, seen = set(), list(project['children']), set() + while stack: + i = stack.pop() + if i in seen: + continue + seen.add(i) + k = key(deps[i]['component']) + if k: + every.add(k) + stack.extend(deps[i].get('children', [])) + path = os.path.realpath(os.path.join(build_root, project['id'])) + view[path] = (direct, every) + return view + + +def main(): + facts_path, entry = sys.argv[1], os.path.abspath(sys.argv[2]) + dotnet = sys.argv[3] if len(sys.argv) > 3 else 'dotnet' + with open(facts_path) as f: + facts = json.load(f) + ours = facts_view(facts, os.path.dirname(os.path.abspath(facts_path))) + sdk = subprocess.run( + [dotnet, '--version'], check=True, capture_output=True, text=True, + cwd=os.path.dirname(entry), + ).stdout.strip() + own = {p['name'].lower() for p in facts.get('projects', [])} if sdk.startswith('7.') else set() + theirs = { + path: ({k for k in direct if k[0] not in own}, {k for k in every if k[0] not in own}) + for path, (direct, every) in nuget_view(entry, dotnet).items() + } + if not theirs: + sys.exit(f'dotnet list reported no project of {entry}') + problems = [] + for path, (direct, every) in sorted(theirs.items()): + if path not in ours: + problems.append(f'{path}: missing from the facts file') + continue + our_direct, our_every = ours[path] + for label, a, b in (('direct', our_direct, direct), ('resolved', our_every, every)): + if a != b: + problems.append( + f'{path}: {label} differs; only in facts: {sorted(a - b)}; only in dotnet list: {sorted(b - a)}' + ) + if problems: + print('\n'.join(problems)) + sys.exit(1) + print(f'facts match dotnet list for {len(theirs)} project(s)') + + +if __name__ == '__main__': + main() diff --git a/src/commands/manifest/scripts/test/dotnet-compat/assert-facts.py b/src/commands/manifest/scripts/test/dotnet-compat/assert-facts.py new file mode 100644 index 000000000..92c585fe1 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/assert-facts.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Fixture-specific assertions for the dotnet compat smoke test. + +Usage: assert-facts.py [phase] + phase: (default) the plain runs; exclude-paths, include-configs, + ignore-unresolved: the option runs; records: the --with-files records. +""" +import json +import os +import sys + +WORK, SDK, CPM_ENTRY = sys.argv[1], sys.argv[2], sys.argv[3] +PHASE = sys.argv[4] if len(sys.argv) > 4 else 'default' +SDK_MAJOR = int(SDK.split('.')[0]) +problems = [] + + +def check(cond, message): + if not cond: + problems.append(message) + + +def load(rel): + path = os.path.join(WORK, rel) + check(os.path.exists(path), f'{rel}: missing') + if not os.path.exists(path): + return {'components': [], 'projects': [], 'dependencies': []} + with open(path) as f: + return json.load(f) + + +def comps(facts, name): + return [c for c in facts['components'] if c['name'] == name] + + +def comp(facts, name, version=None): + found = [c for c in comps(facts, name) if version is None or c.get('version') == version] + check(len(found) == 1, f'{name}@{version}: expected one component, got {found}') + return found[0] if found else {} + + +def project_ids(facts): + return sorted(p['id'] for p in facts.get('projects', [])) + + +def common(rel, facts): + check(facts.get('metadata', {}).get('tool') == 'dotnet', f'{rel}: metadata.tool') + for c in facts['components'] + facts.get('projects', []): + check(c.get('type') == 'nuget', f'{rel}: {c["id"]} type {c.get("type")}') + check('namespace' not in c, f'{rel}: {c["id"]} carries a namespace key') + + +def default_phase(): + basic = load('basic/Basic.sln.socket.facts.json') + common('basic', basic) + check( + project_ids(basic) == [ + 'src/App/App.csproj', 'src/Extra/Extra.csproj', 'src/Lib/Lib.csproj', + 'test/App.Tests/App.Tests.csproj', + ], + f'basic projects: {project_ids(basic)}', + ) + lib = next(p for p in basic['projects'] if p['id'] == 'src/Lib/Lib.csproj') + check((lib['name'], lib.get('version')) == ('Acme.Lib', '2.3.4'), f'Lib identity: {lib}') + check(comp(basic, 'Acme.Lib').get('firstParty') is True, 'Lib as a component is first-party') + check(comp(basic, 'Acme.Lib').get('id') == 'src/Lib/Lib.csproj', 'Lib component id') + check(comp(basic, 'Demo.Ext.Floating').get('version') == '1.1.0', 'floating 1.* resolves to 1.1.0') + check(comp(basic, 'Demo.Ext.Tool').get('dev') is None, 'Tool is prod') + check(comp(basic, 'Demo.Ext.Shared').get('dev') is None, 'Shared is prod') + for dev in ('Demo.Ext.Analyzer', 'Demo.Ext.TestSdk', 'Demo.Ext.Harness', 'Demo.Ext.HarnessCore'): + check(comp(basic, dev).get('dev') is True, f'{dev} is dev') + check(comp(basic, 'Demo.Ext.Analyzer').get('direct') is True, 'Analyzer is direct') + check(comp(basic, 'Demo.Ext.HarnessCore').get('direct') is None, 'HarnessCore is transitive') + check(bool(comps(basic, 'NETStandard.Library')), 'netstandard2.0 target read') + app = next(p for p in basic['projects'] if p['id'] == 'src/App/App.csproj') + check(app.get('manifestFiles') == [{'file': 'src/App/App.csproj'}], f'App manifestFiles {app.get("manifestFiles")}') + + cpm_facts = f'{CPM_ENTRY}.socket.facts.json' + cpm = load(cpm_facts) + common('cpm', cpm) + pinned = comp(cpm, 'Demo.Ext.Shared') + check(pinned.get('version') == '2.0.0' and not pinned.get('direct'), f'transitive pin: {pinned}') + check({c.get('version') for c in comps(cpm, 'Demo.Ext.Harness')} == {'1.0.0', '1.1.0'}, 'VersionOverride') + if SDK_MAJOR >= 7: + # GlobalPackageReference needs NuGet 6.4 (SDK 7.0.100). + analyzer = comp(cpm, 'Demo.Ext.Analyzer') + check(analyzer.get('direct') and analyzer.get('dev'), f'GlobalPackageReference: {analyzer}') + base = os.path.dirname(CPM_ENTRY) + core_id = os.path.relpath('cpm/src/Core/Core.csproj', base) + core = next((p for p in cpm['projects'] if p['id'] == core_id), {}) + files = {m['file'] for m in core.get('manifestFiles', [])} + want = {os.path.relpath(f, base) for f in ('cpm/Directory.Packages.props', 'cpm/src/Core/Core.csproj', 'cpm/src/Core/packages.lock.json')} + check(files == want, f'Core manifestFiles {files} != {want}') + + rid = load('rid/Rid.csproj.socket.facts.json') + check(bool(comps(rid, 'runtime.win-x64.Demo.Ext.Native')), 'runtime-specific package from runtime.json') + + pc = load('packages-config/Mixed.sln.socket.facts.json') + common('packages-config', pc) + legacy = next((p for p in pc['projects'] if p['id'] == 'Legacy/Legacy.csproj'), {}) + check({m['file'] for m in legacy.get('manifestFiles', [])} == {'Legacy/Legacy.csproj', 'Legacy/packages.config'}, f'Legacy manifestFiles {legacy}') + check(comp(pc, 'Demo.Ext.Shared', '1.0.0').get('direct') is True, 'packages.config versions normalize') + check(comp(pc, 'Demo.Ext.Analyzer').get('dev') is True, 'developmentDependency is dev') + check(comp(pc, 'Demo.Ext.Logging').get('direct') is True, 'Modern restored alongside Legacy') + + langs = load('langs/Vb/Vb.vbproj.socket.facts.json') + check(project_ids(langs) == ['../Fs/Fs.fsproj', 'Vb.vbproj'], f'langs projects {project_ids(langs)}') + check(bool(comps(langs, 'FSharp.Core')), 'implicit FSharp.Core') + + for rel in ('multi/One.sln', 'multi/Two.sln', 'multi/Stray.csproj'): + load(f'{rel}.socket.facts.json') + check(not os.path.exists(os.path.join(WORK, 'multi/One/One.csproj.socket.facts.json')), 'covered project built alone') + check(not os.path.exists(os.path.join(WORK, 'multi/One.slnx.socket.facts.json')), 'covered solution built again') + + for name, package in (('A', 'Demo.Ext.Tool'), ('B', 'Demo.Ext.Logging')): + pair = load(f'pair/{name}.csproj.socket.facts.json') + check(project_ids(pair) == [f'{name}.csproj'], f'pair {name} projects {project_ids(pair)}') + check(bool(comps(pair, package)), f'pair {name} resolves {package}') + + dev = load('dev/Dev.csproj.socket.facts.json') + for name, is_dev in (('Demo.Ext.Tool', None), ('Demo.Ext.Shared', None), ('Demo.Ext.Logging', True), ('Demo.Ext.Analyzer', True), ('Demo.Ext.Payload', None), ('Demo.Ext.BuildTool', True)): + check(comp(dev, name).get('dev') is is_dev, f'{name} dev={comp(dev, name).get("dev")}, want {is_dev}') + + load('sibling/sln/All.sln.socket.facts.json') + check(not os.path.exists(os.path.join(WORK, 'sibling/src/A/A.csproj.socket.facts.json')), 'project a sibling solution covers built alone') + + with open(os.path.join(WORK, 'missing.log')) as f: + log = f.read() + check('Demo.Ext.DoesNotExist' in log and 'NU1101' in log, 'missing package reported') + with open(os.path.join(WORK, 'broken.log')) as f: + log = f.read() + check('could not evaluate the project' in log, 'evaluation failure reported') + + +def records(name): + rows = [] + with open(os.path.join(WORK, name)) as f: + for line in f: + rows.append(line.rstrip('\n').split('\t')) + return rows + + +# Some SDKs' MSBuild finds no .NET Framework reference assemblies off Windows, +# which fails the legacy project's design-time build there, as its compile would. +def package_failures(rows): + return [r for r in rows if r[0] == 'failure' and not r[2].startswith('the design-time build failed: MSB3644')] + + +def info_rows(rows, project, name, config=None): + return [r for r in rows if r[0] == 'projectInfo' and r[1] == project and r[3] == name and config in (None, r[2])] + + +def records_phase(): + basic = records('basic.tsv') + files = {r[2]: r[3] for r in basic if r[0] == 'file'} + check(any(k.startswith('demo.ext.tool:') for k in files), 'Tool assembly recorded') + check(not any(k.startswith('demo.ext.analyzer:') for k in files), 'analyzer has no runtime assembly') + check(all(os.path.exists(p) for p in files.values()), 'recorded assemblies exist') + lib_roots = [r for r in basic if r[0] == 'root' and r[2] == 'src/Lib/Lib.csproj'] + check({r[5] for r in lib_roots} == {'net6.0', 'netstandard2.0'}, f'root frameworks {[r[5] for r in lib_roots]}') + check(all(r[7] == r[3] for r in lib_roots), f'root aliases {[(r[3], r[7]) for r in lib_roots]}') + info = {(r[2], r[3], r[4]) for r in basic if r[0] == 'projectInfo' and r[1] == 'src/Lib/Lib.csproj'} + check(('net6.0', 'TargetFrameworkMoniker', '.NETCoreApp,Version=v6.0') in info, 'net6.0 moniker recorded') + check(('net6.0', 'DefineConstant', 'NET6_0_OR_GREATER') in info, 'implicit framework symbols recorded') + check(('net6.0', 'FrameworkReference', 'Microsoft.NETCore.App') in info, 'framework reference recorded') + check(any(r[0] == 'compileFile' and r[2].startswith('demo.ext.tool:') for r in basic), 'compile assemblies recorded') + sources = [r[2] for r in basic if r[0] == 'projectSrc'] + check(any(s.endswith('/shared/Linked.cs') for s in sources), 'linked source recorded') + check(any(s.endswith('.AssemblyInfo.cs') for s in sources), 'generated source recorded') + errors = [r for r in basic if r[0] == 'projectInfo' and r[3] == 'BuildError'] + check(not errors, f'design-time build errors {errors}') + refs = {r[4] for r in info_rows(basic, 'src/Lib/Lib.csproj', 'Reference', 'net6.0')} + check(any(p.endswith('/System.Runtime.dll') for p in refs), 'framework reference assemblies recorded') + # The stub packages' empty assemblies are not references the compiler + # would take; a project reference's output is, built or not. + check(any(p.endswith('/Extra.dll') for p in refs), 'project reference recorded') + analyzers = {r[4] for r in info_rows(basic, 'src/Lib/Lib.csproj', 'Analyzer', 'net6.0')} + check(any('demo.ext.analyzer' in p.lower() for p in analyzers), f'analyzers {analyzers}') + + netstandard = records('basic-netstandard.tsv') + built = {(r[1], r[3]) for r in netstandard if r[0] == 'projectSrc'} + built |= {(r[1], r[2]) for r in netstandard if r[0] == 'projectInfo'} + check(built and {c for _, c in built} == {'netstandard2.0'}, f'builds outside --include-configs {sorted(built)}') + + stale = records('basic-stale.tsv') + check(any(r[0] == 'failure' and 'Demo.Ext.DoesNotExist' in '\t'.join(r) for r in stale), 'failed restore reported') + reused = {r[2] for r in stale if r[0] == 'root'} - {'test/App.Tests/App.Tests.csproj'} + check(not reused, f'projects read from a previous restore {sorted(reused)}') + unknown = {r[3] for r in stale if r[0] == 'unscannable' and 'did not restore this project' in r[2]} + check({'src/App/App.csproj', 'src/Extra/Extra.csproj', 'src/Lib/Lib.csproj'} <= unknown, f'unrestored projects reported {sorted(unknown)}') + + designtime = records('designtime.tsv') + failed = [r for r in designtime if r[0] == 'failure' and 'the design-time build failed' in r[2]] + check(len(failed) == 1 and 'generated sources unavailable' in failed[0][2], f'design-time failure {failed}') + + langs = records('langs.tsv') + vb = {r[4] for r in info_rows(langs, 'Vb.vbproj', 'DefineConstant')} + check({'DEBUG=-1', 'TRACE=-1'} <= vb, f'VB compiler constants {vb}') + fs = [r[2] for r in langs if r[0] == 'projectSrc' and r[1] == '../Fs/Fs.fsproj'] + check(len(fs) >= 2 and fs[-1].endswith('/Library.fs'), f'F# sources in compile order {fs}') + check(not any(r[0] == 'failure' for r in basic), f'basic failures {[r for r in basic if r[0] == "failure"]}') + + missing = records('pc-missing.tsv') + unfetched = {r[2] for r in missing if r[0] == 'unfetched'} + check(unfetched == {'demo.ext.tool:1.0.0', 'demo.ext.shared:1.0.0'}, f'unfetched {unfetched}') + check(len(package_failures(missing)) == 2, 'missing packages.config assemblies fail') + + present = records('pc-present.tsv') + check(not package_failures(present) and not any(r[0] == 'unfetched' for r in present), 'installed packages.config resolves') + legacy_roots = {(r[3], r[5], r[7]) for r in present if r[0] == 'root' and r[2] == 'Legacy/Legacy.csproj'} + check(legacy_roots == {('net48', 'net48', 'net48')}, f'Legacy roots (config, framework, alias) {legacy_roots}') + present_files = {r[2] for r in present if r[0] == 'file'} + check({'demo.ext.tool:1.0.0', 'demo.ext.shared:1.0.0'} <= present_files, f'packages.config files {present_files}') + + +def main(): + if PHASE == 'default': + default_phase() + elif PHASE == 'exclude-paths': + facts = load('basic/Basic.sln.socket.facts.json') + check('test/App.Tests/App.Tests.csproj' not in project_ids(facts), 'excluded project resolved') + check(not comps(facts, 'Demo.Ext.TestSdk'), 'excluded project dependencies emitted') + elif PHASE == 'include-configs': + facts = load('basic/Basic.sln.socket.facts.json') + check(not comps(facts, 'NETStandard.Library'), 'netstandard2.0 target read despite --include-configs') + elif PHASE == 'ignore-unresolved': + facts = load('failures/missing/Missing.csproj.socket.facts.json') + check(bool(comps(facts, 'Demo.Ext.Tool')), 'resolved dependency kept') + check(not comps(facts, 'Demo.Ext.DoesNotExist'), 'unresolved dependency emitted') + elif PHASE == 'records': + records_phase() + if problems: + print('\n'.join(f'FAIL [{PHASE}] {p}' for p in problems)) + sys.exit(1) + print(f'assert-facts: {PHASE} ok') + + +if __name__ == '__main__': + main() diff --git a/src/commands/manifest/scripts/test/dotnet-compat/basic/Basic.sln b/src/commands/manifest/scripts/test/dotnet-compat/basic/Basic.sln new file mode 100644 index 000000000..a86f8a93b --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/basic/Basic.sln @@ -0,0 +1,82 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lib", "src\Lib\Lib.csproj", "{C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Tests", "test\App.Tests\App.Tests.csproj", "{CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nuke", "tools\Nuke\Nuke.csproj", "{5029FD7C-F7DF-4AF4-BC15-ABEA866ADBDA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Debug|x64.ActiveCfg = Debug|Any CPU + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Debug|x64.Build.0 = Debug|Any CPU + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Debug|x86.ActiveCfg = Debug|Any CPU + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Debug|x86.Build.0 = Debug|Any CPU + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Release|Any CPU.Build.0 = Release|Any CPU + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Release|x64.ActiveCfg = Release|Any CPU + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Release|x64.Build.0 = Release|Any CPU + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Release|x86.ActiveCfg = Release|Any CPU + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5}.Release|x86.Build.0 = Release|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Debug|x64.ActiveCfg = Debug|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Debug|x64.Build.0 = Debug|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Debug|x86.ActiveCfg = Debug|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Debug|x86.Build.0 = Debug|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Release|Any CPU.Build.0 = Release|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Release|x64.ActiveCfg = Release|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Release|x64.Build.0 = Release|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Release|x86.ActiveCfg = Release|Any CPU + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82}.Release|x86.Build.0 = Release|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Debug|x64.ActiveCfg = Debug|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Debug|x64.Build.0 = Debug|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Debug|x86.ActiveCfg = Debug|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Debug|x86.Build.0 = Debug|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Release|Any CPU.Build.0 = Release|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Release|x64.ActiveCfg = Release|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Release|x64.Build.0 = Release|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Release|x86.ActiveCfg = Release|Any CPU + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73}.Release|x86.Build.0 = Release|Any CPU + {5029FD7C-F7DF-4AF4-BC15-ABEA866ADBDA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5029FD7C-F7DF-4AF4-BC15-ABEA866ADBDA}.Debug|x64.ActiveCfg = Debug|Any CPU + {5029FD7C-F7DF-4AF4-BC15-ABEA866ADBDA}.Debug|x86.ActiveCfg = Debug|Any CPU + {5029FD7C-F7DF-4AF4-BC15-ABEA866ADBDA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5029FD7C-F7DF-4AF4-BC15-ABEA866ADBDA}.Release|x64.ActiveCfg = Release|Any CPU + {5029FD7C-F7DF-4AF4-BC15-ABEA866ADBDA}.Release|x86.ActiveCfg = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {5BDF8935-F5C4-4E87-BA55-78EE6C4F6BB5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C8D2A239-CC93-4F79-9D8C-3760DD6FDD82} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {CD9C21E4-FA0A-4666-8D23-D1DD08D21E73} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {5029FD7C-F7DF-4AF4-BC15-ABEA866ADBDA} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + EndGlobalSection +EndGlobal diff --git a/src/commands/manifest/scripts/test/dotnet-compat/basic/shared/Linked.cs b/src/commands/manifest/scripts/test/dotnet-compat/basic/shared/Linked.cs new file mode 100644 index 000000000..584bf1870 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/basic/shared/Linked.cs @@ -0,0 +1 @@ +public static class Linked {} diff --git a/src/commands/manifest/scripts/test/dotnet-compat/basic/src/App/App.csproj b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/App/App.csproj new file mode 100644 index 000000000..bfd5b9651 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/App/App.csproj @@ -0,0 +1,8 @@ + + Exenet6.0 + + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/basic/src/App/Program.cs b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/App/Program.cs new file mode 100644 index 000000000..d66ad5d6d --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/App/Program.cs @@ -0,0 +1 @@ +System.Console.WriteLine(new Lib.L()); diff --git a/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Extra/E.cs b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Extra/E.cs new file mode 100644 index 000000000..1eaeed48c --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Extra/E.cs @@ -0,0 +1 @@ +namespace Extra { public class E {} } diff --git a/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Extra/Extra.csproj b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Extra/Extra.csproj new file mode 100644 index 000000000..d2609f95d --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Extra/Extra.csproj @@ -0,0 +1,4 @@ + + net6.0 + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Lib/L.cs b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Lib/L.cs new file mode 100644 index 000000000..d9e3b65f3 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Lib/L.cs @@ -0,0 +1 @@ +namespace Lib { public class L {} } diff --git a/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Lib/Lib.csproj b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Lib/Lib.csproj new file mode 100644 index 000000000..2882a672c --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Lib/Lib.csproj @@ -0,0 +1,11 @@ + + net6.0;netstandard2.0Acme.Lib2.3.4 + + + + + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/basic/test/App.Tests/App.Tests.csproj b/src/commands/manifest/scripts/test/dotnet-compat/basic/test/App.Tests/App.Tests.csproj new file mode 100644 index 000000000..7e3a303dc --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/basic/test/App.Tests/App.Tests.csproj @@ -0,0 +1,8 @@ + + net6.0 + + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/basic/test/App.Tests/T.cs b/src/commands/manifest/scripts/test/dotnet-compat/basic/test/App.Tests/T.cs new file mode 100644 index 000000000..f3eafb877 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/basic/test/App.Tests/T.cs @@ -0,0 +1 @@ +public class T {} diff --git a/src/commands/manifest/scripts/test/dotnet-compat/basic/tools/Nuke/Nuke.csproj b/src/commands/manifest/scripts/test/dotnet-compat/basic/tools/Nuke/Nuke.csproj new file mode 100644 index 000000000..133e5daa2 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/basic/tools/Nuke/Nuke.csproj @@ -0,0 +1,5 @@ + + + net6.0 + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/cpm/Cpm.slnx b/src/commands/manifest/scripts/test/dotnet-compat/cpm/Cpm.slnx new file mode 100644 index 000000000..27e75ac29 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/cpm/Cpm.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/cpm/Directory.Build.props b/src/commands/manifest/scripts/test/dotnet-compat/cpm/Directory.Build.props new file mode 100644 index 000000000..f8602219c --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/cpm/Directory.Build.props @@ -0,0 +1,5 @@ + + + true + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/cpm/Directory.Packages.props b/src/commands/manifest/scripts/test/dotnet-compat/cpm/Directory.Packages.props new file mode 100644 index 000000000..b527d7213 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/cpm/Directory.Packages.props @@ -0,0 +1,14 @@ + + + true + true + + + + + + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Api/A.cs b/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Api/A.cs new file mode 100644 index 000000000..9f4b93d84 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Api/A.cs @@ -0,0 +1 @@ +public class A {} diff --git a/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Api/Api.csproj b/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Api/Api.csproj new file mode 100644 index 000000000..9b70774d4 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Api/Api.csproj @@ -0,0 +1,8 @@ + + net6.0 + + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Core/C.cs b/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Core/C.cs new file mode 100644 index 000000000..9d83983cd --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Core/C.cs @@ -0,0 +1 @@ +public class C {} diff --git a/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Core/Core.csproj b/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Core/Core.csproj new file mode 100644 index 000000000..ab46ffdf9 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Core/Core.csproj @@ -0,0 +1,7 @@ + + net6.0 + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/dev/Dev.csproj b/src/commands/manifest/scripts/test/dotnet-compat/dev/Dev.csproj new file mode 100644 index 000000000..34cdcb726 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/dev/Dev.csproj @@ -0,0 +1,14 @@ + + Exenet6.0 + + + + + + + + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/dev/Program.cs b/src/commands/manifest/scripts/test/dotnet-compat/dev/Program.cs new file mode 100644 index 000000000..5aa94bea0 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/dev/Program.cs @@ -0,0 +1 @@ +System.Console.WriteLine(); diff --git a/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/App.csproj b/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/App.csproj new file mode 100644 index 000000000..3187d81c7 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/App.csproj @@ -0,0 +1,7 @@ + + net6.0 + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/App.sln b/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/App.sln new file mode 100644 index 000000000..35d08ae5d --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/App.sln @@ -0,0 +1,67 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "App.csproj", "{FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "core", "core", "{FBF56CC3-7AE6-AD2D-3F14-7F97FD322CD6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "core\Core.csproj", "{F926C765-DA26-4E24-BEAE-0204C654BD79}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "..\shared\Shared.csproj", "{C16D86A2-769A-416B-9FF9-452D346804A4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Debug|x64.Build.0 = Debug|Any CPU + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Debug|x86.Build.0 = Debug|Any CPU + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Release|Any CPU.Build.0 = Release|Any CPU + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Release|x64.ActiveCfg = Release|Any CPU + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Release|x64.Build.0 = Release|Any CPU + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Release|x86.ActiveCfg = Release|Any CPU + {FD6BBBA4-EC4D-4063-ACAE-BCB726AFA5C1}.Release|x86.Build.0 = Release|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Debug|x64.ActiveCfg = Debug|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Debug|x64.Build.0 = Debug|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Debug|x86.ActiveCfg = Debug|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Debug|x86.Build.0 = Debug|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Release|Any CPU.Build.0 = Release|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Release|x64.ActiveCfg = Release|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Release|x64.Build.0 = Release|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Release|x86.ActiveCfg = Release|Any CPU + {F926C765-DA26-4E24-BEAE-0204C654BD79}.Release|x86.Build.0 = Release|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Debug|x64.ActiveCfg = Debug|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Debug|x64.Build.0 = Debug|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Debug|x86.ActiveCfg = Debug|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Debug|x86.Build.0 = Debug|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Release|Any CPU.Build.0 = Release|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Release|x64.ActiveCfg = Release|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Release|x64.Build.0 = Release|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Release|x86.ActiveCfg = Release|Any CPU + {C16D86A2-769A-416B-9FF9-452D346804A4}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F926C765-DA26-4E24-BEAE-0204C654BD79} = {FBF56CC3-7AE6-AD2D-3F14-7F97FD322CD6} + EndGlobalSection +EndGlobal diff --git a/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/core/Core.csproj b/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/core/Core.csproj new file mode 100644 index 000000000..cfcf38198 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/core/Core.csproj @@ -0,0 +1,3 @@ + + net6.0 + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/shared/Shared.csproj b/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/shared/Shared.csproj new file mode 100644 index 000000000..cfcf38198 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/shared/Shared.csproj @@ -0,0 +1,3 @@ + + net6.0 + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/failures/broken/Broken.csproj b/src/commands/manifest/scripts/test/dotnet-compat/failures/broken/Broken.csproj new file mode 100644 index 000000000..ea793a652 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/failures/broken/Broken.csproj @@ -0,0 +1,5 @@ + + net6.0 + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/failures/designtime/DesignTime.csproj b/src/commands/manifest/scripts/test/dotnet-compat/failures/designtime/DesignTime.csproj new file mode 100644 index 000000000..4e958ccb8 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/failures/designtime/DesignTime.csproj @@ -0,0 +1,8 @@ + + net6.0 + + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/failures/missing/Missing.csproj b/src/commands/manifest/scripts/test/dotnet-compat/failures/missing/Missing.csproj new file mode 100644 index 000000000..4cd3dd957 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/failures/missing/Missing.csproj @@ -0,0 +1,7 @@ + + net6.0 + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/langs/Fs/Fs.fsproj b/src/commands/manifest/scripts/test/dotnet-compat/langs/Fs/Fs.fsproj new file mode 100644 index 000000000..b38c3d8bb --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/langs/Fs/Fs.fsproj @@ -0,0 +1,5 @@ + + net6.0 + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/langs/Fs/Library.fs b/src/commands/manifest/scripts/test/dotnet-compat/langs/Fs/Library.fs new file mode 100644 index 000000000..c1dc68de8 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/langs/Fs/Library.fs @@ -0,0 +1,2 @@ +module Library +let x = 1 diff --git a/src/commands/manifest/scripts/test/dotnet-compat/langs/Vb/V.vb b/src/commands/manifest/scripts/test/dotnet-compat/langs/Vb/V.vb new file mode 100644 index 000000000..7780292ca --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/langs/Vb/V.vb @@ -0,0 +1,2 @@ +Public Class V +End Class diff --git a/src/commands/manifest/scripts/test/dotnet-compat/langs/Vb/Vb.vbproj b/src/commands/manifest/scripts/test/dotnet-compat/langs/Vb/Vb.vbproj new file mode 100644 index 000000000..4eaa39ccf --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/langs/Vb/Vb.vbproj @@ -0,0 +1,7 @@ + + net6.0 + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/lib.sh b/src/commands/manifest/scripts/test/dotnet-compat/lib.sh new file mode 100644 index 000000000..659bcca30 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/lib.sh @@ -0,0 +1,35 @@ +# shellcheck shell=bash +# Sourced by the dotnet smoke tests after they set HERE and DOTNET. +REPO="$(cd "$HERE/../../../../../.." && pwd)" +TOOL="$REPO/dist/manifest-scripts/dotnet-tool/socket-facts-dotnet.dll" +export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 + +if [ ! -f "$TOOL" ]; then + echo "dotnet tool not built: run pnpm run build:dotnet-tool && pnpm build:dist:src" >&2 + exit 1 +fi + +# Copies the fixture paths $2... (relative to HERE) into $1, without restore +# and build output, so every run starts unrestored. +copy_fixture() { + ( cd "$HERE" && find "${@:2}" -type f \ + -not -path '*/obj/*' -not -path '*/bin/*' -not -path './stub-feed/*' \ + -not -name '*.socket.facts.json' -not -name 'packages.lock.json' \ + -exec sh -c 'mkdir -p "$1/$(dirname "$2")" && cp "$2" "$1/$2"' _ "$1" {} \; ) +} + +# Pins SDK version $2 (if any) with a global.json in $1, as a project would, +# and sets SDK_VERSION to the SDK dotnet then selects there. +pin_sdk() { + if [ -n "${2:-}" ]; then + printf '{"sdk": {"version": "%s", "rollForward": "disable"}}\n' "$2" > "$1/global.json" + fi + SDK_VERSION="$(cd "$1" && "$DOTNET" --version)" +} + +# Runs the tool from directory $1 with the remaining arguments. +run_tool() { + # A preview SDK's MSBuild needs a preview runtime (see run.mts). + ( cd "$1" && case "$SDK_VERSION" in *-*) export DOTNET_ROLL_FORWARD_TO_PRERELEASE=1 ;; esac + "$DOTNET" "$TOOL" --dotnet "$DOTNET" "${@:2}" >/dev/null ) +} diff --git a/src/commands/manifest/scripts/test/dotnet-compat/make-stub-feed.py b/src/commands/manifest/scripts/test/dotnet-compat/make-stub-feed.py new file mode 100644 index 000000000..3ede820c7 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/make-stub-feed.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Generate a local NuGet feed of stub packages for the dotnet compat fixtures. + +Like ../make-stub-repo.sh for the JVM fixtures: the fixtures only need the +shape of a dependency graph, never the code, so each stub is a nuspec plus +empty files, generated at test time and never committed. + +Usage: make-stub-feed.py ... + spec = Id:Version[+DepId:DepRange,...][#flag,...] + flags: + analyzer no runtime assembly, only analyzers/dotnet/cs/.dll + test build/.props sets IsTestProject, as Microsoft.NET.Test.Sdk does + payload no assemblies; build/.targets and a native file it would copy + buildtool no assemblies; build/.targets, a developmentDependency + rid= its assembly is runtimes//lib/netstandard2.0/.dll only + runtime= runtime.json makes it depend on runtime.. for +""" +import os +import shutil +import sys +import zipfile +from xml.sax.saxutils import escape + + +def parse(spec): + flags = [] + if '#' in spec: + spec, flag_text = spec.split('#', 1) + flags = [f for f in flag_text.split(',') if f] + deps = [] + if '+' in spec: + spec, dep_text = spec.split('+', 1) + deps = [d.split(':', 1) for d in dep_text.split(',') if d] + pkg_id, version = spec.split(':', 1) + return pkg_id, version, deps, flags + + +def nuspec(pkg_id, version, deps, flags): + dep_xml = ''.join( + f'' for d, v in deps + ) + dev = ( + 'true' + if 'analyzer' in flags or 'buildtool' in flags + else '' + ) + return ( + '' + '' + f'{escape(pkg_id)}{escape(version)}' + f'socketstub{dev}' + f'{dep_xml}' + ) + + +def main(): + feed = sys.argv[1] + specs = sys.argv[2:] + if not specs: + sys.exit('make-stub-feed.py: no package specs given') + shutil.rmtree(feed, ignore_errors=True) + os.makedirs(feed) + for spec in specs: + pkg_id, version, deps, flags = parse(spec) + files = {f'{pkg_id}.nuspec': nuspec(pkg_id, version, deps, flags)} + rid = next((f[4:] for f in flags if f.startswith('rid=')), None) + runtime = next((f[8:] for f in flags if f.startswith('runtime=')), None) + if 'analyzer' in flags: + files[f'analyzers/dotnet/cs/{pkg_id}.dll'] = '' + elif 'payload' in flags or 'buildtool' in flags: + files[f'build/{pkg_id}.targets'] = '' + if 'payload' in flags: + files[f'build/x64/{pkg_id}.native.dll'] = '' + elif rid: + files[f'runtimes/{rid}/lib/netstandard2.0/{pkg_id}.dll'] = '' + files['lib/netstandard2.0/_._'] = '' + else: + files[f'lib/netstandard2.0/{pkg_id}.dll'] = '' + if 'test' in flags: + files[f'build/{pkg_id}.props'] = ( + 'true' + '' + ) + if runtime: + files['runtime.json'] = ( + '{"runtimes": {"%s": {"%s": {"runtime.%s.%s": "%s"}}}}' + % (runtime, pkg_id, runtime, pkg_id, version) + ) + path = os.path.join(feed, f'{pkg_id.lower()}.{version}.nupkg') + with zipfile.ZipFile(path, 'w') as z: + for name, content in files.items(): + z.writestr(name, content) + + +if __name__ == '__main__': + main() diff --git a/src/commands/manifest/scripts/test/dotnet-compat/multi/One.sln b/src/commands/manifest/scripts/test/dotnet-compat/multi/One.sln new file mode 100644 index 000000000..318b25019 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/multi/One.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "One", "One\One.csproj", "{D648480D-09A5-4D87-8AD2-35B88071DE76}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Debug|x64.ActiveCfg = Debug|Any CPU + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Debug|x64.Build.0 = Debug|Any CPU + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Debug|x86.ActiveCfg = Debug|Any CPU + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Debug|x86.Build.0 = Debug|Any CPU + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Release|Any CPU.Build.0 = Release|Any CPU + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Release|x64.ActiveCfg = Release|Any CPU + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Release|x64.Build.0 = Release|Any CPU + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Release|x86.ActiveCfg = Release|Any CPU + {D648480D-09A5-4D87-8AD2-35B88071DE76}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/commands/manifest/scripts/test/dotnet-compat/multi/One.slnx b/src/commands/manifest/scripts/test/dotnet-compat/multi/One.slnx new file mode 100644 index 000000000..94cd13315 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/multi/One.slnx @@ -0,0 +1,3 @@ + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/multi/One/One.csproj b/src/commands/manifest/scripts/test/dotnet-compat/multi/One/One.csproj new file mode 100644 index 000000000..e040792f8 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/multi/One/One.csproj @@ -0,0 +1,4 @@ + + net6.0 + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/multi/Stray.csproj b/src/commands/manifest/scripts/test/dotnet-compat/multi/Stray.csproj new file mode 100644 index 000000000..ac11aa766 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/multi/Stray.csproj @@ -0,0 +1,4 @@ + + net6.0false + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/multi/Two.sln b/src/commands/manifest/scripts/test/dotnet-compat/multi/Two.sln new file mode 100644 index 000000000..17323f598 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/multi/Two.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Two", "Two\Two.csproj", "{5FCA314C-9CC0-4773-9265-3DCE2F400D2C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "One", "One\One.csproj", "{2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Debug|x64.ActiveCfg = Debug|Any CPU + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Debug|x64.Build.0 = Debug|Any CPU + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Debug|x86.ActiveCfg = Debug|Any CPU + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Debug|x86.Build.0 = Debug|Any CPU + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Release|Any CPU.Build.0 = Release|Any CPU + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Release|x64.ActiveCfg = Release|Any CPU + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Release|x64.Build.0 = Release|Any CPU + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Release|x86.ActiveCfg = Release|Any CPU + {5FCA314C-9CC0-4773-9265-3DCE2F400D2C}.Release|x86.Build.0 = Release|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Debug|x64.ActiveCfg = Debug|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Debug|x64.Build.0 = Debug|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Debug|x86.ActiveCfg = Debug|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Debug|x86.Build.0 = Debug|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Release|Any CPU.Build.0 = Release|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Release|x64.ActiveCfg = Release|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Release|x64.Build.0 = Release|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Release|x86.ActiveCfg = Release|Any CPU + {2F89B4FD-FF70-42FE-87D0-46F2F5F94C42}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/commands/manifest/scripts/test/dotnet-compat/multi/Two/Two.csproj b/src/commands/manifest/scripts/test/dotnet-compat/multi/Two/Two.csproj new file mode 100644 index 000000000..ab9c87f16 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/multi/Two/Two.csproj @@ -0,0 +1,4 @@ + + net6.0 + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/nuget.config b/src/commands/manifest/scripts/test/dotnet-compat/nuget.config new file mode 100644 index 000000000..1fdc15d43 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/nuget.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Class1.cs b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Class1.cs new file mode 100644 index 000000000..fadefcb68 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Class1.cs @@ -0,0 +1 @@ +namespace Legacy { public class Class1 {} } diff --git a/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Legacy.csproj b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Legacy.csproj new file mode 100644 index 000000000..360e14566 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Legacy.csproj @@ -0,0 +1,37 @@ + + + + + Debug + AnyCPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F} + Library + Legacy + v4.8 + bin\$(Configuration)\ + 17.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + ..\packages\Demo.Ext.Tool.1.0.0\lib\netstandard2.0\Demo.Ext.Tool.dll + + + ..\packages\Demo.Ext.Shared.1.0\lib\netstandard2.0\Demo.Ext.Shared.dll + + + + + + + + + + + + + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Properties/AssemblyInfo.cs b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..6ca0988a3 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Properties/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: System.Reflection.AssemblyTitle("Legacy")] diff --git a/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/packages.config b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/packages.config new file mode 100644 index 000000000..28f0b996e --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/packages.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Mixed.sln b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Mixed.sln new file mode 100644 index 000000000..dd5c49a21 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Mixed.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Legacy", "Legacy\Legacy.csproj", "{8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modern", "Modern\Modern.csproj", "{547B8BBF-C848-4F81-83E2-82C1B62B9011}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Debug|x64.Build.0 = Debug|Any CPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Debug|x86.Build.0 = Debug|Any CPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Release|Any CPU.Build.0 = Release|Any CPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Release|x64.ActiveCfg = Release|Any CPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Release|x64.Build.0 = Release|Any CPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Release|x86.ActiveCfg = Release|Any CPU + {8E5C1B5E-0D7A-4C8C-9B0E-3B7A1C2D4E5F}.Release|x86.Build.0 = Release|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Debug|Any CPU.Build.0 = Debug|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Debug|x64.ActiveCfg = Debug|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Debug|x64.Build.0 = Debug|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Debug|x86.ActiveCfg = Debug|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Debug|x86.Build.0 = Debug|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Release|Any CPU.ActiveCfg = Release|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Release|Any CPU.Build.0 = Release|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Release|x64.ActiveCfg = Release|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Release|x64.Build.0 = Release|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Release|x86.ActiveCfg = Release|Any CPU + {547B8BBF-C848-4F81-83E2-82C1B62B9011}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Modern/M.cs b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Modern/M.cs new file mode 100644 index 000000000..9b4658fd8 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Modern/M.cs @@ -0,0 +1 @@ +public class M {} diff --git a/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Modern/Modern.csproj b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Modern/Modern.csproj new file mode 100644 index 000000000..cb60089ee --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Modern/Modern.csproj @@ -0,0 +1,9 @@ + + + net6.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v17.0 + + + + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/pair/A.csproj b/src/commands/manifest/scripts/test/dotnet-compat/pair/A.csproj new file mode 100644 index 000000000..3a774aae0 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/pair/A.csproj @@ -0,0 +1,4 @@ + + net6.0false + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/pair/B.csproj b/src/commands/manifest/scripts/test/dotnet-compat/pair/B.csproj new file mode 100644 index 000000000..601838e75 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/pair/B.csproj @@ -0,0 +1,4 @@ + + net6.0false + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/rid/R.cs b/src/commands/manifest/scripts/test/dotnet-compat/rid/R.cs new file mode 100644 index 000000000..0f15cebab --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/rid/R.cs @@ -0,0 +1 @@ +public class R {} diff --git a/src/commands/manifest/scripts/test/dotnet-compat/rid/Rid.csproj b/src/commands/manifest/scripts/test/dotnet-compat/rid/Rid.csproj new file mode 100644 index 000000000..937679614 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/rid/Rid.csproj @@ -0,0 +1,4 @@ + + net6.0linux-x64;win-x64 + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/sibling/sln/All.sln b/src/commands/manifest/scripts/test/dotnet-compat/sibling/sln/All.sln new file mode 100644 index 000000000..a2724ed1a --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/sibling/sln/All.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "A", "..\src\A\A.csproj", "{DC04A8EA-230D-4A3A-93BE-416D33654BEB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Debug|x64.ActiveCfg = Debug|Any CPU + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Debug|x64.Build.0 = Debug|Any CPU + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Debug|x86.ActiveCfg = Debug|Any CPU + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Debug|x86.Build.0 = Debug|Any CPU + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Release|Any CPU.Build.0 = Release|Any CPU + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Release|x64.ActiveCfg = Release|Any CPU + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Release|x64.Build.0 = Release|Any CPU + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Release|x86.ActiveCfg = Release|Any CPU + {DC04A8EA-230D-4A3A-93BE-416D33654BEB}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/commands/manifest/scripts/test/dotnet-compat/sibling/src/A/A.cs b/src/commands/manifest/scripts/test/dotnet-compat/sibling/src/A/A.cs new file mode 100644 index 000000000..9f4b93d84 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/sibling/src/A/A.cs @@ -0,0 +1 @@ +public class A {} diff --git a/src/commands/manifest/scripts/test/dotnet-compat/sibling/src/A/A.csproj b/src/commands/manifest/scripts/test/dotnet-compat/sibling/src/A/A.csproj new file mode 100644 index 000000000..e040792f8 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/sibling/src/A/A.csproj @@ -0,0 +1,4 @@ + + net6.0 + + diff --git a/src/commands/manifest/scripts/test/dotnet-compat/smoke-test-exclude-base.sh b/src/commands/manifest/scripts/test/dotnet-compat/smoke-test-exclude-base.sh new file mode 100755 index 000000000..c6a403da4 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/smoke-test-exclude-base.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# --exclude-paths relative to the scan root (--exclude-base), one level above +# the build root: a `../shared` project outside the build root is excluded by +# its scan-root path, and a nested project by the build root's path in the scan +# root, both from the projects --workspaces follows and from the solution +# members --members lists. A project outside the scan root is never excluded, +# even by a wildcard that would match its `../` path. +# +# Usage: smoke-test-exclude-base.sh [sdk-version] +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +DOTNET="${1:?usage: smoke-test-exclude-base.sh [sdk-version]}" +SDK="${2:-}" +# shellcheck source=SCRIPTDIR/lib.sh +. "$HERE/lib.sh" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +copy_fixture "$WORK" exclude-base +SCAN_ROOT="$(cd "$WORK/exclude-base" && pwd -P)" +pin_sdk "$SCAN_ROOT" "$SDK" +echo "+ dotnet SDK $SDK_VERSION (exclude base)" + +# The project names --workspaces keeps, or the member paths --members lists. +kept() { + local mode="$1" entry="$2" pattern="$3" base="${4:-$SCAN_ROOT}" + rm -f "$WORK/records.tsv" + run_tool "$SCAN_ROOT/app" --records "$WORK/records.tsv" --entry "$entry" \ + "--$mode" --exclude-paths "$pattern" --exclude-base "$base" + # A run that wrote no records keeps nothing, which check reports. + [ -f "$WORK/records.tsv" ] || return 0 + if [ "$mode" = workspaces ]; then + awk -F'\t' '$1 == "project" { print $4 }' "$WORK/records.tsv" + else + awk -F'\t' '$1 == "member" { print $2 }' "$WORK/records.tsv" + fi | LC_ALL=C sort | tr '\n' ' ' +} +check() { + local got + got="$(kept "$1" "$2" "$3" "${5:-}")" + if [ "$got" != "$4" ]; then + echo "FAIL: --$1 of $2 excluding $3 from ${5:-$SCAN_ROOT} kept [$got], want [$4]" >&2 + exit 1 + fi +} +check workspaces App.csproj '^(?:shared)(?:/.*)?$' 'App Core ' +check workspaces App.csproj '^(?:app/core)(?:/.*)?$' 'App Shared ' +# Build-root-relative, so it names nothing under the scan root. +check workspaces App.csproj '^(?:core)(?:/.*)?$' 'App Core Shared ' +check members App.sln '^(?:shared)(?:/.*)?$' 'App.csproj core/Core.csproj ' +check members App.sln '^(?:app/core)(?:/.*)?$' '../shared/Shared.csproj App.csproj ' +check members App.sln '^(?:core)(?:/.*)?$' '../shared/Shared.csproj App.csproj core/Core.csproj ' +# `**/shared`, scanned from app: ../shared is outside the scan root. +check workspaces App.csproj '^(?:(?:[^/]+/)*shared)(?:/.*)?$' 'App Core Shared ' "$SCAN_ROOT/app" +check members App.sln '^(?:(?:[^/]+/)*shared)(?:/.*)?$' '../shared/Shared.csproj App.csproj core/Core.csproj ' "$SCAN_ROOT/app" +echo "PASS: projects are excluded by their scan-root paths, never outside the scan root" diff --git a/src/commands/manifest/scripts/test/dotnet-compat/smoke-test-workspaces.sh b/src/commands/manifest/scripts/test/dotnet-compat/smoke-test-workspaces.sh new file mode 100755 index 000000000..c3c45a781 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/smoke-test-workspaces.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Runs the dotnet tool's listing modes on the basic solution with one .NET SDK +# and asserts they evaluate projects without restoring anything: --workspaces +# lists the solution's built members and the projects they reference (an inner +# build's too), --members only the built members, minus --exclude-paths. +# +# Usage: smoke-test-workspaces.sh [sdk-version] +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +DOTNET="${1:?usage: smoke-test-workspaces.sh [sdk-version]}" +SDK="${2:-}" +# shellcheck source=SCRIPTDIR/lib.sh +. "$HERE/lib.sh" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +copy_fixture "$WORK" basic +pin_sdk "$WORK" "$SDK" +echo "+ dotnet SDK $SDK_VERSION (workspaces)" + +tool() { + run_tool "$WORK/basic" --records "$WORK/$1.tsv" --entry Basic.sln "${@:2}" +} +tool workspaces --workspaces +tool members --members +tool members-excluded --members --exclude-paths '^(?:test)(?:/.*)?$' + +python3 - "$WORK" <<'PY' +import os, sys +work = sys.argv[1] +errors = [] +def rows(name): + return [l.rstrip('\n').split('\t') for l in open(os.path.join(work, name + '.tsv')) if l.strip()] +def check(name, allowed, kind, expected): + got = [] + for r in rows(name): + if r[0] not in allowed: + errors.append(f"{name}: unexpected record kind {r[0]!r}; listing never restores") + elif r[0] == kind: + got.append(r[1] if kind == 'member' else (r[1], r[3])) + if sorted(got) != sorted(expected): + errors.append(f"{name}: {kind} records {sorted(got)} != {sorted(expected)}") + +# Nuke is in the solution but not built by its active configuration. +check('workspaces', {'meta', 'buildRoot', 'entry', 'project', 'projectBuild'}, 'project', [ + ('src/App/App.csproj', 'App'), + ('src/Lib/Lib.csproj', 'Acme.Lib'), + ('test/App.Tests/App.Tests.csproj', 'App.Tests'), + # Referenced only by Lib's net6.0 inner build. + ('src/Extra/Extra.csproj', 'Extra'), +]) +builds = {(r[1], r[2]) for r in rows('workspaces') if r[0] == 'projectBuild'} +for key in ('src/App/App.csproj', 'src/Lib/Lib.csproj', 'test/App.Tests/App.Tests.csproj', 'src/Extra/Extra.csproj'): + if (key, key) not in builds: + errors.append(f"workspaces: {key} has no projectBuild record of its own file") +check('members', {'meta', 'buildRoot', 'entry', 'member'}, 'member', + ['src/App/App.csproj', 'src/Lib/Lib.csproj', 'test/App.Tests/App.Tests.csproj']) +check('members-excluded', {'meta', 'buildRoot', 'entry', 'member'}, 'member', + ['src/App/App.csproj', 'src/Lib/Lib.csproj']) +if any('obj' in dirs for _, dirs, _ in os.walk(os.path.join(work, 'basic'))): + errors.append("an obj/ directory was written; listing must not restore") + +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print("PASS: listings evaluate the solution's built projects without restoring") +PY diff --git a/src/commands/manifest/scripts/test/dotnet-compat/smoke-test.sh b/src/commands/manifest/scripts/test/dotnet-compat/smoke-test.sh new file mode 100755 index 000000000..0448412d1 --- /dev/null +++ b/src/commands/manifest/scripts/test/dotnet-compat/smoke-test.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Runs the built CLI's `socket manifest dotnet` over the fixtures with one .NET +# SDK and checks the facts against `dotnet list package` and assert-facts.py. +# +# Usage: smoke-test.sh [sdk-version] +# sdk-version pins the SDK with a global.json, as a project would. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +DOTNET="${1:?usage: smoke-test.sh [sdk-version]}" +SDK="${2:-}" +# shellcheck source=SCRIPTDIR/lib.sh +. "$HERE/lib.sh" +CLI="$REPO/bin/cli.js" +# shellcheck source=SCRIPTDIR/../compat-cache.sh +. "$HERE/../compat-cache.sh" + +export NUGET_PACKAGES="$SOCKET_COMPAT_CACHE/nuget-packages" +export NUGET_HTTP_CACHE_PATH="$SOCKET_COMPAT_CACHE/nuget-http" +# Stubs are regenerated per run under the same versions, so no stale copy +# may survive in the package folder. +rm -rf "$NUGET_PACKAGES"/demo.* "$NUGET_PACKAGES"/runtime.win-x64.demo.* + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +# A copy per run keeps obj/, lock files and global.json out of the source tree. +copy_fixture "$WORK" . +python3 "$HERE/make-stub-feed.py" "$WORK/stub-feed" \ + 'Demo.Ext.Tool:1.0.0+Demo.Ext.Shared:1.0.0' \ + 'Demo.Ext.Shared:1.0.0' \ + 'Demo.Ext.Shared:2.0.0' \ + 'Demo.Ext.Logging:1.0.0+Demo.Ext.Shared:1.0.0' \ + 'Demo.Ext.Harness:1.0.0+Demo.Ext.HarnessCore:1.0.0' \ + 'Demo.Ext.Harness:1.1.0+Demo.Ext.HarnessCore:1.0.0' \ + 'Demo.Ext.HarnessCore:1.0.0' \ + 'Demo.Ext.TestSdk:1.0.0#test' \ + 'Demo.Ext.Analyzer:1.0.0#analyzer' \ + 'Demo.Ext.Payload:1.0.0#payload' \ + 'Demo.Ext.BuildTool:1.0.0#buildtool' \ + 'Demo.Ext.Floating:1.0.0' \ + 'Demo.Ext.Floating:1.1.0' \ + 'Demo.Ext.Native:1.0.0#runtime=win-x64' \ + 'runtime.win-x64.Demo.Ext.Native:1.0.0#rid=win-x64' +pin_sdk "$WORK" "$SDK" +echo "+ dotnet SDK $SDK_VERSION" + +socket_dotnet() { + node "$CLI" manifest dotnet --bin "$DOTNET" "$@" +} +expect_fail() { + local log="$WORK/fail.log" + if socket_dotnet "$@" >"$log" 2>&1; then + cat "$log" + echo "expected failure: socket manifest dotnet $*" >&2 + exit 1 + fi + cat "$log" +} + +# .slnx needs SDK 9.0.200; older SDKs build the solution's top project. +CPM_ENTRY="cpm/Cpm.slnx" +python3 -c 'import sys; v=[int(x) for x in sys.argv[1].split("-")[0].split(".")]; sys.exit(0 if (v[0], v[2]) >= (9, 200) else 1)' "$SDK_VERSION" \ + || CPM_ENTRY="cpm/src/Api/Api.csproj" + +socket_dotnet "$WORK/basic" +socket_dotnet "$WORK/$CPM_ENTRY" +# The lock files the first restore wrote now pin the second. +socket_dotnet "$WORK/$CPM_ENTRY" --dotnet-opts=--locked-mode +socket_dotnet "$WORK/rid" +socket_dotnet "$WORK/packages-config" +socket_dotnet "$WORK/langs/Vb/Vb.vbproj" +socket_dotnet "$WORK/multi" +socket_dotnet "$WORK/pair" +socket_dotnet "$WORK/dev" +# Recursive discovery, configured through socket.json: a solution in a +# sibling folder covers its projects. +printf '{"version": 1, "defaults": {"manifest": {"dotnet": {"bin": "%s"}}}}\n' "$DOTNET" > "$WORK/sibling/socket.json" +node "$CLI" manifest dynamic-sbom-inference "$WORK/sibling" +expect_fail "$WORK/failures/missing" > "$WORK/missing.log" +expect_fail "$WORK/failures/broken" > "$WORK/broken.log" +python3 "$HERE/assert-facts.py" "$WORK" "$SDK_VERSION" "$CPM_ENTRY" + +if python3 -c 'import sys; v=[int(x) for x in sys.argv[1].split("-")[0].split(".")]; sys.exit(0 if (v[0], v[2]) >= (7, 200) else 1)' "$SDK_VERSION"; then + # `dotnet list package` reports no runtime-specific targets. + for entry in basic/Basic.sln "$CPM_ENTRY" langs/Vb/Vb.vbproj multi/One.sln multi/Two.sln multi/Stray.csproj; do + socket_dotnet "$WORK/$entry" --exclude-configs '*/*' >/dev/null 2>&1 + ( cd "$WORK" && python3 "$HERE/assert-dotnet-list.py" "$entry.socket.facts.json" "$entry" "$DOTNET" ) + done +fi + +# Option handling, on fresh copies of the facts. +socket_dotnet "$WORK/basic" --exclude-paths test +python3 "$HERE/assert-facts.py" "$WORK" "$SDK_VERSION" "$CPM_ENTRY" exclude-paths +socket_dotnet "$WORK/basic" --include-configs net6.0 +python3 "$HERE/assert-facts.py" "$WORK" "$SDK_VERSION" "$CPM_ENTRY" include-configs +socket_dotnet "$WORK/failures/missing" --ignore-unresolved +python3 "$HERE/assert-facts.py" "$WORK" "$SDK_VERSION" "$CPM_ENTRY" ignore-unresolved + +# --with-files: the records the reachability sidecar is built from. +records() { + run_tool "$(dirname "$1")" --records "$2" --entry "$1" --with-files "${@:3}" +} +records "$WORK/basic/Basic.sln" "$WORK/basic-netstandard.tsv" --include-configs netstandard2.0 +records "$WORK/basic/Basic.sln" "$WORK/basic.tsv" +records "$WORK/langs/Vb/Vb.vbproj" "$WORK/langs.tsv" +records "$WORK/failures/designtime/DesignTime.csproj" "$WORK/designtime.tsv" +records "$WORK/packages-config/Mixed.sln" "$WORK/pc-missing.tsv" +# What `nuget restore` lays out for packages.config: .. +python3 - "$WORK" <<'PY' +import os, sys, zipfile +work = sys.argv[1] +for pkg_id, version, folder in (('Demo.Ext.Tool', '1.0.0', '1.0.0'), ('Demo.Ext.Shared', '1.0.0', '1.0'), ('Demo.Ext.Analyzer', '1.0.0', '1.0.0')): + nupkg = os.path.join(work, 'stub-feed', f'{pkg_id.lower()}.{version}.nupkg') + zipfile.ZipFile(nupkg).extractall(os.path.join(work, 'packages-config', 'packages', f'{pkg_id}.{folder}')) +PY +records "$WORK/packages-config/Mixed.sln" "$WORK/pc-present.tsv" +# A restore failing in one project leaves the others' caches as the last +# restore wrote them, which cannot show they are current. +python3 - "$WORK/basic/test/App.Tests/App.Tests.csproj" <<'PY' +import sys +path = sys.argv[1] +with open(path) as f: + project = f.read() +with open(path, 'w') as f: + f.write(project.replace('', '')) +PY +records "$WORK/basic/Basic.sln" "$WORK/basic-stale.tsv" +python3 "$HERE/assert-facts.py" "$WORK" "$SDK_VERSION" "$CPM_ENTRY" records + +echo "dotnet compat smoke test passed: SDK $SDK_VERSION" diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index eaa8d7512..1e7d9ac24 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Run the JVM manifest-script compatibility matrix LOCALLY, on demand. +# Run the manifest-script compatibility matrix LOCALLY, on demand. # # GitHub Actions can't run this: SocketDev's org action allowlist forbids # `actions/setup-java` and `sbt/setup-sbt`, so the build-tool matrix has no CI @@ -13,7 +13,11 @@ # per row; otherwise the current `java` is used (fine if it can run that tool). # sbt rows additionally need the `sbt` launcher on PATH. # -# Usage: run-compat.sh [gradle|sbt|maven|all] (default: all) +# dotnet rows need the SDKs installed side by side under $CACHE/dotnet (each row +# pins its SDK with a global.json), plus a built CLI with the dotnet tool +# (pnpm run build:dotnet-tool && pnpm build:dist:src). +# +# Usage: run-compat.sh [dotnet|gradle|sbt|maven|all] (default: all) set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" @@ -30,6 +34,8 @@ GRADLE_MATRIX=("1.12 8" "2.14.1 8" "3.3 8" "6.2.2 8" "8.10.2 17" "9.2.1 21") # ProjectDependenciesResolver, so both ends are load-bearing, not decorative. MAVEN_MATRIX=("3.2.5 8" "3.6.3 11" "3.8.8 17" "3.9.9 17" "4.0.0-rc-6 21") # The `ivy` row runs sbt 1.x with `useCoursier := false`, the other resolver path; failure suite only. +# One SDK per supported major. +DOTNET_MATRIX=("6.0.428" "7.0.410" "8.0.414" "9.0.315" "10.0.301" "11.0.100-rc.3.26522.148") SBT_MATRIX=("0.13.18 8 2.10.7" "1.4.9 11 2.12.20" "1.6.2 17 2.12.20" "1.9.9 17 2.12.20" "1.9.9 17 2.12.20 ivy") # Select a JDK for the given Java major: use $JDK if set, else current java. @@ -47,6 +53,22 @@ use_jdk() { fi } +run_dotnet() { + local dotnet="$CACHE/dotnet/dotnet" + for sdk in "${DOTNET_MATRIX[@]}"; do + echo "== dotnet SDK $sdk ==" + if ! "$dotnet" --list-sdks 2>/dev/null | grep -q "^$sdk "; then + # SDK downloads stay out of this committed script (fleet CDN allowlist). + echo "dotnet SDK $sdk not found under $CACHE/dotnet." >&2 + echo "Fix: bash dotnet-install.sh --version $sdk --install-dir $CACHE/dotnet (https://dot.net/v1/dotnet-install.sh)" >&2 + exit 1 + fi + bash "$HERE/dotnet-compat/smoke-test.sh" "$dotnet" "$sdk" + bash "$HERE/dotnet-compat/smoke-test-workspaces.sh" "$dotnet" "$sdk" + bash "$HERE/dotnet-compat/smoke-test-exclude-base.sh" "$dotnet" "$sdk" + done +} + run_gradle() { for row in "${GRADLE_MATRIX[@]}"; do # shellcheck disable=SC2086 @@ -120,16 +142,18 @@ run_sbt() { } case "$TOOL" in + dotnet) run_dotnet ;; gradle) run_gradle ;; maven) run_maven ;; sbt) run_sbt ;; all) + run_dotnet run_gradle run_maven run_sbt ;; *) - echo "usage: run-compat.sh [gradle|sbt|maven|all]" >&2 + echo "usage: run-compat.sh [dotnet|gradle|sbt|maven|all]" >&2 exit 1 ;; esac diff --git a/src/commands/manifest/setup-manifest-config.test.mts b/src/commands/manifest/setup-manifest-config.test.mts index ed47c2c09..7138ddee3 100644 --- a/src/commands/manifest/setup-manifest-config.test.mts +++ b/src/commands/manifest/setup-manifest-config.test.mts @@ -9,28 +9,35 @@ vi.mock('@socketsecurity/registry/lib/prompts', () => ({ select: vi.fn(async ({ default: def }: { default?: string }) => def ?? ''), })) -import { select } from '@socketsecurity/registry/lib/prompts' - -import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' +import { input, select } from '@socketsecurity/registry/lib/prompts' + +import { + setupDotnet, + setupGradle, + setupMaven, + setupSbt, +} from './setup-manifest-config.mts' + +describe('setup wizards', () => { + it.for([ + ['dotnet', setupDotnet], + ['gradle', setupGradle], + ['maven', setupMaven], + ['sbt', setupSbt], + ] as const)( + '%s leaves a fresh config empty when every prompt is left blank, instead of freezing the hardcoded bin fallback', + async ({ 1: setup }) => { + const config: Record = {} + + const result = await setup(config) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(config).toEqual({}) + }, + ) +}) describe('setupGradle/setupMaven/setupSbt', () => { - it('leaves bin unset on a fresh config when every prompt is left blank, instead of freezing the hardcoded fallback', async () => { - const gradleConfig: Record = {} - const mavenConfig: Record = {} - const sbtConfig: Record = {} - - const gradleResult = await setupGradle(gradleConfig) - const mavenResult = await setupMaven(mavenConfig) - const sbtResult = await setupSbt(sbtConfig) - - expect(gradleResult).toEqual({ ok: true, data: { canceled: false } }) - expect(mavenResult).toEqual({ ok: true, data: { canceled: false } }) - expect(sbtResult).toEqual({ ok: true, data: { canceled: false } }) - expect(gradleConfig['bin']).toBeUndefined() - expect(mavenConfig['bin']).toBeUndefined() - expect(sbtConfig['bin']).toBeUndefined() - }) - it('still shows and accepts a prior explicit bin value unchanged', async () => { const config: Record = { bin: './custom-gradlew' } @@ -82,3 +89,35 @@ describe('setupGradle/setupMaven/setupSbt', () => { expect(messages.some(m => m.includes('--ignore-unresolved'))).toBe(true) }) }) + +describe('setupDotnet', () => { + it('preserves explicit nulls on a blank re-prompt', async () => { + const config: Record = { bin: null, dotnetOpts: null } + + await setupDotnet(config) + + expect(config).toEqual({ bin: null, dotnetOpts: null }) + }) + + it('asks for target framework filters, never the facts/pom choice', async () => { + vi.mocked(input).mockClear() + vi.mocked(select).mockClear() + + await setupDotnet({}) + + const messages = [ + ...vi.mocked(input).mock.calls, + ...vi.mocked(select).mock.calls, + ].map(c => c[0].message) + expect(messages).toEqual( + expect.arrayContaining( + ['--dotnet-opts', 'target framework globs', '--ignore-unresolved'].map( + s => expect.stringContaining(s), + ), + ), + ) + expect(messages).not.toContainEqual( + expect.stringContaining('--facts / --pom'), + ) + }) +})