@mpgd/platform 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commerce-conformance.d.ts +136 -4
- package/dist/commerce-conformance.js +485 -24
- package/dist/commerce-contract.d.ts +222 -0
- package/dist/commerce-contract.js +1 -0
- package/dist/commerce-vectors.d.ts +8 -0
- package/dist/commerce-vectors.js +38 -33
- package/dist/commerce.d.ts +3 -145
- package/dist/commerce.js +228 -228
- package/dist/index.d.ts +8 -0
- package/package.json +1 -1
|
@@ -10,7 +10,11 @@ import { assertCommercePurchaseTransition, assertRequestPurchaseSupported, Comme
|
|
|
10
10
|
*
|
|
11
11
|
* Store (B-phase) providers run the same vectors by supplying
|
|
12
12
|
* `createProvider` and translating the store actor steps into their own fake
|
|
13
|
-
* SDK; the reconciler, journal, backend, and matcher stay shared.
|
|
13
|
+
* SDK; the reconciler, journal, backend, and matcher stay shared. A vector
|
|
14
|
+
* flagged `profile.adaptive` then matches expectations derived from the
|
|
15
|
+
* provider's declared capabilities rather than its fixture profile; see
|
|
16
|
+
* `deriveAdaptiveCommerceConformanceExpectation` and
|
|
17
|
+
* `commerceConformanceAdaptiveSkipReason`.
|
|
14
18
|
*/
|
|
15
19
|
export const commerceConformanceDefaultPlayerId = 'player-1';
|
|
16
20
|
export const commerceConformanceDefaultTitleId = 'title-a';
|
|
@@ -137,6 +141,10 @@ export function createCommerceConformanceStore(input) {
|
|
|
137
141
|
let purchaseUiOpened = 0;
|
|
138
142
|
let restoreCalls = 0;
|
|
139
143
|
const emit = (event) => {
|
|
144
|
+
// An event-less store reports nothing; the reconciler polls its available list instead.
|
|
145
|
+
if (!capabilities.lifecycle.events) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
140
148
|
if (listener === undefined) {
|
|
141
149
|
buffer.push(event);
|
|
142
150
|
return;
|
|
@@ -283,12 +291,16 @@ function matchesFetchProducts(entry, request) {
|
|
|
283
291
|
* Fake backend: every verify call consumes the next `queueVerify` decision in
|
|
284
292
|
* order and is logged; an unqueued verify is an unscripted operation. A
|
|
285
293
|
* `granted` decision makes sure the ledger holds the entry, so a replayed
|
|
286
|
-
* grant and a host-pushed grant never create a second entry.
|
|
294
|
+
* grant and a host-pushed grant never create a second entry. With
|
|
295
|
+
* `finalizeOnReread`, a replayed granted key is answered as finalized from
|
|
296
|
+
* the backend's own state.
|
|
287
297
|
*/
|
|
288
298
|
export function createCommerceConformanceBackend(input = {}) {
|
|
289
299
|
const decisions = [];
|
|
290
300
|
const verifyRequests = [];
|
|
291
301
|
const ledger = [];
|
|
302
|
+
/** Ledger entry id per granted idempotency key. */
|
|
303
|
+
const grants = new Map();
|
|
292
304
|
const findEntry = (entryId) => ledger.find((entry) => entry.entryId === entryId);
|
|
293
305
|
const reversalOf = (ledgerEntryId) => ledger.find((entry) => entry.type === 'revoke' && entry.reverses === ledgerEntryId);
|
|
294
306
|
return {
|
|
@@ -303,6 +315,26 @@ export function createCommerceConformanceBackend(input = {}) {
|
|
|
303
315
|
},
|
|
304
316
|
async verifyPurchase(request) {
|
|
305
317
|
verifyRequests.push(request);
|
|
318
|
+
const granted = grants.get(request.idempotencyKey);
|
|
319
|
+
if (granted !== undefined
|
|
320
|
+
&& reversalOf(granted) === undefined
|
|
321
|
+
&& input.finalizeOnReread?.() === true) {
|
|
322
|
+
// The key's own grant answers the re-read: no queued decision is
|
|
323
|
+
// consumed, so a decision scripted for another record stays in place.
|
|
324
|
+
// A reversed grant is not finalized; the re-read falls through to the script.
|
|
325
|
+
await input.finalize?.({
|
|
326
|
+
store: request.store,
|
|
327
|
+
transactionId: request.transactionId,
|
|
328
|
+
ledgerEntryId: granted,
|
|
329
|
+
logicalId: request.logicalId,
|
|
330
|
+
});
|
|
331
|
+
return {
|
|
332
|
+
status: 'granted',
|
|
333
|
+
ledgerEntryId: granted,
|
|
334
|
+
finalization: 'completed',
|
|
335
|
+
alreadyProcessed: true,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
306
338
|
const decision = decisions.shift();
|
|
307
339
|
if (decision === undefined) {
|
|
308
340
|
throw new CommerceConformanceScriptError(`Unscripted verify for ${request.idempotencyKey} (${request.store}/${request.transactionId}); `
|
|
@@ -325,6 +357,7 @@ export function createCommerceConformanceBackend(input = {}) {
|
|
|
325
357
|
idempotencyKey: request.idempotencyKey,
|
|
326
358
|
});
|
|
327
359
|
}
|
|
360
|
+
grants.set(request.idempotencyKey, ledgerEntryId);
|
|
328
361
|
if (decision.finalization === 'completed') {
|
|
329
362
|
await input.finalize?.({
|
|
330
363
|
store: request.store,
|
|
@@ -726,6 +759,14 @@ export function createCommerceConformanceRuntime(input) {
|
|
|
726
759
|
};
|
|
727
760
|
const pass = async () => {
|
|
728
761
|
const capabilities = await provider.getCapabilities();
|
|
762
|
+
// A provider without lifecycle.events is polled: every pass reads its available purchases.
|
|
763
|
+
if (!capabilities.lifecycle.events && capabilities.lifecycle.availablePurchases) {
|
|
764
|
+
for (const purchase of await provider.getAvailablePurchases({
|
|
765
|
+
includePending: capabilities.lifecycle.pending,
|
|
766
|
+
})) {
|
|
767
|
+
inbox.push({ type: 'purchaseUpdated', purchase });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
729
770
|
for (const event of inbox.splice(0, inbox.length)) {
|
|
730
771
|
if (event.type === 'purchaseUpdated') {
|
|
731
772
|
await ingestPurchase(event.purchase);
|
|
@@ -787,7 +828,8 @@ export function createCommerceConformanceRuntime(input) {
|
|
|
787
828
|
// Subscribe first, then read: an event delivered before the listener is found either way.
|
|
788
829
|
unsubscribe = provider.subscribe(listener);
|
|
789
830
|
const capabilities = await provider.getCapabilities();
|
|
790
|
-
|
|
831
|
+
// An event-less provider is polled by the first pass instead, so it is read once.
|
|
832
|
+
if (capabilities.lifecycle.availablePurchases && capabilities.lifecycle.events) {
|
|
791
833
|
for (const purchase of await provider.getAvailablePurchases({
|
|
792
834
|
includePending: capabilities.lifecycle.pending,
|
|
793
835
|
})) {
|
|
@@ -1430,31 +1472,153 @@ export function createCommerceConformanceHarness(context) {
|
|
|
1430
1472
|
* each, then matches `expect`. Matching compares only the listed fields; arrays
|
|
1431
1473
|
* compare in order and by length; a listed `journal` is the whole journal.
|
|
1432
1474
|
* Any unscripted operation fails the vector at the step that needed it.
|
|
1475
|
+
*
|
|
1476
|
+
* With `options.createProvider`, a vector flagged `profile.adaptive` is first
|
|
1477
|
+
* checked against the provider's declared capabilities
|
|
1478
|
+
* (`commerceConformanceAdaptiveSkipReason`) and then matched against
|
|
1479
|
+
* expectations derived from a reference run under those capabilities
|
|
1480
|
+
* (`deriveAdaptiveCommerceConformanceExpectation`). Duplicate detection needs
|
|
1481
|
+
* the whole run set, so it applies through `runCommerceConformanceVectors`.
|
|
1433
1482
|
*/
|
|
1434
1483
|
export async function runCommerceConformanceVector(vector, options = {}) {
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1484
|
+
return runVectorIn(vector, options, purchaseFinishFamilyOf([vector]));
|
|
1485
|
+
}
|
|
1486
|
+
export async function runCommerceConformanceVectors(vectors, options = {}) {
|
|
1487
|
+
// The `purchase-<owner>-finish` family of this run is fixed up front; the
|
|
1488
|
+
// canonical member per owner is then one lookup per vector.
|
|
1489
|
+
const family = purchaseFinishFamilyOf(vectors);
|
|
1490
|
+
const results = [];
|
|
1491
|
+
for (const vector of vectors) {
|
|
1492
|
+
results.push(await runVectorIn(vector, options, family));
|
|
1493
|
+
}
|
|
1494
|
+
return results;
|
|
1495
|
+
}
|
|
1496
|
+
async function runVectorIn(vector, options, family) {
|
|
1497
|
+
const ids = {
|
|
1498
|
+
playerId: options.playerId ?? commerceConformanceDefaultPlayerId,
|
|
1499
|
+
titleId: options.titleId ?? commerceConformanceDefaultTitleId,
|
|
1500
|
+
};
|
|
1501
|
+
const failed = (failures) => ({ vector: vector.vector, status: 'failed', passed: false, failures });
|
|
1502
|
+
const skipped = (reason) => ({ vector: vector.vector, status: 'skipped', passed: false, failures: [], reason });
|
|
1503
|
+
// A harness that cannot be built is a failed run, never a rejected promise.
|
|
1504
|
+
let execution;
|
|
1505
|
+
try {
|
|
1506
|
+
execution = await prepareExecution(vector, options.createProvider ?? createCommerceConformanceHarness, ids);
|
|
1507
|
+
}
|
|
1508
|
+
catch (error) {
|
|
1509
|
+
return failed([
|
|
1510
|
+
{ path: 'harness', expected: 'createProvider resolves', actual: describeError(error) },
|
|
1511
|
+
]);
|
|
1512
|
+
}
|
|
1513
|
+
let expectation = vector.expect;
|
|
1514
|
+
let adaptive;
|
|
1515
|
+
let adaptation;
|
|
1516
|
+
if (options.createProvider !== undefined && vector.profile.adaptive === true) {
|
|
1517
|
+
let capabilities;
|
|
1518
|
+
try {
|
|
1519
|
+
capabilities = await execution.harness.provider.getCapabilities();
|
|
1520
|
+
}
|
|
1521
|
+
catch (error) {
|
|
1522
|
+
return failed([
|
|
1523
|
+
{
|
|
1524
|
+
path: 'capabilities',
|
|
1525
|
+
expected: 'provider.getCapabilities() resolves',
|
|
1526
|
+
actual: describeError(error),
|
|
1527
|
+
},
|
|
1528
|
+
]);
|
|
1529
|
+
}
|
|
1530
|
+
const reason = adaptiveSkipReasonWith(vector, capabilities, family);
|
|
1531
|
+
if (reason !== undefined) {
|
|
1532
|
+
return skipped(reason);
|
|
1533
|
+
}
|
|
1534
|
+
const store = execution.harness.provider.store;
|
|
1535
|
+
adaptation = adaptationFor(vector, capabilities);
|
|
1536
|
+
// The reference run reports the tokens the provider under test reports.
|
|
1537
|
+
const tokenOf = execution.harness.store?.tokenOf;
|
|
1538
|
+
const tokenAdaptedVector = tokenOf === undefined
|
|
1539
|
+
? vector
|
|
1540
|
+
: withStoreTokens(vector, (purchase) => tokenOf.call(execution.harness.store, purchase));
|
|
1541
|
+
let reference;
|
|
1542
|
+
try {
|
|
1543
|
+
reference = await prepareExecution(referenceVectorFor(tokenAdaptedVector, store, capabilities), createCommerceConformanceHarness, ids);
|
|
1544
|
+
}
|
|
1545
|
+
catch (error) {
|
|
1546
|
+
return failed([
|
|
1547
|
+
{
|
|
1548
|
+
path: 'reference.harness',
|
|
1549
|
+
expected: 'reference harness builds',
|
|
1550
|
+
actual: describeError(error),
|
|
1551
|
+
},
|
|
1552
|
+
]);
|
|
1553
|
+
}
|
|
1554
|
+
const referenceFailures = await executeSteps(reference, adaptation);
|
|
1555
|
+
const referenceFailure = referenceFailures[0];
|
|
1556
|
+
if (referenceFailure !== undefined) {
|
|
1557
|
+
// The reference pieces cannot play the scenario under these capabilities,
|
|
1558
|
+
// which is a property of the vector and the capability set, not of the
|
|
1559
|
+
// provider under test; the step and its error are kept in the reason.
|
|
1560
|
+
return skipped(`the reference run under the provider capabilities failed at ${referenceFailure.path}: `
|
|
1561
|
+
+ `expected ${String(referenceFailure.expected)}, got ${String(referenceFailure.actual)}`);
|
|
1562
|
+
}
|
|
1563
|
+
const derived = await deriveAdaptiveExpectation(tokenAdaptedVector, reference, store);
|
|
1564
|
+
if (derived.reason !== undefined) {
|
|
1565
|
+
return skipped(derived.reason);
|
|
1566
|
+
}
|
|
1567
|
+
expectation = derived.expect;
|
|
1568
|
+
adaptive = {
|
|
1569
|
+
store,
|
|
1570
|
+
finish: capabilities.lifecycle.finish,
|
|
1571
|
+
evidence: capabilities.authority.evidence,
|
|
1572
|
+
expect: expectation,
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
const failures = await executeSteps(execution, adaptation);
|
|
1576
|
+
if (failures.length > 0) {
|
|
1577
|
+
return { ...failed(failures), ...(adaptive === undefined ? {} : { adaptive }) };
|
|
1578
|
+
}
|
|
1579
|
+
try {
|
|
1580
|
+
await matchExpectations(expectation, { ...execution, failures });
|
|
1581
|
+
}
|
|
1582
|
+
catch (error) {
|
|
1583
|
+
// A provider read that throws while expectations are evaluated is a failed
|
|
1584
|
+
// run, never a rejected promise.
|
|
1585
|
+
failures.push({
|
|
1586
|
+
path: 'expect',
|
|
1587
|
+
expected: 'expectations evaluate',
|
|
1588
|
+
actual: describeError(error),
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
return {
|
|
1592
|
+
vector: vector.vector,
|
|
1593
|
+
status: failures.length === 0 ? 'passed' : 'failed',
|
|
1594
|
+
passed: failures.length === 0,
|
|
1595
|
+
failures,
|
|
1596
|
+
...(adaptive === undefined ? {} : { adaptive }),
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
async function prepareExecution(vector, createHarness, ids) {
|
|
1438
1600
|
const clock = createCommerceConformanceClock();
|
|
1439
1601
|
const journal = createCommerceConformanceJournal();
|
|
1440
1602
|
const harnessRef = {};
|
|
1603
|
+
const adaptation = { current: undefined };
|
|
1441
1604
|
const backend = createCommerceConformanceBackend({
|
|
1442
1605
|
finalize: async (request) => {
|
|
1443
1606
|
await harnessRef.current?.store?.finalize?.(request);
|
|
1444
1607
|
await harnessRef.current?.wallet?.finalize?.(request);
|
|
1445
1608
|
},
|
|
1609
|
+
finalizeOnReread: () => adaptation.current?.serverFinalization === true,
|
|
1446
1610
|
});
|
|
1447
1611
|
const context = {
|
|
1448
1612
|
vector,
|
|
1449
1613
|
profile: vector.profile,
|
|
1450
1614
|
catalog: vector.catalog ?? [],
|
|
1451
|
-
playerId,
|
|
1452
|
-
titleId,
|
|
1615
|
+
playerId: ids.playerId,
|
|
1616
|
+
titleId: ids.titleId,
|
|
1453
1617
|
clock,
|
|
1454
1618
|
backend,
|
|
1455
1619
|
journal,
|
|
1456
1620
|
};
|
|
1457
|
-
const harness = await (
|
|
1621
|
+
const harness = await createHarness(context);
|
|
1458
1622
|
harnessRef.current = harness;
|
|
1459
1623
|
const runtime = createCommerceConformanceRuntime({
|
|
1460
1624
|
provider: harness.provider,
|
|
@@ -1462,32 +1626,330 @@ export async function runCommerceConformanceVector(vector, options = {}) {
|
|
|
1462
1626
|
journal,
|
|
1463
1627
|
clock,
|
|
1464
1628
|
catalog: context.catalog,
|
|
1465
|
-
playerId,
|
|
1629
|
+
playerId: ids.playerId,
|
|
1466
1630
|
});
|
|
1467
|
-
|
|
1631
|
+
return { vector, harness, runtime, backend, journal, clock, adaptation };
|
|
1632
|
+
}
|
|
1633
|
+
/** Runs every step; a thrown step ends the run with a failure at that step. */
|
|
1634
|
+
async function executeSteps(execution, adaptation) {
|
|
1635
|
+
const failures = [];
|
|
1636
|
+
execution.adaptation.current = adaptation;
|
|
1637
|
+
const context = { ...execution, failures };
|
|
1638
|
+
for (const [index, step] of execution.vector.steps.entries()) {
|
|
1468
1639
|
const path = `steps[${index}]`;
|
|
1469
1640
|
try {
|
|
1470
|
-
await runStep(step, path,
|
|
1471
|
-
await runtime.runUntilIdle();
|
|
1641
|
+
await runStep(step, path, context);
|
|
1642
|
+
await execution.runtime.runUntilIdle();
|
|
1472
1643
|
}
|
|
1473
1644
|
catch (error) {
|
|
1474
1645
|
failures.push({
|
|
1475
1646
|
path,
|
|
1476
1647
|
expected: `${step.actor}.${step.action} completes`,
|
|
1477
|
-
actual:
|
|
1648
|
+
actual: describeError(error),
|
|
1478
1649
|
});
|
|
1479
|
-
|
|
1650
|
+
break;
|
|
1480
1651
|
}
|
|
1481
1652
|
}
|
|
1482
|
-
|
|
1483
|
-
return { vector: vector.vector, passed: failures.length === 0, failures };
|
|
1653
|
+
return failures;
|
|
1484
1654
|
}
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1655
|
+
function describeError(error) {
|
|
1656
|
+
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
1657
|
+
}
|
|
1658
|
+
// Adaptive vectors (RFC "Conformance vectors", adaptive vectors).
|
|
1659
|
+
/**
|
|
1660
|
+
* Members of the `purchase-<owner>-finish` family describe the same purchase
|
|
1661
|
+
* under different fixture finish owners; a provider runs the one named after
|
|
1662
|
+
* its own owner, or the first by name when none is (`finish: 'none'`).
|
|
1663
|
+
*/
|
|
1664
|
+
const purchaseFinishFamily = /^purchase-(?:client|server|host|none)-finish$/u;
|
|
1665
|
+
export function isAdaptiveCommerceConformanceVector(vector) {
|
|
1666
|
+
return vector.profile.adaptive === true;
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Why an adaptive vector does not apply to a provider with `capabilities`, or
|
|
1670
|
+
* `undefined` when it runs. `set` is the whole run (for duplicate detection).
|
|
1671
|
+
* The rules:
|
|
1672
|
+
*
|
|
1673
|
+
* - a `game.requestPurchase` of a product kind the provider does not sell
|
|
1674
|
+
* (`purchase.<kind>` false, by the vector catalog), or with a quantity above
|
|
1675
|
+
* one without `purchase.quantity`, cannot open the store UI;
|
|
1676
|
+
* - a store-side `pending` purchase needs `lifecycle.pending`;
|
|
1677
|
+
* - a store `error` event needs `lifecycle.events`;
|
|
1678
|
+
* - without `lifecycle.events`, a store `complete` or `update` reaches the
|
|
1679
|
+
* reconciler only by polling, so the next `runtime` step after it must be
|
|
1680
|
+
* `reconcile` or `start`;
|
|
1681
|
+
* - a `queueFinish` script needs a `client` or `host` finish owner, since the
|
|
1682
|
+
* provider never calls `finishTransaction` under `server` or `none`;
|
|
1683
|
+
* - within the `purchase-<owner>-finish` family only one member runs: the
|
|
1684
|
+
* one named after the provider's owner, else the first by name.
|
|
1685
|
+
*/
|
|
1686
|
+
export function commerceConformanceAdaptiveSkipReason(vector, capabilities, set = [vector]) {
|
|
1687
|
+
return adaptiveSkipReasonWith(vector, capabilities, purchaseFinishFamilyOf(set));
|
|
1688
|
+
}
|
|
1689
|
+
function adaptiveSkipReasonWith(vector, capabilities, family) {
|
|
1690
|
+
const stepReason = stepSkipReason(vector, capabilities);
|
|
1691
|
+
if (stepReason !== undefined) {
|
|
1692
|
+
return stepReason;
|
|
1489
1693
|
}
|
|
1490
|
-
|
|
1694
|
+
if (purchaseFinishFamily.test(vector.vector)) {
|
|
1695
|
+
const owner = capabilities.lifecycle.finish;
|
|
1696
|
+
// The canonical member must itself be runnable here, so a provider whose
|
|
1697
|
+
// capabilities rule out the member named after its owner still runs one.
|
|
1698
|
+
const runnable = family.filter((member) => stepSkipReason(member, capabilities) === undefined);
|
|
1699
|
+
const named = runnable.find((member) => member.vector === `purchase-${owner}-finish`);
|
|
1700
|
+
const canonical = (named ?? runnable[0])?.vector;
|
|
1701
|
+
if (canonical !== undefined && canonical !== vector.vector) {
|
|
1702
|
+
return `duplicate of ${canonical} for finish='${owner}'`;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
return undefined;
|
|
1706
|
+
}
|
|
1707
|
+
/** The step-level rules of `commerceConformanceAdaptiveSkipReason`, without the family rule. */
|
|
1708
|
+
function stepSkipReason(vector, capabilities) {
|
|
1709
|
+
const { lifecycle, purchase } = capabilities;
|
|
1710
|
+
const owner = lifecycle.finish;
|
|
1711
|
+
const catalog = vector.catalog ?? [];
|
|
1712
|
+
const steps = vector.steps;
|
|
1713
|
+
for (const [index, step] of steps.entries()) {
|
|
1714
|
+
if (step.actor === 'game' && step.action === 'requestPurchase') {
|
|
1715
|
+
const kind = catalog.find((entry) => entry.logicalId === step.input.logicalId)?.kind;
|
|
1716
|
+
if (kind !== undefined && !purchase[kind]) {
|
|
1717
|
+
return `requestPurchase of ${step.input.logicalId} needs purchase.${kind}; `
|
|
1718
|
+
+ `the provider declares ${kind}: false`;
|
|
1719
|
+
}
|
|
1720
|
+
const quantity = step.input.quantity ?? 1;
|
|
1721
|
+
if (quantity > 1 && !purchase.quantity) {
|
|
1722
|
+
return `requestPurchase of ${step.input.logicalId} with quantity ${String(quantity)} needs `
|
|
1723
|
+
+ 'purchase.quantity; the provider declares quantity: false';
|
|
1724
|
+
}
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
if (step.actor !== 'store') {
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
if (!lifecycle.pending
|
|
1731
|
+
&& ((step.action === 'complete' || step.action === 'update') && step.purchase.state === 'pending'
|
|
1732
|
+
|| (step.action === 'queueRequest'
|
|
1733
|
+
&& step.result.outcome === 'purchased'
|
|
1734
|
+
&& step.result.purchase.state === 'pending'))) {
|
|
1735
|
+
return 'store-side pending purchase needs lifecycle.pending; the provider declares pending: false';
|
|
1736
|
+
}
|
|
1737
|
+
if (step.action === 'error' && !lifecycle.events) {
|
|
1738
|
+
return 'purchaseError event needs lifecycle.events; the provider declares events: false';
|
|
1739
|
+
}
|
|
1740
|
+
if ((step.action === 'complete' || step.action === 'update') && !lifecycle.events) {
|
|
1741
|
+
// Polling ingests the purchase at the next reconcile or start; a step in
|
|
1742
|
+
// between that would have observed the event-driven grant or finish
|
|
1743
|
+
// (another store script, a ledger write) makes the scenario timing-bound.
|
|
1744
|
+
const prefix = `store.${step.action} at steps[${String(index)}] needs lifecycle.events; `
|
|
1745
|
+
+ 'the provider declares events: false';
|
|
1746
|
+
if (!lifecycle.availablePurchases) {
|
|
1747
|
+
return `${prefix} and lifecycle.availablePurchases: false, so nothing can poll the purchase`;
|
|
1748
|
+
}
|
|
1749
|
+
let polled = false;
|
|
1750
|
+
for (const [offset, later] of steps.slice(index + 1).entries()) {
|
|
1751
|
+
if (later.actor === 'runtime') {
|
|
1752
|
+
if (later.action === 'stop') {
|
|
1753
|
+
break;
|
|
1754
|
+
}
|
|
1755
|
+
polled = true;
|
|
1756
|
+
break;
|
|
1757
|
+
}
|
|
1758
|
+
if (later.actor === 'clock' || (later.actor === 'backend' && later.action === 'queueVerify')) {
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1761
|
+
return `${prefix} and ${later.actor}.${later.action} at steps[${String(index + 1 + offset)}] `
|
|
1762
|
+
+ 'runs before the reconcile or start that would poll the purchase';
|
|
1763
|
+
}
|
|
1764
|
+
if (!polled) {
|
|
1765
|
+
return `${prefix} and no reconcile or start follows it to poll the purchase`;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (step.action === 'queueFinish' && owner !== 'client' && owner !== 'host') {
|
|
1769
|
+
return `queueFinish scripts a client finish; the provider declares finish: '${owner}'`;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return undefined;
|
|
1773
|
+
}
|
|
1774
|
+
/** The adaptive `purchase-<owner>-finish` members of a run, sorted by name. */
|
|
1775
|
+
function purchaseFinishFamilyOf(set) {
|
|
1776
|
+
return set
|
|
1777
|
+
.filter((candidate) => candidate.profile.adaptive === true && purchaseFinishFamily.test(candidate.vector))
|
|
1778
|
+
.sort((left, right) => (left.vector < right.vector ? -1 : left.vector > right.vector ? 1 : 0));
|
|
1779
|
+
}
|
|
1780
|
+
function adaptationFor(vector, capabilities) {
|
|
1781
|
+
const fixtureOwner = createCommerceConformanceCapabilities(vector.profile).lifecycle.finish;
|
|
1782
|
+
return {
|
|
1783
|
+
serverFinalization: capabilities.lifecycle.finish === 'server' && fixtureOwner !== 'server',
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* The vector with every scripted store purchase (`complete`, `update`, and a
|
|
1788
|
+
* `queueRequest` answering `purchased`) carrying the token the store actor
|
|
1789
|
+
* reports for it (`CommerceConformanceStoreActor.tokenOf`).
|
|
1790
|
+
*/
|
|
1791
|
+
export function withStoreTokens(vector, tokenOf) {
|
|
1792
|
+
const withToken = (purchase) => ({ ...purchase, token: tokenOf(purchase) });
|
|
1793
|
+
return {
|
|
1794
|
+
...vector,
|
|
1795
|
+
steps: vector.steps.map((step) => {
|
|
1796
|
+
if (step.actor !== 'store') {
|
|
1797
|
+
return step;
|
|
1798
|
+
}
|
|
1799
|
+
if (step.action === 'complete' || step.action === 'update') {
|
|
1800
|
+
return { ...step, purchase: withToken(step.purchase) };
|
|
1801
|
+
}
|
|
1802
|
+
if (step.action === 'queueRequest' && step.result.outcome === 'purchased') {
|
|
1803
|
+
return { ...step, result: { ...step.result, purchase: withToken(step.result.purchase) } };
|
|
1804
|
+
}
|
|
1805
|
+
return step;
|
|
1806
|
+
}),
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
/** The vector with the provider's store id and declared capabilities as its profile. */
|
|
1810
|
+
function referenceVectorFor(vector, store, capabilities) {
|
|
1811
|
+
return {
|
|
1812
|
+
...vector,
|
|
1813
|
+
profile: {
|
|
1814
|
+
store,
|
|
1815
|
+
purchase: capabilities.purchase,
|
|
1816
|
+
lifecycle: capabilities.lifecycle,
|
|
1817
|
+
authority: capabilities.authority,
|
|
1818
|
+
adaptive: true,
|
|
1819
|
+
},
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Derives the adaptive expectation for `vector` from the observable state of a
|
|
1824
|
+
* finished reference run under the provider's capabilities, applying the
|
|
1825
|
+
* rules the RFC table states:
|
|
1826
|
+
*
|
|
1827
|
+
* - `journal`: the reference journal, so the tail after `granted` is
|
|
1828
|
+
* `finishing, finished` for `client`, `host`, and `server` owners and
|
|
1829
|
+
* `finished` for `none`; derived keys carry the provider's store id;
|
|
1830
|
+
* - `finishCalls`: the reference store's calls, `by` the owner for `client`
|
|
1831
|
+
* and `host`, none for `server` and `none`;
|
|
1832
|
+
* - `verifyRequests`: the reference backend's log, with the finalization
|
|
1833
|
+
* re-read a `server` owner adds unless the grant already reported
|
|
1834
|
+
* `finalization: 'completed'`;
|
|
1835
|
+
* - `available`: what the reference store still lists once idle, so empty
|
|
1836
|
+
* after a completed finish for `client`, `host`, and `server` owners.
|
|
1837
|
+
*
|
|
1838
|
+
* Fields the fixture does not assert stay unasserted. The derivation is
|
|
1839
|
+
* refused (a skip reason) when the reference run does not end every journal
|
|
1840
|
+
* key in the fixture's terminal state, since the scenario then depends on a
|
|
1841
|
+
* capability the provider lacks.
|
|
1842
|
+
*/
|
|
1843
|
+
export async function deriveAdaptiveCommerceConformanceExpectation(vector, reference) {
|
|
1844
|
+
const { expect } = vector;
|
|
1845
|
+
if (expect.journal !== undefined) {
|
|
1846
|
+
const adapted = new Map();
|
|
1847
|
+
for (const fixtureKey of Object.keys(expect.journal)) {
|
|
1848
|
+
adapted.set(fixtureKey, await adaptDerivedPurchaseKey(vector, fixtureKey, reference.store));
|
|
1849
|
+
}
|
|
1850
|
+
for (const [fixtureKey, states] of Object.entries(expect.journal)) {
|
|
1851
|
+
const key = adapted.get(fixtureKey) ?? fixtureKey;
|
|
1852
|
+
const terminal = states.at(-1);
|
|
1853
|
+
const actual = reference.journal[key]?.at(-1);
|
|
1854
|
+
if (actual !== terminal) {
|
|
1855
|
+
return {
|
|
1856
|
+
reason: `the reference run under the provider capabilities ends ${key} in `
|
|
1857
|
+
+ `'${String(actual)}', not '${String(terminal)}'`,
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
const expectedKeys = new Set(adapted.values());
|
|
1862
|
+
const extra = Object.keys(reference.journal).filter((key) => !expectedKeys.has(key));
|
|
1863
|
+
if (extra.length > 0) {
|
|
1864
|
+
return {
|
|
1865
|
+
reason: `the reference run under the provider capabilities journals ${extra.join(', ')}, `
|
|
1866
|
+
+ 'which the vector does not expect',
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
return {
|
|
1871
|
+
expect: {
|
|
1872
|
+
...expect,
|
|
1873
|
+
...(expect.journal === undefined ? {} : { journal: reference.journal }),
|
|
1874
|
+
...(expect.verifyRequests === undefined
|
|
1875
|
+
? {}
|
|
1876
|
+
: {
|
|
1877
|
+
verifyRequests: reference.verifyRequests.map((request) => ({
|
|
1878
|
+
idempotencyKey: request.idempotencyKey,
|
|
1879
|
+
store: request.store,
|
|
1880
|
+
transactionId: request.transactionId,
|
|
1881
|
+
token: request.token,
|
|
1882
|
+
})),
|
|
1883
|
+
}),
|
|
1884
|
+
...(expect.finishCalls === undefined ? {} : { finishCalls: reference.finishCalls }),
|
|
1885
|
+
...(expect.available === undefined
|
|
1886
|
+
? {}
|
|
1887
|
+
: {
|
|
1888
|
+
available: reference.available.map((purchase) => ({
|
|
1889
|
+
transactionId: purchase.transactionId,
|
|
1890
|
+
storeSku: purchase.storeSku,
|
|
1891
|
+
state: purchase.state,
|
|
1892
|
+
})),
|
|
1893
|
+
}),
|
|
1894
|
+
},
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* A fixture journal key as the provider's store id derives it: only the
|
|
1899
|
+
* encoded store segment of a `store:<store>:<transactionId>` key
|
|
1900
|
+
* (`createDerivedPurchaseIdempotencyKey`) is replaced, so a transaction id
|
|
1901
|
+
* that happens to contain the fixture store id is left alone. Game-chosen
|
|
1902
|
+
* keys and store-specific key forms carry no such prefix and pass through.
|
|
1903
|
+
*/
|
|
1904
|
+
export function adaptDerivedPurchaseKeyStore(key, fixtureStore, providerStore) {
|
|
1905
|
+
const fixturePrefix = `store:${encodeURIComponent(fixtureStore)}:`;
|
|
1906
|
+
return key.startsWith(fixturePrefix)
|
|
1907
|
+
? `store:${encodeURIComponent(providerStore)}:${key.slice(fixturePrefix.length)}`
|
|
1908
|
+
: key;
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* A fixture journal key as the provider's store id derives it. When the key is
|
|
1912
|
+
* the derived key of a purchase the vector scripts under the fixture store, it
|
|
1913
|
+
* is re-derived with `createDerivedPurchaseIdempotencyKey` for the provider
|
|
1914
|
+
* store, which also covers the store-specific key forms (`google-play` token
|
|
1915
|
+
* keys, `apps-in-toss:purchase:<id>`); otherwise only the generic
|
|
1916
|
+
* `store:<store>:` prefix is swapped (`adaptDerivedPurchaseKeyStore`).
|
|
1917
|
+
*/
|
|
1918
|
+
export async function adaptDerivedPurchaseKey(vector, key, providerStore) {
|
|
1919
|
+
const fixtureStore = vector.profile.store;
|
|
1920
|
+
for (const purchase of scriptedPurchasesOf(vector)) {
|
|
1921
|
+
const derive = (store) => createDerivedPurchaseIdempotencyKey({
|
|
1922
|
+
store,
|
|
1923
|
+
transactionId: purchase.transactionId,
|
|
1924
|
+
token: purchase.token,
|
|
1925
|
+
});
|
|
1926
|
+
if (await derive(fixtureStore) === key) {
|
|
1927
|
+
return derive(providerStore);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
return adaptDerivedPurchaseKeyStore(key, fixtureStore, providerStore);
|
|
1931
|
+
}
|
|
1932
|
+
function scriptedPurchasesOf(vector) {
|
|
1933
|
+
return vector.steps.flatMap((step) => {
|
|
1934
|
+
if (step.actor !== 'store') {
|
|
1935
|
+
return [];
|
|
1936
|
+
}
|
|
1937
|
+
if (step.action === 'complete' || step.action === 'update') {
|
|
1938
|
+
return [step.purchase];
|
|
1939
|
+
}
|
|
1940
|
+
return step.action === 'queueRequest' && step.result.outcome === 'purchased'
|
|
1941
|
+
? [step.result.purchase]
|
|
1942
|
+
: [];
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
async function deriveAdaptiveExpectation(vector, reference, store) {
|
|
1946
|
+
return deriveAdaptiveCommerceConformanceExpectation(vector, {
|
|
1947
|
+
store,
|
|
1948
|
+
journal: reference.journal.snapshot(),
|
|
1949
|
+
verifyRequests: reference.backend.verifyRequests,
|
|
1950
|
+
finishCalls: reference.harness.store?.finishCalls ?? [],
|
|
1951
|
+
available: await reference.harness.provider.getAvailablePurchases({ includePending: true }),
|
|
1952
|
+
});
|
|
1491
1953
|
}
|
|
1492
1954
|
function storeActorOf(context, step) {
|
|
1493
1955
|
if (context.harness.store === undefined) {
|
|
@@ -1604,8 +2066,7 @@ async function runStep(step, path, context) {
|
|
|
1604
2066
|
return;
|
|
1605
2067
|
}
|
|
1606
2068
|
}
|
|
1607
|
-
async function matchExpectations(
|
|
1608
|
-
const { expect } = vector;
|
|
2069
|
+
async function matchExpectations(expect, context) {
|
|
1609
2070
|
const { failures, harness } = context;
|
|
1610
2071
|
if (expect.journal !== undefined) {
|
|
1611
2072
|
const actual = context.journal.snapshot();
|