@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.
@@ -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
- if (capabilities.lifecycle.availablePurchases) {
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
- const failures = [];
1436
- const playerId = options.playerId ?? commerceConformanceDefaultPlayerId;
1437
- const titleId = options.titleId ?? commerceConformanceDefaultTitleId;
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 (options.createProvider ?? createCommerceConformanceHarness)(context);
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
- for (const [index, step] of vector.steps.entries()) {
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, { harness, runtime, backend, clock, failures });
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: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
1648
+ actual: describeError(error),
1478
1649
  });
1479
- return { vector: vector.vector, passed: false, failures };
1650
+ break;
1480
1651
  }
1481
1652
  }
1482
- await matchExpectations(vector, { harness, runtime, backend, journal, failures });
1483
- return { vector: vector.vector, passed: failures.length === 0, failures };
1653
+ return failures;
1484
1654
  }
1485
- export async function runCommerceConformanceVectors(vectors, options = {}) {
1486
- const results = [];
1487
- for (const vector of vectors) {
1488
- results.push(await runCommerceConformanceVector(vector, options));
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
- return results;
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(vector, context) {
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();