From de060c105be30a084ae7088eeb6786cf8da03895 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Jul 2026 15:20:55 +0300 Subject: [PATCH] feat(foundry): add map zones v2 consumer policy --- registry/data-product-consumer-policies.json | 40 +++ scripts/validate-registry.mjs | 38 +++ server/foundry-data-product-consumer.mjs | 95 +++++++ server/foundry-data-product-consumer.test.mjs | 248 ++++++++++++++++++ 4 files changed, 421 insertions(+) diff --git a/registry/data-product-consumer-policies.json b/registry/data-product-consumer-policies.json index 74814ca..29f6ee1 100644 --- a/registry/data-product-consumer-policies.json +++ b/registry/data-product-consumer-policies.json @@ -57,6 +57,46 @@ "staleAfterMs": null, "terminalStatuses": [], "removeMode": "canonical-tombstone-or-snapshot-rebase" + }, + { + "id": "map-zone-current-v2", + "version": "2.0.0", + "dataProductId": "map.zones.current.v2", + "productVersion": "2.0.0", + "freshness": "none", + "staleAfterMs": null, + "terminalStatuses": [], + "consumerContract": { + "ontologyRevision": "ontology.map.zone.v1", + "deliveryMode": "snapshot+patch", + "semanticTypes": [ + "map.zone" + ], + "fieldProjection": [ + "display_name", + "geometry_kind", + "max_speed_kph", + "schedule_timezone", + "applies_to_couriers", + "applies_to_kicksharing" + ], + "fieldTypes": { + "display_name": "string", + "geometry_kind": "string", + "max_speed_kph": "number", + "schedule_timezone": "string", + "applies_to_couriers": "boolean", + "applies_to_kicksharing": "boolean" + }, + "geometryTypes": [ + "Polygon", + "MultiPolygon" + ], + "subjectIdentity": "semantic-type+source-id", + "snapshotMode": "atomic-replace", + "patchMode": "atomic" + }, + "removeMode": "canonical-tombstone-or-snapshot-rebase" } ] } diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index 4c87aca..7f328bd 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -67,6 +67,23 @@ for (const policy of consumerPolicies) { requireValue(Array.isArray(policy.terminalStatuses) && policy.terminalStatuses.every((value) => typeof value === "string" && /^[a-z0-9_-]{1,64}$/.test(value)), `invalid terminal statuses: ${policy.id ?? "unknown"}`); requireValue(policy.removeMode === "canonical-tombstone-or-snapshot-rebase", `invalid remove mode: ${policy.id ?? "unknown"}`); requireValue(["none", "observed-at"].includes(freshness), `invalid freshness mode: ${policy.id ?? "unknown"}`); + if (policy.consumerContract !== undefined) { + const contract = policy.consumerContract; + const keys = contract && typeof contract === "object" && !Array.isArray(contract) ? Object.keys(contract).sort() : []; + requireValue(JSON.stringify(keys) === JSON.stringify(["deliveryMode", "fieldProjection", "fieldTypes", "geometryTypes", "ontologyRevision", "patchMode", "semanticTypes", "snapshotMode", "subjectIdentity"]), `invalid consumer contract keys: ${policy.id ?? "unknown"}`); + requireValue(typeof contract?.ontologyRevision === "string" && /^[A-Za-z0-9._:-]{1,160}$/.test(contract.ontologyRevision), `invalid consumer ontology revision: ${policy.id ?? "unknown"}`); + requireValue(contract?.deliveryMode === "snapshot+patch", `invalid consumer delivery mode: ${policy.id ?? "unknown"}`); + requireValue(Array.isArray(contract?.semanticTypes) && contract.semanticTypes.length >= 1 && new Set(contract.semanticTypes).size === contract.semanticTypes.length, `invalid consumer semantic types: ${policy.id ?? "unknown"}`); + requireValue(contract?.semanticTypes?.every((value) => typeof value === "string" && /^[A-Za-z0-9._:-]{1,160}$/.test(value)), `invalid consumer semantic type: ${policy.id ?? "unknown"}`); + requireValue(Array.isArray(contract?.fieldProjection) && contract.fieldProjection.length >= 1 && new Set(contract.fieldProjection).size === contract.fieldProjection.length, `invalid consumer field projection: ${policy.id ?? "unknown"}`); + requireValue(contract?.fieldProjection?.every((value) => typeof value === "string" && /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(value)), `invalid consumer projection field: ${policy.id ?? "unknown"}`); + requireValue(contract?.fieldTypes && typeof contract.fieldTypes === "object" && !Array.isArray(contract.fieldTypes), `invalid consumer field types: ${policy.id ?? "unknown"}`); + requireValue(JSON.stringify(Object.keys(contract?.fieldTypes ?? {})) === JSON.stringify(contract?.fieldProjection ?? []), `consumer field type order/scope mismatch: ${policy.id ?? "unknown"}`); + requireValue(Object.values(contract?.fieldTypes ?? {}).every((value) => ["string", "number", "boolean"].includes(value)), `invalid consumer field type: ${policy.id ?? "unknown"}`); + requireValue(JSON.stringify(contract?.geometryTypes) === JSON.stringify(["Polygon", "MultiPolygon"]), `invalid consumer geometry types: ${policy.id ?? "unknown"}`); + requireValue(contract?.subjectIdentity === "semantic-type+source-id", `invalid consumer subject identity: ${policy.id ?? "unknown"}`); + requireValue(contract?.snapshotMode === "atomic-replace" && contract?.patchMode === "atomic", `invalid consumer commit modes: ${policy.id ?? "unknown"}`); + } if (statusContract === undefined) { requireValue( freshness === "none" @@ -93,6 +110,27 @@ requireValue(movingObjectV4Policy?.statusContract?.attribute === "signal_state", requireValue(JSON.stringify(movingObjectV4Policy?.statusContract?.allowedValues) === JSON.stringify(["active", "inactive"]), "fleet.positions.current.v4 signal states must remain closed active/inactive"); requireValue(movingObjectV4Policy?.statusContract?.freshness === "none" && movingObjectV4Policy?.staleAfterMs === null, "fleet.positions.current.v4 must not invent freshness states"); +const zoneV1Policy = consumerPolicies.find((policy) => policy.dataProductId === "map.zones.current.v1" && policy.productVersion === "1.0.0"); +requireValue(JSON.stringify(zoneV1Policy) === JSON.stringify({ + id: "map-zone-current-v1", + version: "1.0.0", + dataProductId: "map.zones.current.v1", + productVersion: "1.0.0", + freshness: "none", + staleAfterMs: null, + terminalStatuses: [], + removeMode: "canonical-tombstone-or-snapshot-rebase", +}), "historical map.zones.current.v1 consumer policy changed"); + +const zoneV2Policy = consumerPolicies.find((policy) => policy.dataProductId === "map.zones.current.v2" && policy.productVersion === "2.0.0"); +const zoneV2Projection = ["display_name", "geometry_kind", "max_speed_kph", "schedule_timezone", "applies_to_couriers", "applies_to_kicksharing"]; +requireValue(zoneV2Policy?.id === "map-zone-current-v2" && zoneV2Policy?.version === "2.0.0", "map.zones.current.v2 consumer policy is missing"); +requireValue(zoneV2Policy?.freshness === "none" && zoneV2Policy?.staleAfterMs === null, "map.zones.current.v2 must not invent freshness states"); +requireValue(JSON.stringify(zoneV2Policy?.consumerContract?.semanticTypes) === JSON.stringify(["map.zone"]), "map.zones.current.v2 semantic scope must be exact"); +requireValue(JSON.stringify(zoneV2Policy?.consumerContract?.fieldProjection) === JSON.stringify(zoneV2Projection), "map.zones.current.v2 field projection must be exact"); +requireValue(JSON.stringify(zoneV2Policy?.consumerContract?.geometryTypes) === JSON.stringify(["Polygon", "MultiPolygon"]), "map.zones.current.v2 geometry contract must be Polygon/MultiPolygon"); +requireValue(!/(provider|tenant|connection|endpoint|credential|token|secret|authorization)/i.test(JSON.stringify(zoneV2Policy)), "map.zones.current.v2 consumer policy crosses the transport/secret boundary"); + const ids = components.components.map((component) => component.id); requireValue(new Set(ids).size === ids.length, "component ids must be unique"); const candidateIds = candidates.candidates.map((component) => component.id); diff --git a/server/foundry-data-product-consumer.mjs b/server/foundry-data-product-consumer.mjs index b1167d8..a688b35 100644 --- a/server/foundry-data-product-consumer.mjs +++ b/server/foundry-data-product-consumer.mjs @@ -138,6 +138,94 @@ function activeReaderGrantGeneration(state) { return generation; } +function validateConsumerContract(value) { + if (value === undefined) return null; + const keys = value && typeof value === "object" && !Array.isArray(value) + ? Object.keys(value).sort() + : []; + if (JSON.stringify(keys) !== JSON.stringify([ + "deliveryMode", + "fieldProjection", + "fieldTypes", + "geometryTypes", + "ontologyRevision", + "patchMode", + "semanticTypes", + "snapshotMode", + "subjectIdentity", + ])) throw consumerError("data_product_consumer_policy_invalid", 500); + const semanticTypes = Array.isArray(value.semanticTypes) ? value.semanticTypes : []; + const fieldProjection = Array.isArray(value.fieldProjection) ? value.fieldProjection : []; + const geometryTypes = Array.isArray(value.geometryTypes) ? value.geometryTypes : []; + const fieldTypes = value.fieldTypes && typeof value.fieldTypes === "object" && !Array.isArray(value.fieldTypes) + ? value.fieldTypes + : null; + if ( + !identifier.test(String(value.ontologyRevision || "")) + || value.deliveryMode !== "snapshot+patch" + || semanticTypes.length === 0 + || semanticTypes.length > 8 + || new Set(semanticTypes).size !== semanticTypes.length + || semanticTypes.some((item) => !identifier.test(item)) + || fieldProjection.length === 0 + || fieldProjection.length > 32 + || new Set(fieldProjection).size !== fieldProjection.length + || fieldProjection.some((item) => !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(item)) + || !fieldTypes + || JSON.stringify(Object.keys(fieldTypes)) !== JSON.stringify(fieldProjection) + || Object.values(fieldTypes).some((type) => !["string", "number", "boolean"].includes(type)) + || JSON.stringify(geometryTypes) !== JSON.stringify(["Polygon", "MultiPolygon"]) + || value.subjectIdentity !== "semantic-type+source-id" + || value.snapshotMode !== "atomic-replace" + || value.patchMode !== "atomic" + ) throw consumerError("data_product_consumer_policy_invalid", 500); + return { + ontologyRevision: value.ontologyRevision, + deliveryMode: "snapshot+patch", + semanticTypes: [...semanticTypes], + fieldProjection: [...fieldProjection], + fieldTypes: { ...fieldTypes }, + geometryTypes: [...geometryTypes], + subjectIdentity: "semantic-type+source-id", + snapshotMode: "atomic-replace", + patchMode: "atomic", + }; +} + +function assertConsumerContract(contract, product, binding) { + if (!contract) return; + const productFieldTypes = Object.fromEntries(contract.fieldProjection.map((field) => [ + field, + product?.fieldContracts?.[field]?.type, + ])); + if ( + product.ontologyRevision !== contract.ontologyRevision + || product.deliveryMode !== contract.deliveryMode + || JSON.stringify(product.semanticTypes) !== JSON.stringify(contract.semanticTypes) + || JSON.stringify(binding.semanticTypes) !== JSON.stringify(contract.semanticTypes) + || JSON.stringify(binding.fieldProjection) !== JSON.stringify(contract.fieldProjection) + || !Array.isArray(product.fields) + || contract.fieldProjection.some((field) => !product.fields.includes(field)) + || JSON.stringify(productFieldTypes) !== JSON.stringify(contract.fieldTypes) + ) throw consumerError("data_product_consumer_contract_mismatch", 409); +} + +function assertEnvelopeProduct(envelope, product, errorCode) { + if (envelope?.dataProduct?.id !== product.id || envelope?.dataProduct?.version !== product.version) { + throw consumerError(errorCode, 502); + } +} + +function assertFactContract(fact, policy) { + const contract = policy.consumerContract; + if (!contract) return; + if ( + !contract.semanticTypes.includes(fact.semanticType) + || !fact.geometry + || !contract.geometryTypes.includes(fact.geometry.type) + ) throw consumerError("data_product_consumer_fact_contract_invalid", 502); +} + function validatePolicy(policy, product) { if (!policy || policy.dataProductId !== product.id || policy.productVersion !== product.version) { throw consumerError("data_product_consumer_policy_not_found", 409); @@ -169,6 +257,7 @@ function validatePolicy(policy, product) { if (policy.removeMode !== "canonical-tombstone-or-snapshot-rebase") { throw consumerError("data_product_consumer_policy_invalid", 500); } + const consumerContract = validateConsumerContract(policy.consumerContract); return { id: String(policy.id || ""), version: String(policy.version || ""), @@ -178,6 +267,7 @@ function validatePolicy(policy, product) { staleAfterMs: policy.staleAfterMs, terminalStatuses: [...terminalStatuses], ...(statusContract ? { statusContract } : {}), + ...(consumerContract ? { consumerContract } : {}), removeMode: policy.removeMode, }; } @@ -347,6 +437,7 @@ export function createFoundryDataProductConsumerManager({ throw consumerError("data_product_consumer_semantic_scope_mismatch", 409); } const policy = validatePolicy(resolvePolicy(product), product); + assertConsumerContract(policy.consumerContract, product, target.binding); if (policy.statusContract && !target.binding.fieldProjection.includes(policy.statusContract.attribute)) { throw consumerError("data_product_consumer_status_field_not_projected", 409); } @@ -473,9 +564,11 @@ export function createFoundryDataProductConsumerManager({ await writeState(record); try { const snapshot = await fetchSnapshot(record); + assertEnvelopeProduct(snapshot, record.state.product, "data_product_snapshot_product_mismatch"); const previousKeys = new Set(Object.keys(record.state.subjects || {})); const subjects = {}; for (const fact of snapshot.facts) { + assertFactContract(fact, record.state.policy); const key = factKey(fact); subjects[key] = { fact, @@ -645,6 +738,7 @@ export function createFoundryDataProductConsumerManager({ const emittedOperations = []; for (const operation of patch.operations) { if (operation.op === "upsert") { + assertFactContract(operation.fact, record.state.policy); const key = factKey(operation.fact); const subject = { fact: operation.fact, @@ -735,6 +829,7 @@ export function createFoundryDataProductConsumerManager({ try { payload = JSON.parse(event.data); } catch { payload = null; } const patch = sanitizePatch(payload, record.target.binding); if (!patch) throw consumerError("data_product_patch_contract_invalid", 502); + assertEnvelopeProduct(patch, record.state.product, "data_product_patch_product_mismatch"); await applyPatch(record, patch); } } diff --git a/server/foundry-data-product-consumer.test.mjs b/server/foundry-data-product-consumer.test.mjs index e1c73f3..3586b68 100644 --- a/server/foundry-data-product-consumer.test.mjs +++ b/server/foundry-data-product-consumer.test.mjs @@ -614,3 +614,251 @@ test("v4 consumer rejects bindings that omit signal_state and snapshots outside await rm(invalidStateDir, { recursive: true, force: true }); } }); + +const zoneV2Projection = [ + "display_name", + "geometry_kind", + "max_speed_kph", + "schedule_timezone", + "applies_to_couriers", + "applies_to_kicksharing", +]; + +const zoneV2Policy = { + id: "map-zone-current-v2", + version: "2.0.0", + dataProductId: "map.zones.current.v2", + productVersion: "2.0.0", + freshness: "none", + staleAfterMs: null, + terminalStatuses: [], + consumerContract: { + ontologyRevision: "ontology.map.zone.v1", + deliveryMode: "snapshot+patch", + semanticTypes: ["map.zone"], + fieldProjection: zoneV2Projection, + fieldTypes: { + display_name: "string", + geometry_kind: "string", + max_speed_kph: "number", + schedule_timezone: "string", + applies_to_couriers: "boolean", + applies_to_kicksharing: "boolean", + }, + geometryTypes: ["Polygon", "MultiPolygon"], + subjectIdentity: "semantic-type+source-id", + snapshotMode: "atomic-replace", + patchMode: "atomic", + }, + removeMode: "canonical-tombstone-or-snapshot-rebase", +}; + +const zoneV2Product = { + id: "map.zones.current.v2", + version: "2.0.0", + ontologyRevision: "ontology.map.zone.v1", + deliveryMode: "snapshot+patch", + semanticTypes: ["map.zone"], + fields: [...zoneV2Projection, "geometry", "source_revision"], + fieldContracts: { + display_name: { type: "string", required: true }, + geometry_kind: { type: "string", required: true }, + max_speed_kph: { type: "number", required: false }, + schedule_timezone: { type: "string", required: false }, + applies_to_couriers: { type: "boolean", required: false }, + applies_to_kicksharing: { type: "boolean", required: false }, + }, + active: true, +}; + +function zoneV2Target(fieldProjection = zoneV2Projection) { + return { + application: { id: "55555555-5555-4555-8555-555555555555" }, + page: { id: "map" }, + binding: { + id: "depttrans-pmd-slow-zones", + dataProductId: zoneV2Product.id, + slotId: "points", + delivery: "snapshot+patch", + semanticTypes: ["map.zone"], + fieldProjection: [...fieldProjection], + }, + }; +} + +function zoneFact({ sourceId = "depttrans-zone-001", geometryType = "Polygon", maxSpeedKph = 20 } = {}) { + const polygon = [[[37.60, 55.75], [37.61, 55.75], [37.61, 55.76], [37.60, 55.75]]]; + return { + sourceId, + semanticType: "map.zone", + observedAt: "2026-07-21T10:00:00.000Z", + receivedAt: "2026-07-21T10:00:01.000Z", + attributes: { + display_name: "Slow zone 001", + geometry_kind: "polygon", + max_speed_kph: maxSpeedKph, + schedule_timezone: "Europe/Moscow", + applies_to_couriers: true, + applies_to_kicksharing: true, + }, + geometry: geometryType === "MultiPolygon" + ? { type: "MultiPolygon", coordinates: [polygon] } + : geometryType === "Polygon" + ? { type: "Polygon", coordinates: polygon } + : { type: geometryType, coordinates: [37.61, 55.75] }, + }; +} + +function zoneSnapshot({ cursor = "903", version = "2.0.0", facts = [zoneFact()] } = {}) { + return { + schemaVersion: "nodedc.data-product.snapshot/v1", + dataProduct: { id: zoneV2Product.id, version }, + generatedAt: "2026-07-21T10:00:02.000Z", + cursor, + facts, + }; +} + +function zonePatch(cursor, previousCursor, operations, version = "2.0.0") { + return { + schemaVersion: "nodedc.data-product.patch/v1", + dataProduct: { id: zoneV2Product.id, version }, + cursor, + previousCursor, + emittedAt: "2026-07-21T10:00:03.000Z", + operations, + }; +} + +test("zone v2 consumer pins provider-neutral contract and commits Polygon/MultiPolygon patches by stable subject identity", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-zone-v2-")); + const currentTarget = zoneV2Target(); + const streamCounters = { open: 0, closed: 0 }; + const manager = createFoundryDataProductConsumerManager({ + stateDir, + dataPlaneUrl: "http://edp.test", + resolveTarget: async () => structuredClone(currentTarget), + readReaderToken: async () => "ndc_edprb_zone-reader-capability", + inspectReaderGrant: async () => ({ product: zoneV2Product, readerGrantAction: "reuse", readerGrantGeneration: 1 }), + resolvePolicy: () => zoneV2Policy, + sanitizeSnapshot: (value) => value, + sanitizePatch: (value) => value, + fetchImpl: async () => Response.json(zoneSnapshot()), + openStream: async ({ signal }) => sseResponse( + zonePatch("904", "903", [{ op: "upsert", fact: zoneFact({ geometryType: "MultiPolygon", maxSpeedKph: 15 }) }]), + signal, + streamCounters, + ), + now: () => Date.parse("2026-07-21T10:00:10.000Z"), + idleStopMs: 10, + reconnectMinMs: 10, + reconnectMaxMs: 20, + }); + try { + const input = { applicationId: currentTarget.application.id, pageId: currentTarget.page.id, bindingId: currentTarget.binding.id }; + const plan = await manager.plan(input); + assert.equal(plan.configuration.product.version, "2.0.0"); + assert.deepEqual(plan.configuration.policy.consumerContract.semanticTypes, ["map.zone"]); + assert.deepEqual(plan.configuration.policy.consumerContract.fieldProjection, zoneV2Projection); + assert.deepEqual(plan.configuration.policy.consumerContract.geometryTypes, ["Polygon", "MultiPolygon"]); + assert.equal(JSON.stringify(plan).includes("http://edp.test"), false); + assert.equal(JSON.stringify(plan).includes("ndc_edprb_"), false); + + const applied = await manager.apply({ ...input, planId: plan.planId }); + assert.equal(applied.consumer.cursor, "903"); + assert.equal(applied.consumer.subjectCount, 1); + assert.equal(applied.consumer.subjects[0].subjectId, "depttrans-zone-001"); + assert.equal(applied.consumer.subjects[0].status, "active"); + assert.equal(applied.consumer.metrics.staleTransitions, 0); + + const lease = await manager.subscribe({ ...currentTarget, after: "903" }, () => undefined); + await waitUntil(async () => (await manager.status(input)).consumer.cursor === "904", "zone_patch_cursor_904"); + const patched = await manager.snapshot(currentTarget); + assert.equal(patched.facts.length, 1); + assert.equal(patched.facts[0].sourceId, "depttrans-zone-001"); + assert.equal(patched.facts[0].geometry.type, "MultiPolygon"); + assert.equal(patched.facts[0].attributes.max_speed_kph, 15); + assert.equal((await manager.status(input)).consumer.upstreamStreamCount, 1); + lease.release(); + await waitUntil(() => streamCounters.closed === 1, "zone_stream_closed"); + await waitUntil(async () => (await manager.status(input)).consumer.runtimeState === "idle", "zone_stream_idle"); + } finally { + await manager.shutdown(); + await rm(stateDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }); + } +}); + +test("zone v2 consumer fails closed on projection, product, envelope and geometry contract drift", async () => { + const cases = [ + { + name: "projection", + target: zoneV2Target(zoneV2Projection.slice(0, -1)), + product: zoneV2Product, + policy: zoneV2Policy, + snapshot: zoneSnapshot(), + planError: /data_product_consumer_contract_mismatch/, + }, + { + name: "field-type", + target: zoneV2Target(), + product: { ...zoneV2Product, fieldContracts: { ...zoneV2Product.fieldContracts, max_speed_kph: { type: "string" } } }, + policy: zoneV2Policy, + snapshot: zoneSnapshot(), + planError: /data_product_consumer_contract_mismatch/, + }, + { + name: "product-version", + target: zoneV2Target(), + product: { ...zoneV2Product, version: "2.0.1" }, + policy: zoneV2Policy, + snapshot: zoneSnapshot({ version: "2.0.1" }), + planError: /data_product_consumer_policy_not_found/, + }, + { + name: "envelope-version", + target: zoneV2Target(), + product: zoneV2Product, + policy: zoneV2Policy, + snapshot: zoneSnapshot({ version: "2.0.1" }), + applyError: /data_product_snapshot_product_mismatch/, + }, + { + name: "point-geometry", + target: zoneV2Target(), + product: zoneV2Product, + policy: zoneV2Policy, + snapshot: zoneSnapshot({ facts: [zoneFact({ geometryType: "Point" })] }), + applyError: /data_product_consumer_fact_contract_invalid/, + }, + ]; + + for (const variant of cases) { + const stateDir = await mkdtemp(join(tmpdir(), `foundry-consumer-zone-v2-${variant.name}-`)); + const manager = createFoundryDataProductConsumerManager({ + stateDir, + dataPlaneUrl: "http://edp.test", + resolveTarget: async () => structuredClone(variant.target), + readReaderToken: async () => "ndc_edprb_zone-reader-capability", + inspectReaderGrant: async () => ({ product: variant.product, readerGrantAction: "reuse", readerGrantGeneration: 1 }), + resolvePolicy: () => variant.policy, + sanitizeSnapshot: (value) => value, + sanitizePatch: (value) => value, + fetchImpl: async () => Response.json(variant.snapshot), + }); + const input = { applicationId: variant.target.application.id, pageId: variant.target.page.id, bindingId: variant.target.binding.id }; + try { + if (variant.planError) { + await assert.rejects(manager.plan(input), variant.planError); + continue; + } + const plan = await manager.plan(input); + await assert.rejects(manager.apply({ ...input, planId: plan.planId }), variant.applyError); + const failed = await manager.status(input); + assert.equal(failed.consumer.subjectCount, 0); + assert.equal(failed.consumer.runtimeState, "error"); + } finally { + await manager.shutdown(); + await rm(stateDir, { recursive: true, force: true }); + } + } +});