Skip to content

Commit cd920f4

Browse files
jfblaaclaude
andcommitted
test(manifest): cover the .NET review fixes
- Smoke-check that --include-configs filters the per-framework build context, that a failed restore never reads another project's assets from an earlier restore, and that a project outside the scan root is never excluded. - Unit-test the SDK probe shared under one global.json and the cached solution listings, and follow isRootCovered, typed entry ordering and code-only NuGet classification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a9ca528 commit cd920f4

7 files changed

Lines changed: 100 additions & 24 deletions

File tree

‎src/commands/manifest/cmd-manifest-dotnet.test.mts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('socket manifest dotnet', async () => {
2525
--bin Location of the dotnet binary to use, default: dotnet on PATH
2626
--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
2727
--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.
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. A build module outside CWD cannot be excluded.
2929
--ignore-unresolved Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file)
3030
--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
3131
--verbose Print debug messages

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

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
buildRootPath,
99
findBuildToolCandidates,
1010
isBuildRootCovered,
11-
isSolutionCovered,
11+
isRootCovered,
1212
sortByDepthThenPath,
1313
} from './discover-manifest-roots.mts'
1414
import { testPath } from '../../../test/utils.mts'
@@ -301,6 +301,7 @@ describe('sortByDepthThenPath', () => {
301301
{ dir: '/repo', entryFile: 'App.sln' },
302302
],
303303
'/repo',
304+
'dotnet',
304305
),
305306
).toEqual([
306307
{ dir: '/repo', entryFile: 'App.sln' },
@@ -322,22 +323,24 @@ async function failToList(): Promise<string[]> {
322323
throw new Error('listed')
323324
}
324325

325-
describe('isSolutionCovered', () => {
326+
describe('isRootCovered', () => {
326327
const root = { dir: '/repo', entryFile: 'App.slnx' }
327328

328329
it('covers a solution once every project it builds is covered', async () => {
329330
const covered = new Set(['/repo/src/A/A.csproj', '/repo/src/B/B.csproj'])
330331
expect(
331-
await isSolutionCovered(
332+
await isRootCovered(
332333
covered,
334+
'dotnet',
333335
root,
334336
{},
335337
listing(['/repo/src/A/A.csproj', '/repo/src/B/B.csproj']),
336338
),
337339
).toBe(true)
338340
expect(
339-
await isSolutionCovered(
341+
await isRootCovered(
340342
covered,
343+
'dotnet',
341344
root,
342345
{},
343346
listing(['/repo/src/A/A.csproj', '/repo/src/C/C.csproj']),
@@ -354,25 +357,28 @@ describe('isSolutionCovered', () => {
354357
const slnxUnreadable = async ({ entryFile }: { entryFile?: string }) =>
355358
entryFile?.endsWith('.slnx') ? undefined : []
356359
expect(
357-
await isSolutionCovered(
360+
await isRootCovered(
358361
covered,
362+
'dotnet',
359363
{ dir, entryFile: 'App.slnx' },
360364
{},
361365
slnxUnreadable,
362366
),
363367
).toBe(true)
364368
// Also before anything is covered, e.g. when the twin built nothing.
365369
expect(
366-
await isSolutionCovered(
370+
await isRootCovered(
367371
new Set(),
372+
'dotnet',
368373
{ dir, entryFile: 'App.slnx' },
369374
{},
370375
slnxUnreadable,
371376
),
372377
).toBe(true)
373378
expect(
374-
await isSolutionCovered(
379+
await isRootCovered(
375380
covered,
381+
'dotnet',
376382
{ dir, entryFile: 'Other.slnx' },
377383
{},
378384
slnxUnreadable,
@@ -390,7 +396,13 @@ describe('isSolutionCovered', () => {
390396
await fs.writeFile(path.join(dir, 'App.slnx'), '')
391397
const covered = await Promise.all(
392398
['App.sln', 'App.slnx'].map(entryFile =>
393-
isSolutionCovered(new Set(), { dir, entryFile }, {}, unreadable),
399+
isRootCovered(
400+
new Set(),
401+
'dotnet',
402+
{ dir, entryFile },
403+
{},
404+
unreadable,
405+
),
394406
),
395407
)
396408
expect(covered).toEqual([false, false])
@@ -401,18 +413,20 @@ describe('isSolutionCovered', () => {
401413

402414
it('lists no members while nothing is covered and no twin exists, or for a project or a POM', async () => {
403415
const list = failToList
404-
expect(await isSolutionCovered(new Set(), root, {}, list)).toBe(false)
416+
expect(await isRootCovered(new Set(), 'dotnet', root, {}, list)).toBe(false)
405417
expect(
406-
await isSolutionCovered(
418+
await isRootCovered(
407419
new Set(['/x']),
420+
'dotnet',
408421
{ dir: '/repo', entryFile: 'A.csproj' },
409422
{},
410423
list,
411424
),
412425
).toBe(false)
413426
expect(
414-
await isSolutionCovered(
427+
await isRootCovered(
415428
new Set(['/x']),
429+
'maven',
416430
{ dir: '/repo', entryFile: 'pom.xml' },
417431
{},
418432
list,

‎src/commands/manifest/scripts/resolution-report-nuget.test.mts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('classifyNugetFailure', () => {
1414
'repository-or-network',
1515
],
1616
[
17-
'Response status code does not indicate success: 401 (Unauthorized).',
17+
'NU1301: Unable to load the service index for source https://feed/index.json. Response status code does not indicate success: 401 (Unauthorized).',
1818
'repository-or-network',
1919
],
2020
[
@@ -35,15 +35,15 @@ describe('classifyNugetFailure', () => {
3535
],
3636
[
3737
'could not evaluate the project: The imported project "x.props" was not found.',
38-
'config-problem',
38+
'other',
3939
],
4040
[
4141
'the design-time build failed: MSB3644: The reference assemblies for .NETFramework,Version=v4.8 were not found.',
4242
'config-problem',
4343
],
4444
[
4545
'obj/project.assets.json belongs to B.csproj: the projects share an intermediate output directory, so restore output cannot be attributed',
46-
'config-problem',
46+
'other',
4747
],
4848
[
4949
'runtime assemblies restore listed are missing from the package folders (lib/net8.0/Foo.dll); re-run `dotnet restore`',

‎src/commands/manifest/scripts/run.test.mts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@ vi.mock('@socketsecurity/registry/lib/spawn', () => ({
1818
spawn: vi.fn(async () => ({ code: 0, stderr: '', stdout: '' })),
1919
}))
2020
// The Maven extension jar and the dotnet tool exist only in a built checkout.
21+
// Each dotnetDir() case pins its own SDK, so cases never share a probe.
2122
vi.mock('node:fs', async importOriginal => {
2223
const actual = await importOriginal<typeof import('node:fs')>()
2324
return {
2425
...actual,
2526
existsSync: (p: string) =>
26-
p.endsWith('.jar') || p.endsWith('.dll') || actual.existsSync(p),
27+
p.endsWith('.jar') ||
28+
p.endsWith('.dll') ||
29+
/[\\/]repo[\\/]dotnet-\d+[\\/]global\.json$/.test(p) ||
30+
actual.existsSync(p),
2731
}
2832
})
2933

@@ -162,8 +166,12 @@ function mockDotnet(sdk: string, records = '', code = 0): void {
162166
}) as any)
163167
}
164168

169+
function toolCalls() {
170+
return vi.mocked(spawn).mock.calls.filter(c => c[1]?.[0] !== '--version')
171+
}
172+
165173
function toolCall() {
166-
return vi.mocked(spawn).mock.calls.find(c => c[1]?.[0] !== '--version')
174+
return toolCalls()[0]
167175
}
168176

169177
describe('dotnet tool invocation', () => {
@@ -277,6 +285,20 @@ describe('skipWithoutDotnetSdk', () => {
277285
mockDotnet('8.0.414')
278286
expect(await skipWithoutDotnetSdk('dotnet', dotnetDir())).toBe(false)
279287
})
288+
289+
it('probes the SDK once per global.json', async () => {
290+
const pinned = dotnetDir()
291+
mockDotnet('8.0.414')
292+
for (const dir of ['a', path.join('b', 'c')]) {
293+
// eslint-disable-next-line no-await-in-loop
294+
expect(await skipWithoutDotnetSdk('dotnet', path.join(pinned, dir))).toBe(
295+
false,
296+
)
297+
}
298+
expect(
299+
vi.mocked(spawn).mock.calls.filter(c => c[1]?.[0] === '--version'),
300+
).toHaveLength(1)
301+
})
280302
})
281303

282304
function solutionOpts(projectDir: string) {
@@ -311,4 +333,13 @@ describe('listDotnetEntryProjects', () => {
311333
mockDotnet('8.0.414', 'member\tsrc/A/A.csproj\n', 1)
312334
expect(await listDotnetEntryProjects(solutionOpts(dotnetDir()))).toEqual([])
313335
})
336+
337+
it('lists an entry once for the same options', async () => {
338+
const opts = solutionOpts(dotnetDir())
339+
mockDotnet('8.0.414', 'member\tsrc/A/A.csproj\n')
340+
await listDotnetEntryProjects(opts)
341+
await listDotnetEntryProjects({ ...opts })
342+
await listDotnetEntryProjects({ ...opts, excludePaths: ['src/A'] })
343+
expect(toolCalls()).toHaveLength(2)
344+
})
314345
})

‎src/commands/manifest/scripts/test/dotnet-compat/assert-facts.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,18 @@ def records_phase():
177177
analyzers = {r[4] for r in info_rows(basic, 'src/Lib/Lib.csproj', 'Analyzer', 'net6.0')}
178178
check(any('demo.ext.analyzer' in p.lower() for p in analyzers), f'analyzers {analyzers}')
179179

180+
netstandard = records('basic-netstandard.tsv')
181+
built = {(r[1], r[3]) for r in netstandard if r[0] == 'projectSrc'}
182+
built |= {(r[1], r[2]) for r in netstandard if r[0] == 'projectInfo'}
183+
check(built and {c for _, c in built} == {'netstandard2.0'}, f'builds outside --include-configs {sorted(built)}')
184+
185+
stale = records('basic-stale.tsv')
186+
check(any(r[0] == 'failure' and 'Demo.Ext.DoesNotExist' in '\t'.join(r) for r in stale), 'failed restore reported')
187+
reused = {r[2] for r in stale if r[0] == 'root'} - {'test/App.Tests/App.Tests.csproj'}
188+
check(not reused, f'projects read from a previous restore {sorted(reused)}')
189+
unknown = {r[3] for r in stale if r[0] == 'unscannable' and 'did not restore this project' in r[2]}
190+
check({'src/App/App.csproj', 'src/Extra/Extra.csproj', 'src/Lib/Lib.csproj'} <= unknown, f'unrestored projects reported {sorted(unknown)}')
191+
180192
designtime = records('designtime.tsv')
181193
failed = [r for r in designtime if r[0] == 'failure' and 'the design-time build failed' in r[2]]
182194
check(len(failed) == 1 and 'generated sources unavailable' in failed[0][2], f'design-time failure {failed}')

‎src/commands/manifest/scripts/test/dotnet-compat/smoke-test-exclude-base.sh‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
# the build root: a `../shared` project outside the build root is excluded by
44
# its scan-root path, and a nested project by the build root's path in the scan
55
# root, both from the projects --workspaces follows and from the solution
6-
# members --members lists.
6+
# members --members lists. A project outside the scan root is never excluded,
7+
# even by a wildcard that would match its `../` path.
78
#
89
# Usage: smoke-test-exclude-base.sh <dotnet> [sdk-version]
910
set -euo pipefail
@@ -22,9 +23,12 @@ echo "+ dotnet SDK $SDK_VERSION (exclude base)"
2223

2324
# The project names --workspaces keeps, or the member paths --members lists.
2425
kept() {
25-
local mode="$1" entry="$2" pattern="$3"
26+
local mode="$1" entry="$2" pattern="$3" base="${4:-$SCAN_ROOT}"
27+
rm -f "$WORK/records.tsv"
2628
run_tool "$SCAN_ROOT/app" --records "$WORK/records.tsv" --entry "$entry" \
27-
"--$mode" --exclude-paths "$pattern" --exclude-base "$SCAN_ROOT"
29+
"--$mode" --exclude-paths "$pattern" --exclude-base "$base"
30+
# A run that wrote no records keeps nothing, which check reports.
31+
[ -f "$WORK/records.tsv" ] || return 0
2832
if [ "$mode" = workspaces ]; then
2933
awk -F'\t' '$1 == "project" { print $4 }' "$WORK/records.tsv"
3034
else
@@ -33,9 +37,9 @@ kept() {
3337
}
3438
check() {
3539
local got
36-
got="$(kept "$@")"
40+
got="$(kept "$1" "$2" "$3" "${5:-}")"
3741
if [ "$got" != "$4" ]; then
38-
echo "FAIL: --$1 of $2 excluding $3 kept [$got], want [$4]" >&2
42+
echo "FAIL: --$1 of $2 excluding $3 from ${5:-$SCAN_ROOT} kept [$got], want [$4]" >&2
3943
exit 1
4044
fi
4145
}
@@ -46,4 +50,7 @@ check workspaces App.csproj '^(?:core)(?:/.*)?$' 'App Core Shared '
4650
check members App.sln '^(?:shared)(?:/.*)?$' 'App.csproj core/Core.csproj '
4751
check members App.sln '^(?:app/core)(?:/.*)?$' '../shared/Shared.csproj App.csproj '
4852
check members App.sln '^(?:core)(?:/.*)?$' '../shared/Shared.csproj App.csproj core/Core.csproj '
49-
echo "PASS: projects inside and outside the build root are excluded by their scan-root paths"
53+
# `**/shared`, scanned from app: ../shared is outside the scan root.
54+
check workspaces App.csproj '^(?:(?:[^/]+/)*shared)(?:/.*)?$' 'App Core Shared ' "$SCAN_ROOT/app"
55+
check members App.sln '^(?:(?:[^/]+/)*shared)(?:/.*)?$' '../shared/Shared.csproj App.csproj core/Core.csproj ' "$SCAN_ROOT/app"
56+
echo "PASS: projects are excluded by their scan-root paths, never outside the scan root"

‎src/commands/manifest/scripts/test/dotnet-compat/smoke-test.sh‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,9 @@ python3 "$HERE/assert-facts.py" "$WORK" "$SDK_VERSION" "$CPM_ENTRY" ignore-unres
9797

9898
# --with-files: the records the reachability sidecar is built from.
9999
records() {
100-
run_tool "$(dirname "$1")" --records "$2" --entry "$1" --with-files
100+
run_tool "$(dirname "$1")" --records "$2" --entry "$1" --with-files "${@:3}"
101101
}
102+
records "$WORK/basic/Basic.sln" "$WORK/basic-netstandard.tsv" --include-configs netstandard2.0
102103
records "$WORK/basic/Basic.sln" "$WORK/basic.tsv"
103104
records "$WORK/langs/Vb/Vb.vbproj" "$WORK/langs.tsv"
104105
records "$WORK/failures/designtime/DesignTime.csproj" "$WORK/designtime.tsv"
@@ -112,6 +113,17 @@ for pkg_id, version, folder in (('Demo.Ext.Tool', '1.0.0', '1.0.0'), ('Demo.Ext.
112113
zipfile.ZipFile(nupkg).extractall(os.path.join(work, 'packages-config', 'packages', f'{pkg_id}.{folder}'))
113114
PY
114115
records "$WORK/packages-config/Mixed.sln" "$WORK/pc-present.tsv"
116+
# A restore failing in one project leaves the others' caches as the last
117+
# restore wrote them, which cannot show they are current.
118+
python3 - "$WORK/basic/test/App.Tests/App.Tests.csproj" <<'PY'
119+
import sys
120+
path = sys.argv[1]
121+
with open(path) as f:
122+
project = f.read()
123+
with open(path, 'w') as f:
124+
f.write(project.replace('</Project>', '<ItemGroup><PackageReference Include="Demo.Ext.DoesNotExist" Version="1.0.0" /></ItemGroup></Project>'))
125+
PY
126+
records "$WORK/basic/Basic.sln" "$WORK/basic-stale.tsv"
115127
python3 "$HERE/assert-facts.py" "$WORK" "$SDK_VERSION" "$CPM_ENTRY" records
116128

117129
echo "dotnet compat smoke test passed: SDK $SDK_VERSION"

0 commit comments

Comments
 (0)