Scenario Files
Scenario Files
Section titled “Scenario Files”A scenario is a JSON document that describes one operation: which plugin runs, the execution
context it runs in, the data it is allowed to read, and — optionally — the exception it is expected
to throw. You hand it to xg run with --scenario, and the FrameworkHost
builds the whole IPluginExecutionContext from it.
This page is the canonical reference for that document. A starting template for a real assembly can
be produced with xg generate-scenario; everything below is
what you edit afterwards.
Minimum shape
Section titled “Minimum shape”Four keys are enough to run a plugin:
{ "scenarioName": "Short description of the operation", "pluginAssemblyName": "MyCompany.Plugins.dll", "pluginTypeName": "MyCompany.Plugins.Account.ValidateAccount", "executionContext": { "messageName": "Create", "primaryEntityName": "account", "primaryEntityId": "00000000-0000-0000-0000-000000000001", "stage": 20, "mode": 0, "inputParameters": { "Target": { "logicalName": "account", "id": "00000000-0000-0000-0000-000000000001", "attributes": { } } } }}| Key | What it is |
|---|---|
scenarioName | A human label. It appears in the run output and is never interpreted. |
pluginAssemblyName | The assembly the scenario was written against — documentation for the reader; the assembly actually loaded is the one xg run -a points at. |
pluginTypeName | The fully-qualified plugin type the scenario was written against; the type actually executed is the one xg run -t names. |
executionContext | Everything the plugin sees through IPluginExecutionContext. |
Inside executionContext, three fields carry most of the meaning:
messageName— the Dataverse message:Create,Update,Delete,Retrieve,RetrieveMultiple, or a Custom API message (see Custom API scenarios). It is the name your plugin reads to decide whether it should act at all.stage— the pipeline stage:10PreValidation,20PreOperation,40PostOperation. The main operation of a Custom API is stage30.inputParameters.Target— the record the message carries. ForCreateandUpdateit is the entity being written, and itsattributesobject is what your plugin reads. ForDeleteit is normally anEntityReferenceinstead.
mode is 0 for synchronous and 1 for asynchronous. depth, userId, initiatingUserId,
organizationId, businessUnitId, correlationId, sharedVariables and outputParameters are
accepted too, and default to sensible values when omitted.
Attribute value typing
Section titled “Attribute value typing”Inside any attributes object, value typing must be explicit or the framework misreads it. This is
the single most common source of scenario errors:
- Plain values (string, number, bool) are written directly —
"name": "Contoso Ltd","revenue": 1000000,"creditonhold": false. - OptionSet / Picklist is
{ "value": X }— never the bare number. The tag"type": "OptionSetValue"is optional:{ "type": "OptionSetValue", "value": X }reads the same. - Money is
{ "type": "Money", "value": X }, whereXmay be an integer or a decimal. The"type": "Money"tag is what makes it Money: without it,{ "value": X }stays an OptionSet. With the currentxgrelease the tag is honoured only in the root-levelentitiesseed, which the CLI hands to the host as written; see the caution below. - EntityReference / lookup is
{ "logicalName": "...", "id": "...", "name": "..." }, wherenameis optional. - GUIDs must be valid hexadecimal. An id containing a non-hex character — an
x, ag, a typo — fails to parse inside anEntityReferenceand takes the whole scenario down with it.
{ "attributes": { "name": "Contoso Ltd", "revenue": 1000000, "statuscode": { "value": 2 }, "primarycontactid": { "logicalName": "contact", "id": "00000000-0000-0000-0000-0000000000c1", "name": "Alex Taylor" } }}:::caution Where the Money tag is recognised
The CLI reads Target, the rest of inputParameters, preEntityImages, postEntityImages and
organizationServiceMock and writes them out again before handing them to the host, and the current
xg release does not carry the "type": "Money" tag through that step. Use the tag only on records
in the root-level entities seed; elsewhere, do not rely on it to produce a Money value.
:::
Supplying the data the code reads
Section titled “Supplying the data the code reads”When the plugin reads other records while it runs, those records are described in the scenario — and there are two distinct mechanisms, one per message. Using the wrong one is the second most common mistake, because both “work” in the sense that the JSON parses and neither returns what you expected.
| The plugin calls | You describe it in | Where the attributes go |
|---|---|---|
Retrieve (one record, by id) | organizationServiceMock.retrieveResponses — or a record of the root-level entities seed with that id | Nested under an attributes object in retrieveResponses; directly on the entity object in entities |
RetrieveMultiple (a query) | a root-level entities key, sibling of executionContext | Directly on the entity object |
Retrieve — organizationServiceMock.retrieveResponses
Section titled “Retrieve — organizationServiceMock.retrieveResponses”Each entry pairs the call to match (request) with the record to hand back (response). An entry
matches on entityName and id; request.columnSet does not take part in the match, and can be
omitted:
{ "organizationServiceMock": { "retrieveResponses": [ { "request": { "entityName": "contact", "id": "00000000-0000-0000-0000-0000000000c1", "columnSet": ["fullname", "emailaddress1"] }, "response": { "logicalName": "contact", "id": "00000000-0000-0000-0000-0000000000c1", "attributes": { "fullname": "Alex Taylor", "emailaddress1": "alex.taylor@example.com" } } } ] }}How a Retrieve is answered
Section titled “How a Retrieve is answered”When the scenario answers a Retrieve, the host looks for the record in this order and stops at the
first source that has it:
- What the run itself did — records the plugin created or updated during this run.
organizationServiceMock.retrieveResponses— the explicit responses above.- The root-level
entitiesseed — see the next section. - The rest of the in-memory store.
So an explicit retrieveResponses entry wins over a seed record with the same entityName and
id: declare a record in one place, or expect the response to be the one you see.
Whichever source answers, the record is projected on the ColumnSet the plugin passes — not on
the columnSet written in the scenario:
- columns the plugin did not ask for are not returned;
- the primary key (
<logicalName>id) is always present; new ColumnSet(true)(all columns) returns every attribute the record has;- an empty
ColumnSetreturns only the primary key.
A record that no source holds — or an id that is Guid.Empty — makes Retrieve throw the fault
Dataverse throws: a FaultException<OrganizationServiceFault> with error code 0x80040217
(the record does not exist). A plugin that catches that fault sees what it would see in Dataverse.
Writes are visible to later reads within the same run: a Retrieve after the plugin’s Update
returns the updated values, and a Retrieve after a Delete of that record throws the same
not-found fault, even if retrieveResponses or the seed still describe it.
RetrieveMultiple — the root-level entities key
Section titled “RetrieveMultiple — the root-level entities key”entities is not inside organizationServiceMock: it sits at the root of the document, next to
executionContext. It is grouped by logical name, and each record carries its attributes directly
on the object — the opposite of retrieveResponses:
{ "entities": { "contact": [ { "id": "00000000-0000-0000-0000-0000000000c1", "fullname": "Alex Taylor", "emailaddress1": "alex.taylor@example.com", "statuscode": { "value": 1 }, "creditlimit": { "type": "Money", "value": 5000.50 }, "parentcustomerid": { "logicalName": "account", "id": "00000000-0000-0000-0000-000000000001" } } ] }}The CLI does not hand this list back verbatim: the host applies the query itself and returns only the records that match. So every field the query’s filter or order references must be present on the record, or it is filtered out and your plugin sees an empty result it will not explain to you.
The same records also answer a Retrieve by id, after any matching retrieveResponses entry (see
How a Retrieve is answered).
A malformed seed fails the run. If the entities value cannot be parsed, or one of its records
or attribute values cannot be converted, the plugin is not run against a partial store: the run is
reported as failed and xg run exits with code 1, whatever expectedException declares. Fix the
seed; a run that silently dropped it
would not be testing what you wrote.
Pre-images
Section titled “Pre-images”A pre-image is supplied through a root-level preEntityImages key, whose name must match the image
registered on the step — the same name the code reads:
{ "preEntityImages": { "PreImage": { "logicalName": "account", "id": "00000000-0000-0000-0000-000000000001", "attributes": { "name": "Contoso Ltd", "revenue": 900000 } } }}var preImage = (Entity)context.PreEntityImages["PreImage"];postEntityImages works identically. Both keys are also accepted under executionContext; when the
same key appears in both places the root-level one wins and the CLI prints a warning saying so,
so pick one and keep it there.
Custom API scenarios
Section titled “Custom API scenarios”A Custom API runs through the same pipeline, but its scenario differs from an entity plugin’s in three ways:
- Message name:
custom_<publisherprefix>_<ApiName>— both thecustom_segment and the publisher prefix are required, because the message name is the API’s unique name. - No
Target: each declared request parameter is a direct key underinputParameters. - Primary entity:
primaryEntityNameis the empty string""or omitted — a Custom API is global, not bound to a record. Do not substitute a placeholder such as"none".
{ "scenarioName": "Custom API - validate an invitation code", "pluginAssemblyName": "MyCompany.Plugins.dll", "pluginTypeName": "MyCompany.Plugins.Api.ValidateInvitation", "executionContext": { "messageName": "custom_mycompany_ValidateInvitation", "primaryEntityName": "", "stage": 30, "mode": 0, "inputParameters": { "InvitationCode": "ABC-123", "RequestedBy": "alex.taylor@example.com" } }}Backing data works exactly as it does for a plugin: retrieveResponses and entities are read the
same way.
Expecting the outcome
Section titled “Expecting the outcome”Happy path
Section titled “Happy path”A scenario with no expectedException expects the plugin not to throw. Putting the pieces above
together:
{ "scenarioName": "Account create — the credit limit is applied", "pluginAssemblyName": "MyCompany.Plugins.dll", "pluginTypeName": "MyCompany.Plugins.Account.ApplyCreditLimit", "executionContext": { "messageName": "Create", "primaryEntityName": "account", "primaryEntityId": "00000000-0000-0000-0000-000000000001", "stage": 20, "mode": 0, "inputParameters": { "Target": { "logicalName": "account", "id": "00000000-0000-0000-0000-000000000001", "attributes": { "name": "Contoso Ltd", "revenue": 1000000, "primarycontactid": { "logicalName": "contact", "id": "00000000-0000-0000-0000-0000000000c1" } } } } }, "organizationServiceMock": { "retrieveResponses": [ { "request": { "entityName": "contact", "id": "00000000-0000-0000-0000-0000000000c1", "columnSet": ["fullname", "emailaddress1"] }, "response": { "logicalName": "contact", "id": "00000000-0000-0000-0000-0000000000c1", "attributes": { "fullname": "Alex Taylor", "emailaddress1": "alex.taylor@example.com" } } } ] }}The run passes when the plugin completes without throwing, and xg run exits with code 0.
Expecting an exception
Section titled “Expecting an exception”Rejection is behaviour too. Derive the negative case from the scenario above — same plugin, an input the plugin is supposed to refuse — and declare what it must throw:
{ "scenarioName": "Account create — a negative revenue is rejected", "pluginAssemblyName": "MyCompany.Plugins.dll", "pluginTypeName": "MyCompany.Plugins.Account.ApplyCreditLimit", "executionContext": { "messageName": "Create", "primaryEntityName": "account", "primaryEntityId": "00000000-0000-0000-0000-000000000001", "stage": 20, "mode": 0, "inputParameters": { "Target": { "logicalName": "account", "id": "00000000-0000-0000-0000-000000000001", "attributes": { "name": "Contoso Ltd", "revenue": -1 } } } }, "expectedException": { "typeName": "Microsoft.Xrm.Sdk.InvalidPluginExecutionException", "messageContains": "Revenue cannot be negative" }}Four rules govern how that declaration is evaluated:
typeNameis mandatory and fully qualified. It is the exception’s namespace and type name —Microsoft.Xrm.Sdk.InvalidPluginExecutionException, notInvalidPluginExecutionException— and the comparison is exact: no base classes, no case-insensitivity, no partial names. AnexpectedExceptionwhosetypeNameis missing or empty matches nothing, and the run fails; an unwritten assertion is never a green run.- The whole exception chain is examined, from the outermost exception inward through
InnerException. When a plugin catches a failure and rethrows it wrapped, the outer type is a legitimate thing to declare — you do not have to dig down to the root cause. messageContainsis optional, and is checked on the same link as the type. It is an ordinal substring test on that exception’s message. A link whose type matches but whose message does not is not the answer: the walk continues inward, because the same type can appear twice in one chain and only the inner one may carry the text you named. OmittingmessageContainssimply states no constraint on the message.- A satisfied expectation is a passing run.
xg runexits0and the run summary reportsExecution Succeeded!followed by anExpected Exception Matched:line. That line restates the expectation you declared — thetypeName, plus(message contains: …)whenmessageContainsis set — and is the confirmation that the match happened. The summary does not print the thrown exception, its message or its stack trace.
The expectation covers the plugin’s own execution and nothing wider. A failure to load the assembly or to construct the plugin type is the harness being broken, and stays red whatever the scenario declares.
Expecting traces
Section titled “Expecting traces”expectedTraceLog is a list of strings the plugin is expected to have written through
ITracingService.Trace. It is an assertion, evaluated once the plugin’s Execute has finished:
"expectedTraceLog": [ "Credit limit applied", "revenue=1000000"]These rules govern how the list is evaluated:
- Each entry is an ordinal, case-sensitive substring. An entry is satisfied when the message of
at least one trace emitted by the plugin contains it.
"credit limit"does not match a trace that readsCredit limit applied. - Only traces with source
Plugincount. Lines written by the framework and the host are not searched, and neither is the renderedTraceLogyou read with-vor-d: its prefixes and timestamps are presentation, not the corpus. A host line that merely quotes your expected text never satisfies the entry on the plugin’s behalf. - Order is not checked, and entries are independent. The entries can appear in any order, and a single trace can satisfy several entries at once.
- An absent list, or
[], asserts nothing. The run is judged onexpectedExceptionalone. - An empty or whitespace-only entry makes the run red. Every message contains the empty string, so such an entry is not a wildcard: it is reported as invalid, with its position in the list.
expectedExceptionandexpectedTraceLogcombine with AND. Both must hold for the run to pass, and the traces are evaluated also when the plugin throws the expected exception and that exception is captured. When the expected exception never arrives, the run is already red and keeps theexpectedExceptiondiagnostic.
A missing trace fails the run like any other failed expectation. The summary reports
Execution Failed!, then Error Type: XrmGhost.ExpectedTraceLogAssertion — not a CLR type: nothing
was thrown, the name tells a missing trace apart from an exception the plugin raised — and an
Error Message: listing the entries no plugin trace contained (and the positions of any blank
entries). The traces and the rest of the output are kept, so you can compare what the plugin
actually traced with what you declared, and xg run exits with code 1.
| What happens | Result |
|---|---|
No expectedException, plugin does not throw | Pass — exit code 0 |
No expectedException, plugin throws | Fail — exit code 1 |
expectedException satisfied by some link of the chain | Pass — exit code 0, summary line Expected Exception Matched: |
expectedException declared, plugin throws a different type or a message that does not contain messageContains | Fail — exit code 1 |
expectedException declared, plugin throws nothing | Fail — exit code 1 |
expectedException declared with a missing or empty typeName | Fail — exit code 1 |
expectedTraceLog absent or [] | No trace assertion — the result follows the rows above |
Every expectedTraceLog entry found in a Plugin trace, and the exception expectation (if any) holds | Pass — exit code 0 |
An expectedTraceLog entry found in no Plugin trace | Fail — exit code 1, Error Type: XrmGhost.ExpectedTraceLogAssertion |
An expectedTraceLog entry that is empty or whitespace-only | Fail — exit code 1, Error Type: XrmGhost.ExpectedTraceLogAssertion |
expectedException satisfied, an expectedTraceLog entry missing | Fail — exit code 1, Error Type: XrmGhost.ExpectedTraceLogAssertion |
Migrating the legacy placeholder
Section titled “Migrating the legacy placeholder”Scenarios generated by earlier releases of xg generate-scenario carry a placeholder entry of the
form Trace log for {messageName} (expected) — for example "Trace log for Create (expected)".
It used to be ignored; now it is an assertion that no plugin traces, so the run turns red with
XrmGhost.ExpectedTraceLogAssertion. For each saved scenario, pick one of three fixes:
- remove the
expectedTraceLogkey; - empty it to
"expectedTraceLog": []; - replace the placeholder with a substring of a trace the plugin really emits.
See Also
Section titled “See Also”xg run— execute a scenario, with the full flag reference and exit codesxg generate-scenario— produce a starting template from an assembly and plugin type- Using the Skills — how an AI coding agent authors, runs and debugs these files with you