Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8ddd2b7
fix(wrapper-generator): map numeric parameter types by OpenAPI format
Joywambui-maina Aug 4, 2026
ca215f3
fix(wrapper-generator): correct four singularization words found by o…
Joywambui-maina Aug 3, 2026
c4d8545
fix(wrapper-generator): correct HostWhoi name, add Statistics invaria…
Joywambui-maina Aug 4, 2026
695cfe7
feat(wrapper-generator): add module packaging and smoke-test scripts
Joywambui-maina Aug 6, 2026
f2de362
fix(wrapper-generator): align emitted code with real kiota client output
Joywambui-maina Aug 6, 2026
26948e8
fix(wrapper-generator): correct PlaceCheckIn names, add Rights invari…
Joywambui-maina Aug 6, 2026
668a28d
fix(wrapper-generator): fail loudly on cmdlet file collisions
Joywambui-maina Aug 11, 2026
e6c98e4
feat(wrapper-generator): resolve cmdlet collisions via oracle-derived…
Joywambui-maina Aug 12, 2026
5608540
Potential fix for pull request finding
Joywambui-maina Aug 14, 2026
b7bf79a
Potential fix for pull request finding
Joywambui-maina Aug 14, 2026
2ed1ab3
Merge pull request #3713 from microsoftgraph/fix/cmdlet-file-collisions
Joywambui-maina Aug 14, 2026
e287a91
feat(wrapper-generator): complete v1.0 request-body binding
Joywambui-maina Aug 14, 2026
1b6f282
Merge powershell-v3 into feature/wrapper-module-packaging
Joywambui-maina Aug 14, 2026
3f99034
Merge feature/wrapper-module-packaging into feat/bind-request-body-pr…
Joywambui-maina Aug 14, 2026
dec945a
fix(wrapper-generator): align wrapper packaging projects
Joywambui-maina Aug 14, 2026
b1c7cb0
Merge branch 'feature/wrapper-module-packaging' into feat/bind-reques…
Joywambui-maina Aug 14, 2026
6addca0
feat(wrapper-generator): generate remaining OData operation shapes
Joywambui-maina Aug 18, 2026
db9f611
Merge remote-tracking branch 'origin/feat/bind-request-body-propertie…
Joywambui-maina Aug 18, 2026
c450ffc
feat(wrapper-generator): commit v1.0 wrapper sources under src/{Modul…
Joywambui-maina Aug 20, 2026
afebe5f
fix(wrapper-generator): correct wrapper package identity and Authenti…
Joywambui-maina Aug 21, 2026
7877dab
feat(wrapper-runtime): add shared runtime library with client-lifetim…
Joywambui-maina Aug 21, 2026
1689c38
feat(wrapper-generator): emit cmdlets on the shared runtime base class
Joywambui-maina Aug 21, 2026
faeef51
fix(wrapper-generator): resolve shared dependencies from the installe…
Joywambui-maina Aug 22, 2026
71478f7
fix(wrapper-generator): make wrapper packages installable from a feed
Joywambui-maina Aug 24, 2026
d17f0d9
feat(wrapper-generator): emit -All and nextLink following for list cm…
Joywambui-maina Aug 25, 2026
c8a833a
chore(wrapper-generator): regenerate the v1.0 corpus for -All pagination
Joywambui-maina Aug 25, 2026
1484db1
feat(wrapper-generator): emit deltaLink-aware pagination for delta op…
Joywambui-maina Aug 25, 2026
71ed2dc
Merge powershell-v3 into feat/wrapper-delta-generator
Joywambui-maina Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions tools/Test-WrapperDelta.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<#
.SYNOPSIS
Deterministic delta-pagination gate (#3742). Drives a REAL compiled delta cmdlet through a stub
transport that fabricates a change set - no tenant data is read beyond one /me call.

.DESCRIPTION
The runtime's session adapter cache is pre-seeded with an adapter whose HttpClient returns a
scripted change set: two @odata.nextLink pages, then a page carrying @odata.deltaLink. A separate
scripted response answers a resume request and returns a replacement link. The active
Connect-MgGraph session is used only as the cache KEY.

Proves, deterministically:
1. -All walks the change set to its terminal page and publishes the deltaLink
2. without -All: first page only, exactly one warning, no link published
3. -DeltaLink resumes from the literal link and publishes the replacement
4. the variable is cleared at invocation start, so a run that ends without a terminal page
cannot leave a previous run's link readable
5. no *DeltaWithToken command exists - the token form is a parameter set, not a command

Requires an active or cached Graph session (any scopes - only /me is requested).

.EXAMPLE
pwsh -NoProfile -File .\tools\Test-WrapperDelta.ps1
#>
param([string]$Configuration = 'Release')
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path $PSScriptRoot -Parent
$psd1 = Join-Path $repoRoot "src\Files\wrapper\v1.0\bin\$Configuration\netstandard2.0\Microsoft.Graph.Wrapper.Files.psd1"
if (-not (Test-Path $psd1)) {
throw "no built module at $psd1 - run tools\Build-WrapperModule.ps1 -Module Files -Configuration $Configuration first"
}

$fail = [System.Collections.Generic.List[string]]::new()
function Assert([bool]$ok, [string]$what) {
$tag = if ($ok) { 'PASS' } else { $script:fail.Add($what); 'FAIL' }
Write-Host "$tag $what"
}

$probe = @"
`$ErrorActionPreference = 'Stop'
Import-Module Microsoft.Graph.Authentication
Import-Module '$psd1'
Connect-MgGraph -NoWelcome
`$null = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/me'

Add-Type -TypeDefinition @'
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
public class DeltaStubHandler : HttpMessageHandler
{
public System.Collections.Generic.List<string> Urls = new System.Collections.Generic.List<string>();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
{
string url = request.RequestUri.ToString();
Urls.Add(url);
string body;
if (url.Contains("RESUMELINK"))
body = "{\"value\":[{\"id\":\"r1\"}],\"@odata.deltaLink\":\"https://graph.microsoft.com/v1.0/drives/d/items/i/delta?`$deltatoken=SECOND\"}";
else if (url.Contains("PAGE3"))
body = "{\"value\":[{\"id\":\"c1\"}],\"@odata.deltaLink\":\"https://graph.microsoft.com/v1.0/drives/d/items/i/delta?`$deltatoken=FIRST\"}";
else if (url.Contains("PAGE2"))
body = "{\"value\":[{\"id\":\"b1\"},{\"id\":\"b2\"}],\"@odata.nextLink\":\"https://graph.microsoft.com/v1.0/drives/d/items/i/delta?`$skiptoken=PAGE3\"}";
else
body = "{\"value\":[{\"id\":\"a1\"},{\"id\":\"a2\"}],\"@odata.nextLink\":\"https://graph.microsoft.com/v1.0/drives/d/items/i/delta?`$skiptoken=PAGE2\"}";
var resp = new HttpResponseMessage(HttpStatusCode.OK);
resp.Content = new StringContent(body, Encoding.UTF8, "application/json");
return Task.FromResult(resp);
}
}
'@

function Get-LoadedAssembly([string]`$name) {
`$a = [System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { `$_.GetName().Name -eq `$name } | Select-Object -First 1
if (-not `$a) { throw "assembly not loaded: `$name" }
`$a
}
`$runtimeAsm = Get-LoadedAssembly 'Microsoft.Graph.Wrapper.Runtime'
`$kiotaHttp = Get-LoadedAssembly 'Microsoft.Kiota.Http.HttpClientLibrary'
`$kiotaAbs = Get-LoadedAssembly 'Microsoft.Kiota.Abstractions'

`$real = [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpHelpers]::GetGraphHttpClient()
`$handler = [DeltaStubHandler]::new()
`$stub = [System.Net.Http.HttpClient]::new(`$handler)
`$anon = `$kiotaAbs.GetType('Microsoft.Kiota.Abstractions.Authentication.AnonymousAuthenticationProvider').GetConstructor([type[]]@()).Invoke(@())
`$adapterType = `$kiotaHttp.GetType('Microsoft.Kiota.Http.HttpClientLibrary.HttpClientRequestAdapter')
`$adapter = `$adapterType.GetConstructors() | Where-Object { `$_.GetParameters().Count -eq 5 } |
ForEach-Object { `$_.Invoke(@(`$anon, `$null, `$null, `$stub, `$null)) } | Select-Object -First 1
if (-not `$adapter) { throw 'no 5-parameter HttpClientRequestAdapter constructor - kiota surface changed' }

`$cache = `$runtimeAsm.GetType('Microsoft.Graph.Wrapper.Runtime.SessionAdapterCache', `$true)
`$flags = [System.Reflection.BindingFlags]'NonPublic,Static'
`$cache.GetField('_key', `$flags).SetValue(`$null, `$real)
`$cache.GetField('_adapter', `$flags).SetValue(`$null, `$adapter)

# --- 1. -All walks to the terminal page and publishes the link ---
`$handler.Urls.Clear()
`$dl = 'PRESET-STALE'
`$w1 = @()
`$all = @(Get-MgDriveItemDelta -DriveId d -DriveItemId i -All -DeltaLinkVariable dl -WarningVariable w1 -WarningAction SilentlyContinue)
`$r1 = [pscustomobject]@{ Test='all'; Items=`$all.Count; Requests=`$handler.Urls.Count; Warnings=`$w1.Count; Link="`$dl" }

# --- 2. without -All: one page, one warning, nothing published ---
`$handler.Urls.Clear()
`$dl2 = 'PRESET-STALE'
`$w2 = @()
`$page1 = @(Get-MgDriveItemDelta -DriveId d -DriveItemId i -DeltaLinkVariable dl2 -WarningVariable w2 -WarningAction SilentlyContinue)
`$r2 = [pscustomobject]@{ Test='nopage'; Items=`$page1.Count; Requests=`$handler.Urls.Count; Warnings=`$w2.Count; Link="`$dl2" }

# --- 3. resume from a link ---
`$handler.Urls.Clear()
`$dl3 = ''
`$w3 = @()
`$res = @(Get-MgDriveItemDelta -DeltaLink 'https://graph.microsoft.com/v1.0/drives/d/items/i/delta?RESUMELINK' -DeltaLinkVariable dl3 -WarningVariable w3 -WarningAction SilentlyContinue)
`$r3 = [pscustomobject]@{ Test='resume'; Items=`$res.Count; Requests=`$handler.Urls.Count; Warnings=`$w3.Count; Link="`$dl3"; FirstUrl=`$handler.Urls[0] }

# --- 5. the token form is not a command ---
`$tokenCmds = @(Get-Command -Module Microsoft.Graph.Wrapper.Files -Name '*DeltaWithToken*' -ErrorAction SilentlyContinue).Count
`$r5 = [pscustomobject]@{ Test='notoken'; Count=`$tokenCmds }

@(`$r1, `$r2, `$r3, `$r5) | ConvertTo-Json -Compress
"@
$enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($probe))
$out = & pwsh -NoProfile -NonInteractive -EncodedCommand $enc 2>&1
$json = $out | Where-Object { $_ -match '^\[' } | Select-Object -Last 1
if (-not $json) { Assert $false "probe produced no result: $(($out | Select-Object -Last 3) -join ' | ')" }
else {
$r = $json | ConvertFrom-Json
$a = $r | Where-Object Test -eq 'all'
Assert ($a.Items -eq 5) "-All returned the whole change set across 3 pages (got $($a.Items))"
Assert ($a.Requests -eq 3) "-All made exactly 3 requests (got $($a.Requests))"
Assert ($a.Warnings -eq 0) "-All emitted no warning (got $($a.Warnings))"
Assert ($a.Link -like '*deltatoken=FIRST*') "terminal deltaLink published to the variable (got '$($a.Link)')"

$n = $r | Where-Object Test -eq 'nopage'
Assert ($n.Items -eq 2 -and $n.Requests -eq 1) "without -All: first page only, 1 request (items=$($n.Items) req=$($n.Requests))"
Assert ($n.Warnings -eq 1) "exactly one truncation warning (got $($n.Warnings))"
Assert ([string]::IsNullOrEmpty($n.Link)) "no terminal page reached: variable cleared, not left stale (got '$($n.Link)')"

$s = $r | Where-Object Test -eq 'resume'
Assert ($s.FirstUrl -like '*RESUMELINK*') "resume issued the literal link supplied (got '$($s.FirstUrl)')"
Assert ($s.Items -eq 1 -and $s.Requests -eq 1) "resume returned its page in 1 request (items=$($s.Items) req=$($s.Requests))"
Assert ($s.Link -like '*deltatoken=SECOND*') "replacement deltaLink published (got '$($s.Link)')"

$t = $r | Where-Object Test -eq 'notoken'
Assert ($t.Count -eq 0) "no *DeltaWithToken command exists (found $($t.Count))"
}

''
if ($fail.Count -eq 0) { 'RESULT: ALL PASS' } else { "RESULT: $($fail.Count) FAILURE(S)" }
exit $fail.Count
100 changes: 99 additions & 1 deletion tools/WrapperGenerator.Tests/EmitterTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Net.Http;
using WrapperGenerator;
using Xunit;
Expand Down Expand Up @@ -119,6 +120,103 @@ public void DispatcherDeclaresAllOnListSetAndPassesPipelineStopThrough()
Assert.Contains("catch (Exception ex) when (ex is not PipelineStoppedException && ex is not OperationCanceledException)", source);
}

// Delta pins (#3742). A change-tracking read is a function by classification but a paged
// collection in practice, so it gets its own shape. The contract and its evidence live in
// tools/WrapperGenerator/docs/edge-cases/delta-edge-cases.md.
private static string EmitDeltaSource(IReadOnlySet<string>? queryParams = null)
{
var naming = Naming.Resolve(new OperationInfo(HttpMethod.Get, "/users/delta()"));
return CmdletEmitter.EmitDelta(naming, new EmitContext("Test.Client"),
new CmdletEmitter.CallPlan("GetAsDeltaGetResponseAsync", "DeltaGetResponse", BodyTypeName: null),
queryParams ?? new HashSet<string> { "$top", "$filter" });
}

// Initial sync and resume are one command with two parameter sets: the published SDK folds
// the token form into the canonical delta command rather than shipping it separately.
// -DeltaLink is universal, where a token argument exists on only a few routes.
[Fact]
public void DeltaEmitsSyncAndResumeParameterSets()
{
var source = EmitDeltaSource();

Assert.Contains("DefaultParameterSetName = \"DeltaSync\"", source);
Assert.Contains("[Parameter(Mandatory = true, ParameterSetName = \"Resume\")]", source);
Assert.Contains("public string DeltaLink { get; set; }", source);
Assert.Contains("ParameterSetName == \"Resume\"", source);
Assert.Contains(".WithUrl(DeltaLink).GetAsDeltaGetResponseAsync(", source);

// Query options belong to the initial sync only.
Assert.Contains("[Parameter(Mandatory = false, ParameterSetName = \"DeltaSync\")]", source);

// The token form must never appear as a parameter or a command of its own.
Assert.DoesNotContain("Token", source);
}

// Resuming from a link must not demand the path ids the link already carries - the raw-URL
// builder discards them anyway. Found by the delta gate: with the ids mandatory in every set,
// -DeltaLink failed binding with "missing mandatory parameters".
[Fact]
public void DeltaScopesPathIdsToTheInitialSyncSet()
{
var naming = Naming.Resolve(new OperationInfo(HttpMethod.Get, "/users/{user-id}/todo/lists/{todoTaskList-id}/tasks/delta()"));
var source = CmdletEmitter.EmitDelta(naming, new EmitContext("Test.Client"),
new CmdletEmitter.CallPlan("GetAsDeltaGetResponseAsync", "DeltaGetResponse", BodyTypeName: null),
new HashSet<string>());

Assert.Contains("[Parameter(Mandatory = true, Position = 0, ParameterSetName = \"DeltaSync\")]", source);
Assert.DoesNotContain("[Parameter(Mandatory = true, Position = 0)]", source);
}

// The terminal link is published to a caller-named variable, the idiom this SDK already uses
// for returning a scalar beside a pipeline, and cleared first so a failed run cannot leave
// the previous run's link readable.
[Fact]
public void DeltaPublishesTerminalLinkAndClearsItFirst()
{
var source = EmitDeltaSource();

Assert.Contains("[Alias(\"DLV\")]", source);
Assert.Contains("public string? DeltaLinkVariable { get; set; }", source);
Assert.Contains("SessionState.PSVariable.Set(DeltaLinkVariable, null);", source);
Assert.Contains("SessionState.PSVariable.Set(DeltaLinkVariable, deltaLink);", source);

// Clearing must precede the request, or a failure leaves a stale link behind.
Assert.True(source.IndexOf("SessionState.PSVariable.Set(DeltaLinkVariable, null);", StringComparison.Ordinal)
< source.IndexOf("result = ParameterSetName", StringComparison.Ordinal));
}

// Every terminal state is decided explicitly; a response carrying both links is refused
// rather than guessed at.
[Fact]
public void DeltaHandlesEveryTerminalState()
{
var source = EmitDeltaSource();

Assert.Contains("if (!string.IsNullOrEmpty(nextLink) && !string.IsNullOrEmpty(deltaLink))", source);
Assert.Contains("\"InvalidDeltaResponse\"", source);
Assert.Contains("if (string.IsNullOrEmpty(nextLink)) break;", source);
Assert.Contains("WriteWarning(\"More results are available. Use -All to return all pages.\");", source);
Assert.Contains("if (Stopping) break;", source);
Assert.Contains("if (this.IsParameterBound(nameof(Top)) && fetched >= Top) break;", source);
Assert.Contains("catch (Exception ex) when (ex is not PipelineStoppedException)", source);

// Continuation re-applies headers only: the link already carries the query state.
var continuation = source[source.IndexOf(".WithUrl(nextLink)", StringComparison.Ordinal)..];
Assert.DoesNotContain("QueryParameters", continuation);
}

// Without a declared $top there is no cap to enforce, so no counter is emitted either -
// an unused variable would not compile cleanly and would be dead weight in every file.
[Fact]
public void DeltaWithoutTopEmitsNoCounter()
{
var source = EmitDeltaSource(new HashSet<string>());

Assert.Contains("if (Stopping) break;", source);
Assert.DoesNotContain("fetched", source);
Assert.DoesNotContain("public int Top", source);
}

// Control: non-list shapes must not grow paging machinery.
[Fact]
public void NonListShapesEmitNoPagingMachinery()
Expand Down
Loading
Loading