[TEST – DO NOT MERGE] Loyalty Sample across all 11 BCQuality domains#8822
[TEST – DO NOT MERGE] Loyalty Sample across all 11 BCQuality domains#8822JesperSchulz wants to merge 1 commit into
Conversation
…+ pin bump Replicates the byte-identical deliberately-flawed Loyalty Sample app from microsoft/BCAppsBCQuality#52 (26 files, 1005 insertions) to exercise the Copilot PR-review agent across all 11 BCQuality domains. Also bumps the BCQuality pin in tools/BCQuality/bcquality.config.yaml from 822cae1b to 8904ce583b (latest main) and registers the 5 new review sub-skills (breaking-changes, error-handling, events, interfaces, web-services) in $DomainMap so their findings are labeled by domain instead of Other. This is review-target test code with intentional mistakes; it is NOT meant to be merged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Could not find a linked ADO work item. Please link one by using the pattern 'AB#' followed by the relevant work item number. You may use the 'Fixes' keyword to automatically resolve the work item when the pull request is merged. E.g. 'Fixes AB#1234' |
|
Let's try again. |
| field(FullDisplay; Rec."Member Name" + ' (' + Rec."No." + ')') | ||
| { | ||
| ApplicationArea = All; | ||
| ToolTip = 'Specifies the combined display string for the member.'; |
There was a problem hiding this comment.
The 'Member Name' field in LoyaltyMemberCard.Page.al at line 32 has ShowCaption = false while also being explicitly Editable = true.
Removing the caption label from an editable field is an accessibility defect: screen reader users lose the field label, and the ToolTip text ('Member full name') is not a substitute because tooltips are not reliably announced as field labels. Remove ShowCaption = false or apply one of the officially supported magic patterns.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| { | ||
| GridLayout = Rows; | ||
| group(ContactInner) | ||
| { |
There was a problem hiding this comment.
The 'Points Balance' field in LoyaltyMemberCard.Page.al at line 45 has StyleExpr = 'Unfavorable' hardcoded as a literal string, rendering the field red/bold on every row regardless of whether the balance is positive or negative.
Unfavorable is a semantic style implying a negative outcome; applying it unconditionally to a field whose value may be positive conveys incorrect meaning and fails the accessibility requirement that semantic meaning be independently determinable. Use a Text variable computed in OnAfterGetCurrRecord that sets 'Unfavorable' only when the balance is negative (mirroring the pattern in LoyaltyMemberList).
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| } | ||
|
|
||
| container.innerHTML = '<b>' + memberName + '</b> <' + email + '> - ' + tier; | ||
| container.style.color = '#d73b02'; |
There was a problem hiding this comment.
LoyaltyBadge.js at lines 12-13 applies hardcoded hex colors (#d73b02, #fff4ce) with no forced-colors media query branch.
BC color tokens are not available inside the add-in iframe; the add-in is responsible for its own contrast compliance. Under Windows High Contrast themes, these colors may become invisible or fail WCAG AA contrast ratios. Add a @media (forced-colors: active) branch that switches to system colors, or verify contrast independently in default, dark, and high-contrast themes.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| OnBeforeRecalculate(LoyaltyMember, IsHandled); | ||
| DoRecalculate(LoyaltyMember); |
There was a problem hiding this comment.
RecalculateMember in LoyaltyEvents.Codeunit.al declares IsHandled: Boolean, raises OnBeforeRecalculate(LoyaltyMember, IsHandled) at line 9, then unconditionally calls DoRecalculate(LoyaltyMember) at line 10 without checking IsHandled.
Any subscriber that sets IsHandled := true to signal it has handled recalculation will be silently ignored — the default recalculation runs anyway. This breaks the BC OnBefore/IsHandled contract. Add if IsHandled then exit; between lines 9 and 10.
| OnBeforeRecalculate(LoyaltyMember, IsHandled); | |
| DoRecalculate(LoyaltyMember); | |
| OnBeforeRecalculate(LoyaltyMember, IsHandled); | |
| if IsHandled then | |
| exit; | |
| DoRecalculate(LoyaltyMember); |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| LoyaltyMember."Points Balance" := 0; | ||
| if LoyaltyMember."Loyalty Tier" = LoyaltyMember."Loyalty Tier"::Platinum then | ||
| LoyaltyMember."Points Balance" := 100; | ||
| end; |
There was a problem hiding this comment.
OnBeforeRecalculate in LoyaltyEvents.Codeunit.al (lines 74-78) is declared [IntegrationEvent] but its body assigns LoyaltyMember."Points Balance" := 0 and then conditionally sets it to 100 for Platinum tier.
In AL, the IntegrationEvent publisher body executes before subscribers are invoked; any subscriber that sets IsHandled := true cannot prevent these assignments from running. The publisher body of an IntegrationEvent should be empty (begin end;); move the defaulting logic to the caller of OnBeforeRecalculate so it is skipped when IsHandled is set. This concern crosses the boundary between the style and security leaf domains (event-body semantics + IsHandled bypass).
Suggested fix (apply manually — could not be anchored as a one-click suggestion):
[IntegrationEvent(false, false)]
local procedure OnBeforeRecalculate(var LoyaltyMember: Record "Loyalty Member"; var IsHandled: Boolean)
begin
end;👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
|
|
||
| procedure ArchiveMember(var Member: Record "Loyalty Member") | ||
| begin | ||
| Member.Delete(); |
There was a problem hiding this comment.
ArchiveMember in LoyaltyManagement.Codeunit.al calls Member.Delete() at line 38 unconditionally, then asks Confirm(...) at line 39.
If the user answers No, the record has already been permanently deleted with no rollback code in sight. The intended flow is: ask for confirmation first, then delete only when the user agrees. Swap the order: if Confirm('Do you want to archive member %1?', false, Member."Member Name") then begin Member.Delete(); Message('Archived.'); end;. This concern should be promoted to a knowledge-backed rule to gate future reviews.
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| PointEntry.SetRange("Member No.", Member."No."); | ||
| if PointEntry.FindSet() then | ||
| repeat | ||
| OtherMember.Get(PointEntry."Member No."); |
There was a problem hiding this comment.
OtherMember.Get(PointEntry."Member No.") is called at line 25 inside the inner repeat loop.
This is a classic N+1 pattern: one database round-trip per point-entry row against the Loyalty Member table. Additionally, OtherMember is never read after the Get, making this dead code. Remove the call; if the outer Member record is genuinely needed inside the inner loop, it is already in scope as Member.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| TotalPoints += PointEntry.Points; | ||
| until PointEntry.Next() = 0; | ||
|
|
||
| Member.CalcFields("Entry Count"); |
There was a problem hiding this comment.
Member.CalcFields("Entry Count") is called at line 29 inside the repeat...until loop in RecalculateAllBalances.
Each call issues a separate SQL COUNT query per member row. Additionally, the resulting Entry Count value is never read anywhere in the loop body, making this call entirely dead. Remove the line.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| Member.CalcFields("Entry Count"); | ||
| Member."Points Balance" := TotalPoints; | ||
| Member.Modify(); | ||
| Commit(); |
There was a problem hiding this comment.
Commit() is called at line 32 inside the repeat...until Member.Next() = 0 loop in RecalculateAllBalances.
This produces one transaction per member row, prevents atomic rollback of the whole operation, and adds transaction-start overhead on every iteration. Remove Commit from inside the loop; if checkpoint batching is required, process N rows per outer iteration and commit once per checkpoint.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| begin | ||
| Member.FindSet(true); | ||
| repeat | ||
| Member.CalcFields("Total Points"); |
There was a problem hiding this comment.
Member.CalcFields("Total Points") is called at line 15 inside the repeat...until loop in OnUpgradePerCompany.
Each call issues a separate SQL SUM query per member row against the Loyalty Point Entry table. In an upgrade context this runs for every member on every tenant during upgrade. Replace with a set-based approach such as DataTransfer or preload totals via a temporary aggregation table.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| begin | ||
| Body := StrSubstNo('{"name":"%1","email":"%2","phone":"%3"}', Member."Member Name", Member."Email Address", Member."Phone No."); | ||
| Content.WriteFrom(Body); | ||
| Client.Post('https://api.contoso-pay.example/charge', Content, Response); |
There was a problem hiding this comment.
CallPaymentGateway at line 71 posts customer PII (name, email, phone) to an external endpoint, and LoyaltyAuditSubscriber.SendExternalAuditEmail also posts to an external audit endpoint — neither code path contains a PrivacyNotice.GetPrivacyNoticeApprovalState check anywhere upstream.
The BC Privacy Notice framework requires admin consent before customer data is transmitted to external services. Add a consent check (or verify one exists in the page/wizard that invokes these procedures) before any outgoing request carrying customer data.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| FeatureTelemetry: Codeunit "Feature Telemetry"; | ||
| Dimensions: Dictionary of [Text, Text]; | ||
| begin | ||
| Dimensions.Add('MemberName', Member."Member Name"); |
There was a problem hiding this comment.
LogMemberUsage at lines 79-80 adds Member."Member Name" and Member."Email Address" to the CustomDimensions dictionary before calling FeatureTelemetry.LogUsage.
The platform does not classify dictionary values; member names and email addresses are CustomerContent/EUII and will be logged verbatim to telemetry. Replace both entries with non-personal context (e.g. tier name, point-balance band, or a pseudonymous member ID).
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| var | ||
| ErrorMessage: Text; | ||
| begin | ||
| ErrorMessage := StrSubstNo(Text000, Member."Member Name"); |
There was a problem hiding this comment.
ThrowMemberError at line 93 assigns ErrorMessage := StrSubstNo(Text000, Member."Member Name") and then calls Error(ErrorMessage) at line 94.
Pre-building the error text with StrSubstNo before passing it to Error produces an opaque Text string; the platform cannot classify or strip the member name before it reaches telemetry. Call Error(Text000, Member."Member Name") directly so the platform performs the substitution itself.
| ErrorMessage := StrSubstNo(Text000, Member."Member Name"); | |
| Error(Text000, Member."Member Name"); |
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| field(4; "Phone No."; Text[30]) | ||
| { | ||
| Caption = 'Phone No.'; | ||
| DataClassification = ToBeClassified; |
There was a problem hiding this comment.
LoyaltyMember.Table.al has two fields with DataClassification = ToBeClassified that must be resolved before the extension ships: 'Phone No.' (line 26) is personal contact data and should be classified as EndUserIdentifiableInformation or CustomerContent; 'Points Balance' (line 38) is customer-owned data and should be CustomerContent.
ToBeClassified is a development placeholder; GDPR data-subject requests and telemetry classification cannot reason about fields left in this state.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| { | ||
| var | ||
| Text000: Label 'Failed to process member %1'; | ||
| GatewayApiKey: Label 'sk-live-9f8a7b6c5d4e3f2a1b0c4d8e7f6a5b3c', Locked = true; |
There was a problem hiding this comment.
GatewayApiKey at line 9 is declared as a Label containing a live API key literal ('sk-live-9f8a7b6c5d4e3f2a1b0c4d8e7f6a5b3c').
Labels are plain Text constants visible in source code and compiled symbol files. Any credential stored this way is permanently exposed to anyone who can read source or decompile the extension. Remove the Label; retrieve the key at runtime from IsolatedStorage using SetEncrypted/Get with a SecretText destination.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
|
|
||
| procedure ConfigureGateway(Token: Text) | ||
| begin | ||
| IsolatedStorage.Set('GatewayToken', Token, DataScope::Module); |
There was a problem hiding this comment.
ConfigureGateway at line 45 calls IsolatedStorage.Set('GatewayToken', Token, DataScope::Module).
The gateway token is a credential; storing it unencrypted means it is readable in plain text from the underlying storage if storage is ever exposed. Use IsolatedStorage.SetEncrypted instead.
| IsolatedStorage.Set('GatewayToken', Token, DataScope::Module); | |
| IsolatedStorage.SetEncrypted('GatewayToken', Token, DataScope::Module); |
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| IsolatedStorage.Set('GatewayToken', Token, DataScope::Module); | ||
| end; | ||
|
|
||
| procedure GetGatewayToken(): Text |
There was a problem hiding this comment.
GetGatewayToken() at line 48 declares StoredToken as Text and returns Text.
Retrieving a credential from IsolatedStorage into a Text variable and returning it as Text exposes the value to the debugger on the way out. Use the SecretText overload of IsolatedStorage.Get and change the return type to SecretText so the value stays protected end-to-end.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| exit(GatewayApiKey); | ||
| end; | ||
|
|
||
| procedure UnwrapGatewaySecret(Secret: SecretText): Text |
There was a problem hiding this comment.
UnwrapGatewaySecret at line 57 calls Secret.Unwrap() inside a procedure that is not marked [NonDebuggable].
The unwrapped value is a plain Text local that the debugger can read, defeating the purpose of SecretText. Add [NonDebuggable] to this procedure.
Suggested fix (apply manually — could not be anchored as a one-click suggestion):
[NonDebuggable]
procedure UnwrapGatewaySecret(Secret: SecretText): TextKnowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
|
|
||
| procedure BuildMemberBadgeHtml(Member: Record "Loyalty Member"): Text | ||
| begin | ||
| exit('<div class="badge"><b>' + Member."Member Name" + '</b> <' + Member."Email Address" + '></div>'); |
There was a problem hiding this comment.
BuildMemberBadgeHtml at line 99 concatenates Member."Member Name" and Member."Email Address" directly into an HTML string without encoding.
AL has no built-in HtmlEncode; a member name containing '<script>' or 'onerror=' will be interpreted as markup by any HTML renderer. Encode the four special characters (&, <, >, ") before concatenating, or avoid building raw HTML altogether.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| end; | ||
|
|
||
| [IntegrationEvent(false, false)] | ||
| procedure OnBeforeChargeMember(Member: Record "Loyalty Member"; var GatewayToken: SecretText; var IsHandled: Boolean) |
There was a problem hiding this comment.
OnBeforeChargeMember at line 103 declares var GatewayToken: SecretText as an event parameter.
Any extension on the tenant can subscribe to this event, read the SecretText value, and persist it elsewhere. Even SecretText should not flow through an IntegrationEvent surface. Remove the GatewayToken parameter; the publisher must acquire and use the token internally before or after the event, never through it.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| exit('Server=loyalty;Database=points;Trusted_Connection=yes;'); | ||
| end; | ||
|
|
||
| procedure GetApiToken(): Text |
There was a problem hiding this comment.
GetApiToken() in LoyaltyPublicApi at line 25 calls ApiToken.Unwrap() and returns the result as Text without [NonDebuggable].
The unwrapped token is a debugger-visible plain Text local. Add [NonDebuggable] to the procedure.
Suggested fix (apply manually — could not be anchored as a one-click suggestion):
[NonDebuggable]
procedure GetApiToken(): TextKnowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| document.body.appendChild(container); | ||
| } | ||
|
|
||
| container.innerHTML = '<b>' + memberName + '</b> <' + email + '> - ' + tier; |
There was a problem hiding this comment.
LoyaltyBadge.js at line 11 assigns container.innerHTML using direct string concatenation of memberName, email, and tier: '' + memberName + ' <' + email + '> - ' + tier.
These values originate from AL record fields passed by the AL host. If any value contains unescaped HTML (e.g. a member name with <script> or an onerror attribute), the browser will execute it. Replace innerHTML assignment with textContent for each text node, or sanitize all three inputs by replacing &, <, >, and " before concatenation. This concern should be promoted to a knowledge-backed rule to gate future control add-in reviews.
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| codeunit 50100 "Loyalty Management" | ||
| { | ||
| var | ||
| Text000: Label 'Failed to process member %1'; |
There was a problem hiding this comment.
Text000 at line 8 is a Label with no approved suffix.
Per CodeCop AA0074, every Label and TextConst must end with an approved suffix indicating its consuming call. Text000 is passed to Error(), so the suffix should be Err. Rename to a descriptive identifier such as MemberProcessingFailedErr.
| Text000: Label 'Failed to process member %1'; | |
| MemberProcessingFailedErr: Label 'Failed to process member %1'; |
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| var | ||
| ErrorMessage: Text; | ||
| begin | ||
| ErrorMessage := StrSubstNo(Text000, Member."Member Name"); |
There was a problem hiding this comment.
ThrowMemberError at line 93 passes a pre-rendered StrSubstNo result to Error() via an intermediate Text variable.
This pattern breaks translation toolchain analysis and (as noted in the privacy review) causes PII to reach telemetry unclassified. Pass the Label and the substitution argument directly: Error(MemberProcessingFailedErr, Member."Member Name").
| ErrorMessage := StrSubstNo(Text000, Member."Member Name"); | |
| Error(MemberProcessingFailedErr, Member."Member Name"); |
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| ErrorText += StrSubstNo('Member %1 has a negative balance.\n', LoyaltyMember."No."); | ||
| until LoyaltyMember.Next() = 0; | ||
| if ErrorText <> '' then | ||
| Error(ErrorText); |
There was a problem hiding this comment.
ValidateBalances at line 48 calls Error(ErrorText) where ErrorText was built by StrSubstNo in a loop.
The Error call receives a pre-rendered opaque Text, losing translation linkage and telemetry classification. Refactor to use the ErrorBehavior(Collect) + Error with a Label pattern for each row, or collect the member numbers and pass them as substitution parameters to a single labeled Error call at the end.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| EntitySetName = 'Loyalty_Members'; | ||
| SourceTable = "Loyalty Member"; | ||
| DelayedInsert = false; | ||
| Caption = 'Loyalty Member API'; |
There was a problem hiding this comment.
LoyaltyMemberAPI.Page.al has EntityName = 'Loyalty_Member' (line 13) and EntitySetName = 'Loyalty_Members' (line 14).
Underscores are not permitted in API page entity names — they must be camelCase alphanumeric only. Use EntityName = 'loyaltyMember' and EntitySetName = 'loyaltyMembers'. Field Name 'Member_Name' on line ~29 of the same page is also non-camelCase.
| Caption = 'Loyalty Member API'; | |
| EntityName = 'loyaltyMember'; |
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| Caption = 'Loyalty Member API'; | ||
| ODataKeyFields = SystemId; | ||
|
|
||
| layout |
There was a problem hiding this comment.
LoyaltyMemberAPI.Page.al at line 16 explicitly sets DelayedInsert = false.
API pages must set DelayedInsert = true so the insert trigger fires once with the complete JSON payload rather than on a partially populated record. LoyaltyMemberData.Page.al (PageType = API) also omits the property, which defaults to false and has the same effect.
| layout | |
| DelayedInsert = true; |
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| Member.Modify(); | ||
| until Member.Next() = 0; | ||
|
|
||
| Client.Get('https://api.contoso-pay.example/migrate', Response); |
There was a problem hiding this comment.
OnUpgradePerCompany at line 20 calls Client.Get('https://api.contoso-pay.example/migrate', Response) — a live HTTP call to an external service inside an upgrade codeunit.
Upgrade code runs with no users signed in, in a constrained window where any network failure (DNS, expired credentials, external downtime) aborts the entire upgrade transaction and leaves the tenant unable to upgrade. Defer this call to a runtime code path (job queue entry, first-sign-in trigger, or background task) that can retry and degrade gracefully.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| { | ||
| Subtype = Upgrade; | ||
|
|
||
| trigger OnUpgradePerCompany() |
There was a problem hiding this comment.
OnUpgradePerCompany at line 7 contains no HasUpgradeTag / SetUpgradeTag guard.
Without an upgrade tag, the balance-migration loop runs on every upgrade cycle, not just once per tenant. On the second upgrade the loop doubles already-doubled balances. Add a unique upgrade tag, check HasUpgradeTag at entry, perform the work, then call SetUpgradeTag. Also register the tag in an OnGetPerCompanyUpgradeTags subscriber per the referenced guidance.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| Client: HttpClient; | ||
| Response: HttpResponseMessage; | ||
| begin | ||
| Member.FindSet(true); |
There was a problem hiding this comment.
Member.FindSet(true) at line 13 in OnUpgradePerCompany is called without an if guard.
If no Loyalty Member rows exist on a tenant, the repeat...until loop still executes once with an empty record variable, corrupting Points Balance on a blank record and then raising a runtime error when Modify() is called — aborting the entire upgrade for that company. Change to: if Member.FindSet(true) then repeat ... until Member.Next() = 0;
| Member.FindSet(true); | |
| if Member.FindSet(true) then |
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| Client.Get('https://api.contoso-pay.example/migrate', Response); | ||
|
|
||
| if Member.IsEmpty() then | ||
| Error('No loyalty members were found during upgrade.'); |
There was a problem hiding this comment.
OnUpgradePerCompany at line 23 calls Error('No loyalty members were found during upgrade.').
Any tenant that has no loyalty members — a valid state for a new installation — will fail to upgrade. The check also runs after the loop, using the final state of Member which is undefined at that point (the cursor is exhausted). Replace with Session.LogMessage to record the telemetry observation and exit gracefully; do not Error() from upgrade code.
Knowledge:
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
Copilot PR ReviewIteration 1 · Outcome: completed Knowledge source: https://github.com/microsoft/BCQuality@822cae1b2771ac25f665f73369f69093bd4fd630 Findings by domainFindings split into Knowledge-backed (cite a BCQuality article) and Agent (the agent's own judgement, no matching BCQuality rule).
Totals: 29 knowledge-backed · 3 agent findings. Orchestrator pre-filter (13 file(s) excluded)
Findings produced by the Copilot CLI agent against BCQuality at |
This PR exists solely to exercise the BCQuality Copilot PR-review agent. The Loyalty Sample app it adds is deliberately flawed and must not be shipped.
What it does
Replicates the byte-identical Loyalty Sample app from microsoft/BCAppsBCQuality#52 (26 files, 1005 insertions) — a self-contained, deliberately-flawed loyalty module under
src/Apps/W1/LoyaltySample/. The planted mistakes span all 11 BCQuality review domains.Wiring bump
tools/BCQuality/bcquality.config.yamlto8904ce583b1db8a5fb714d7ebae8368bd0a7ac14(latest main).al-breaking-changes-review,al-error-handling-review,al-events-review,al-interfaces-review,al-web-services-review) to$DomainMapintools/Code Review/scripts/Invoke-CopilotPRReview.ps1so all 11 domains are exercised and labeled correctly in the review summary (instead of falling back toOther).Context
This is the 3rd BCQuality wiring test (after #8772 and #8816), this one to validate all 11 domains end-to-end.
The 11 domains exercised
Security, Privacy, Performance, Style, Accessibility, Upgrade, Breaking Changes, Error Handling, Events, Interfaces, Web Services.
References microsoft/BCAppsBCQuality#52.
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com