Skip to content

Commit 8a34bb7

Browse files
committed
test(manifest): cover the .NET facts producer
Unit tests for .NET detection, discovery order and coverage, the no-SDK skip, assembly, the per-framework sidecar and NuGet failure classification, plus `run-compat.sh dotnet`: stub-package fixtures run through the built CLI per SDK, each project's tree compared with `dotnet list package --include-transitive`, with fixture assertions for the dev rule, coverage, solution configurations, twins, design-time build inputs and failures.
1 parent de78286 commit 8a34bb7

64 files changed

Lines changed: 1783 additions & 7 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ describe('socket fix --dynamic-sbom-inference', () => {
124124
const uploadManifestFiles = vi.fn()
125125
const generated = {
126126
paths: [FACTS],
127+
sidecar: undefined,
127128
sidecarFile: '/tmp/socket-fix-facts/sidecar.json',
128129
remove: vi.fn(),
129130
restore: vi.fn(),
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect } from 'vitest'
2+
3+
import constants, {
4+
FLAG_CONFIG,
5+
FLAG_DRY_RUN,
6+
FLAG_HELP,
7+
} from '../../../src/constants.mts'
8+
import { cmdit, spawnSocketCli } from '../../../test/utils.mts'
9+
10+
describe('socket manifest dotnet', async () => {
11+
const { binCliPath } = constants
12+
13+
cmdit(
14+
['manifest', 'dotnet', FLAG_HELP, FLAG_CONFIG, '{}'],
15+
`should support ${FLAG_HELP}`,
16+
async cmd => {
17+
const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd)
18+
expect(stdout).toMatchInlineSnapshot(`
19+
"[beta] Generate Socket facts files from .NET solutions and projects
20+
21+
Usage
22+
$ socket manifest dotnet [options] [CWD=.|FILE]
23+
24+
Options
25+
--bin Location of the dotnet binary to use, default: dotnet on PATH
26+
--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
27+
--exclude-configs Comma-separated glob patterns; target frameworks matching any pattern are skipped (applied after --include-configs)
28+
--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 \`<cwd>/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.
29+
--ignore-unresolved Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file)
30+
--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
31+
--verbose Print debug messages
32+
33+
Emits a \`<file>.socket.facts.json\` per solution or project describing its
34+
resolved NuGet dependency graph. Given a directory, it builds every
35+
solution (.sln, .slnx) and project (.csproj, .fsproj, .vbproj) at its top
36+
level, solutions first, skipping projects a solution already covers; given
37+
a file, just that one (a .slnf solution filter too).
38+
39+
Dependencies come from your own \`dotnet restore\` (same SDK, global.json,
40+
nuget.config and credential providers), so a build that restores for you
41+
restores here. PackageReference projects (central package management and
42+
lock files included) and packages.config projects are supported. An
43+
unresolved dependency is a fatal error unless --ignore-unresolved.
44+
45+
Support is beta. Please report issues or give us feedback on what's missing.
46+
47+
Examples
48+
49+
$ socket manifest dotnet .
50+
$ socket manifest dotnet ./App.sln
51+
$ socket manifest dotnet --dotnet-opts="-p:Configuration=Release" ."
52+
`)
53+
expect(`\n ${stderr}`).toMatchInlineSnapshot(`
54+
"
55+
_____ _ _ /---------------
56+
| __|___ ___| |_ ___| |_ | CLI: <redacted>
57+
|__ | * | _| '_| -_| _| | token: <redacted>, org: <redacted>
58+
|_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dotnet\`, cwd: <redacted>"
59+
`)
60+
61+
expect(code, 'explicit help should exit with code 0').toBe(0)
62+
expect(stderr, 'banner includes base command').toContain(
63+
'`socket manifest dotnet`',
64+
)
65+
},
66+
)
67+
68+
cmdit(
69+
['manifest', 'dotnet', FLAG_DRY_RUN, FLAG_CONFIG, '{}'],
70+
'should require args with just dry-run',
71+
async cmd => {
72+
const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd)
73+
expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`)
74+
expect(`\n ${stderr}`).toMatchInlineSnapshot(`
75+
"
76+
_____ _ _ /---------------
77+
| __|___ ___| |_ ___| |_ | CLI: <redacted>
78+
|__ | * | _| '_| -_| _| | token: <redacted>, org: <redacted>
79+
|_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dotnet\`, cwd: <redacted>"
80+
`)
81+
82+
expect(code, 'dry-run should exit with code 0 if input ok').toBe(0)
83+
},
84+
)
85+
})

‎src/commands/manifest/detect-manifest-actions.test.mts‎

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
22
import os from 'node:os'
33
import path from 'node:path'
44

55
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
66

7-
import { detectManifestActions } from './detect-manifest-actions.mts'
7+
import {
8+
detectManifestActions,
9+
findDotnetEntries,
10+
} from './detect-manifest-actions.mts'
811

912
import type { SocketJson } from '../../utils/socket-json.mts'
1013

@@ -193,3 +196,60 @@ describe('detectManifestActions — conda detector', () => {
193196
expect(result.count).toBe(0)
194197
})
195198
})
199+
200+
describe('detectManifestActions — dotnet detector', () => {
201+
let cwd: string
202+
203+
beforeEach(() => {
204+
cwd = mkTmp()
205+
})
206+
207+
afterEach(() => {
208+
rmSync(cwd, { recursive: true, force: true })
209+
})
210+
211+
it.each([
212+
'App.sln',
213+
'App.slnx',
214+
'App.csproj',
215+
'App.fsproj',
216+
'App.vbproj',
217+
'APP.SLN',
218+
])('detects %s', async name => {
219+
touch(cwd, name)
220+
expect((await detectManifestActions(null, cwd)).dotnet).toBe(true)
221+
})
222+
223+
it('ignores a solution filter, other files and directories named like projects', async () => {
224+
touch(cwd, 'App.slnf')
225+
touch(cwd, 'App.sqlproj')
226+
mkdirSync(path.join(cwd, 'Lib.csproj'))
227+
expect((await detectManifestActions(null, cwd)).dotnet).toBe(false)
228+
})
229+
230+
it('skips dotnet when defaults.manifest.dotnet.disabled is true', async () => {
231+
touch(cwd, 'App.sln')
232+
const sockJson = {
233+
defaults: { manifest: { dotnet: { disabled: true } } },
234+
} as unknown as SocketJson
235+
expect((await detectManifestActions(sockJson, cwd)).dotnet).toBe(false)
236+
})
237+
238+
it('lists solutions before projects, each sorted by name', () => {
239+
for (const name of [
240+
'b.csproj',
241+
'A.vbproj',
242+
'Z.sln',
243+
'M.slnx',
244+
'notes.txt',
245+
]) {
246+
touch(cwd, name)
247+
}
248+
expect(findDotnetEntries(cwd)).toEqual([
249+
'M.slnx',
250+
'Z.sln',
251+
'A.vbproj',
252+
'b.csproj',
253+
])
254+
})
255+
})

‎src/commands/manifest/discover-manifest-roots.test.mts‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
buildRootPath,
99
findBuildToolCandidates,
1010
isBuildRootCovered,
11+
isSolutionCovered,
1112
sortByDepthThenPath,
1213
} from './discover-manifest-roots.mts'
1314
import { testPath } from '../../../test/utils.mts'
@@ -239,6 +240,22 @@ describe('isBuildRootCovered', () => {
239240
).toBe(false)
240241
})
241242

243+
it('covers a .NET project by its project file, never a solution', () => {
244+
const covered = new Set(['/repo/src/App/App.csproj'])
245+
expect(
246+
isBuildRootCovered(covered, 'dotnet', {
247+
dir: '/repo/src/App',
248+
entryFile: 'App.csproj',
249+
}),
250+
).toBe(true)
251+
expect(
252+
isBuildRootCovered(covered, 'dotnet', {
253+
dir: '/repo/src/App',
254+
entryFile: 'App.sln',
255+
}),
256+
).toBe(false)
257+
})
258+
242259
it('covers a root without an entry file through a marker file', () => {
243260
expect(
244261
isBuildRootCovered(new Set(['/repo/lib/settings.gradle.kts']), 'gradle', {
@@ -274,4 +291,132 @@ describe('sortByDepthThenPath', () => {
274291
{ dir: '/repo/b/nested' },
275292
])
276293
})
294+
295+
it('puts the solutions of a directory before its projects', () => {
296+
expect(
297+
sortByDepthThenPath(
298+
[
299+
{ dir: '/repo', entryFile: 'App.csproj' },
300+
{ dir: '/repo', entryFile: 'Zed.slnx' },
301+
{ dir: '/repo', entryFile: 'App.sln' },
302+
],
303+
'/repo',
304+
),
305+
).toEqual([
306+
{ dir: '/repo', entryFile: 'App.sln' },
307+
{ dir: '/repo', entryFile: 'Zed.slnx' },
308+
{ dir: '/repo', entryFile: 'App.csproj' },
309+
])
310+
})
311+
})
312+
313+
function listing(members: string[]) {
314+
return async () => members
315+
}
316+
317+
async function unreadable(): Promise<undefined> {
318+
return undefined
319+
}
320+
321+
async function failToList(): Promise<string[]> {
322+
throw new Error('listed')
323+
}
324+
325+
describe('isSolutionCovered', () => {
326+
const root = { dir: '/repo', entryFile: 'App.slnx' }
327+
328+
it('covers a solution once every project it builds is covered', async () => {
329+
const covered = new Set(['/repo/src/A/A.csproj', '/repo/src/B/B.csproj'])
330+
expect(
331+
await isSolutionCovered(
332+
covered,
333+
root,
334+
{},
335+
listing(['/repo/src/A/A.csproj', '/repo/src/B/B.csproj']),
336+
),
337+
).toBe(true)
338+
expect(
339+
await isSolutionCovered(
340+
covered,
341+
root,
342+
{},
343+
listing(['/repo/src/A/A.csproj', '/repo/src/C/C.csproj']),
344+
),
345+
).toBe(false)
346+
})
347+
348+
it('takes a solution the SDK cannot read as covered by its twin in the other format', async () => {
349+
const dir = await fs.mkdtemp(path.join(tmpdir(), 'entry-covered-'))
350+
try {
351+
await fs.writeFile(path.join(dir, 'App.sln'), '')
352+
await fs.writeFile(path.join(dir, 'App.slnx'), '')
353+
const covered = new Set(['/repo/src/A/A.csproj'])
354+
const slnxUnreadable = async ({ entryFile }: { entryFile?: string }) =>
355+
entryFile?.endsWith('.slnx') ? undefined : []
356+
expect(
357+
await isSolutionCovered(
358+
covered,
359+
{ dir, entryFile: 'App.slnx' },
360+
{},
361+
slnxUnreadable,
362+
),
363+
).toBe(true)
364+
// Also before anything is covered, e.g. when the twin built nothing.
365+
expect(
366+
await isSolutionCovered(
367+
new Set(),
368+
{ dir, entryFile: 'App.slnx' },
369+
{},
370+
slnxUnreadable,
371+
),
372+
).toBe(true)
373+
expect(
374+
await isSolutionCovered(
375+
covered,
376+
{ dir, entryFile: 'Other.slnx' },
377+
{},
378+
slnxUnreadable,
379+
),
380+
).toBe(false)
381+
} finally {
382+
await fs.rm(dir, { recursive: true, force: true })
383+
}
384+
})
385+
386+
it('builds an unreadable solution whose twin is unreadable too, so its failure shows', async () => {
387+
const dir = await fs.mkdtemp(path.join(tmpdir(), 'entry-covered-'))
388+
try {
389+
await fs.writeFile(path.join(dir, 'App.sln'), '')
390+
await fs.writeFile(path.join(dir, 'App.slnx'), '')
391+
const covered = await Promise.all(
392+
['App.sln', 'App.slnx'].map(entryFile =>
393+
isSolutionCovered(new Set(), { dir, entryFile }, {}, unreadable),
394+
),
395+
)
396+
expect(covered).toEqual([false, false])
397+
} finally {
398+
await fs.rm(dir, { recursive: true, force: true })
399+
}
400+
})
401+
402+
it('lists no members while nothing is covered and no twin exists, or for a project or a POM', async () => {
403+
const list = failToList
404+
expect(await isSolutionCovered(new Set(), root, {}, list)).toBe(false)
405+
expect(
406+
await isSolutionCovered(
407+
new Set(['/x']),
408+
{ dir: '/repo', entryFile: 'A.csproj' },
409+
{},
410+
list,
411+
),
412+
).toBe(false)
413+
expect(
414+
await isSolutionCovered(
415+
new Set(['/x']),
416+
{ dir: '/repo', entryFile: 'pom.xml' },
417+
{},
418+
list,
419+
),
420+
).toBe(false)
421+
})
277422
})

‎src/commands/manifest/generate-recursive-manifests.test.mts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@ vi.mock('../../utils/socket-json.mts', () => ({
1515
vi.mock('./run-manifest-facts.mts', () => ({
1616
runManifestFacts: vi.fn(),
1717
}))
18+
vi.mock('./scripts/run.mts', async importOriginal => ({
19+
...(await importOriginal<typeof import('./scripts/run.mts')>()),
20+
skipWithoutDotnetSdk: vi.fn(async () => false),
21+
}))
1822

1923
import { buildRootPath } from './discover-manifest-roots.mts'
2024
import { generateRecursiveManifests } from './generate-recursive-manifests.mts'
2125
import { runManifestFacts } from './run-manifest-facts.mts'
26+
import { skipWithoutDotnetSdk } from './scripts/run.mts'
2227
import { testPath } from '../../../test/utils.mts'
2328
import {
2429
readOrDefaultSocketJson,
@@ -518,6 +523,33 @@ describe('generateRecursiveManifests', () => {
518523
})
519524
})
520525

526+
it('skips .NET builds, without failing, where no .NET SDK runs', async () => {
527+
const dir = await fs.mkdtemp(path.join(tmpdir(), 'dsi-no-sdk-'))
528+
try {
529+
await fs.writeFile(path.join(dir, 'pom.xml'), '<project/>')
530+
await fs.mkdir(path.join(dir, 'samples'))
531+
await fs.writeFile(path.join(dir, 'samples', 'Stray.csproj'), '')
532+
vi.mocked(skipWithoutDotnetSdk).mockResolvedValueOnce(true)
533+
vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({
534+
factsPath: path.join(cwd, 'pom.xml.socket.facts.json'),
535+
projects: [],
536+
}))
537+
const outcomes = await generateRecursiveManifests({
538+
cwd: dir,
539+
verbose: false,
540+
})
541+
542+
expect(
543+
outcomes.map(o => `${o.ecosystem}:${o.entryFile}:${o.status}`).sort(),
544+
).toEqual(['dotnet:Stray.csproj:skippedNoSdk', 'maven:pom.xml:generated'])
545+
expect(
546+
vi.mocked(runManifestFacts).mock.calls.map(([o]) => o.ecosystem),
547+
).toEqual(['maven'])
548+
} finally {
549+
await fs.rm(dir, { recursive: true, force: true })
550+
}
551+
})
552+
521553
it('resolves each build root its own nearest socket.json instead of only the root config', async () => {
522554
vi.mocked(readSocketJsonCascade).mockImplementation(
523555
(dir, _boundaryDir, fallback) =>

0 commit comments

Comments
 (0)