test: stabilize preload push replay assertions

This commit is contained in:
2026-08-15 17:32:16 +08:00
parent 8350bcdf00
commit 05921eca13
5 changed files with 202 additions and 24 deletions
@@ -1,4 +1,5 @@
import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs'
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
const MODULE = 'phase3-container-loading'
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
@@ -258,14 +259,21 @@ export async function run(context) {
Boolean(pushedLink?.id) && Number(pushedLink?.allocatedPackageCount) === 1,
`linkId=${id(pushedLink?.id) || 'missing'}, preloadPlanId=${id(pushedLink?.preloadPlanId) || 'missing'}`, OPTIONS)
if (!pushedLink?.id) return
const postPushPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS)
context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse,
postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(pushedLink?.loadingPlanVersion)
&& Number(postPushPlan?.version) > Number(assigned.version),
`pushResponseVersion=${pushed?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${pushedLink?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return
const pushedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '无号 push 成功后 PDA 查询 A0404', pushedTaskResponse,
pushedTaskResponse.http === 200 && codeOf(pushedTaskResponse) === 'A0404',
responseEvidence(pushedTaskResponse), OPTIONS)
const pushBaseline = JSON.stringify({
plan: pushed, detail: pushedDetail, links: pushedLinks,
const pushBaseline = projectPushPreloadStability({
plan: postPushPlan, detail: pushedDetail, links: pushedLinks,
task: { http: pushedTaskResponse.http, code: codeOf(pushedTaskResponse) },
})
const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
@@ -279,15 +287,16 @@ export async function run(context) {
const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS)
const replayTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const pushReplaySnapshot = JSON.stringify({
const pushReplaySnapshot = projectPushPreloadStability({
plan: replayPlan, detail: replayDetail, links: replayLinks,
task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) },
})
const pushReplayDiff = diffPushPreloadStability(pushBaseline, pushReplaySnapshot)
context.assert(MODULE, 'push exact replay 对计划版本历史关联任务零漂移', pushReplayResponse,
pushBaseline === pushReplaySnapshot && codeOf(replayTaskResponse) === 'A0404',
`baselineBytes=${pushBaseline.length}, replayBytes=${pushReplaySnapshot.length}`, OPTIONS)
pushReplayDiff.length === 0 && codeOf(replayTaskResponse) === 'A0404',
`changed=${pushReplayDiff.join('|') || 'none'}; replayTask=${responseEvidence(replayTaskResponse)}`, OPTIONS)
const pushSameKeyDifferentBody = await request(context, 'POST', pushRequest.path,
{ expectedVersion: Number(pushed.version) }, pushRequest.headers)
{ expectedVersion: Number(postPushPlan.version) }, pushRequest.headers)
context.addStep(MODULE, 'push 同 key 异参拒绝', pushSameKeyDifferentBody,
failure(pushSameKeyDifferentBody), responseEvidence(pushSameKeyDifferentBody), OPTIONS)
const pushNewKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body,
@@ -304,21 +313,22 @@ export async function run(context) {
const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS)
const rejectedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedSnapshot = JSON.stringify({
const rejectedSnapshot = projectPushPreloadStability({
plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks,
task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) },
})
const rejectedDiff = diffPushPreloadStability(pushBaseline, rejectedSnapshot)
context.assert(MODULE, '两类非法 push 后计划版本历史关联任务仍零漂移', rejectedTaskResponse,
failure(pushSameKeyDifferentBody) && failure(pushNewKeyDuplicate)
&& pushBaseline === rejectedSnapshot && codeOf(rejectedTaskResponse) === 'A0404',
`baselineBytes=${pushBaseline.length}, rejectedBytes=${rejectedSnapshot.length}`, OPTIONS)
if (pushBaseline !== rejectedSnapshot || codeOf(rejectedTaskResponse) !== 'A0404') return
&& rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404',
`changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(pushSameKeyDifferentBody)}; newKey=${responseEvidence(pushNewKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS)
if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return
const realContainerNo = `TST-${suffix}`.slice(0, 64)
const transportResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo: realContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
expectedVersion: Number(pushed.version), operationReason: context.marker,
expectedVersion: Number(postPushPlan.version), operationReason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-preload-bind-container-no`.slice(0, 128) })
const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定本轮真实柜号', transportResponse, '', OPTIONS)
context.assert(MODULE, '预装补号保持柜次和计划身份稳定', transportResponse,
@@ -1,3 +1,5 @@
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
const MODULE = 'phase3-loading'
const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true }
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
@@ -5,6 +7,9 @@ const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function responseEvidence(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function failure(response) {
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|| (response?.http === 200 && codeOf(response) !== '200')
@@ -205,6 +210,13 @@ export async function run(context) {
&& Number(link?.allocatedPackageCount) === 1,
`linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}`, OPTIONS)
if (!link?.id) return
const postPushPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS)
context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse,
postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(link?.loadingPlanVersion)
&& Number(postPushPlan?.version) > Number(assignedLoadingPlan.version),
`pushResponseVersion=${pushedLoadingPlan?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return
const missingContainerTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const missingContainerTaskAbsent = missingContainerTaskResponse.http === 200 && codeOf(missingContainerTaskResponse) === 'A0404'
@@ -213,8 +225,8 @@ export async function run(context) {
`HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, requestId=${missingContainerTaskResponse.requestId || 'missing'}, traceId=${missingContainerTaskResponse.traceId || 'missing'}`, OPTIONS)
if (!missingContainerTaskAbsent) return
const replayBaseline = JSON.stringify({
plan: pushedLoadingPlan, detail: pushedDetail, links: pushedLinks,
const replayBaseline = projectPushPreloadStability({
plan: postPushPlan, detail: pushedDetail, links: pushedLinks,
task: { http: missingContainerTaskResponse.http, code: codeOf(missingContainerTaskResponse) },
})
const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
@@ -228,16 +240,17 @@ export async function run(context) {
const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS)
const replayTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const replaySnapshot = JSON.stringify({
const replaySnapshot = projectPushPreloadStability({
plan: replayPlan, detail: replayDetail, links: replayLinks,
task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) },
})
const replayDiff = diffPushPreloadStability(replayBaseline, replaySnapshot)
context.assert(MODULE, 'push exact replay 对计划版本历史关联和任务零漂移', pushReplayResponse,
replayBaseline === replaySnapshot && codeOf(replayTaskResponse) === 'A0404',
`baselineBytes=${replayBaseline.length}, replayBytes=${replaySnapshot.length}`, OPTIONS)
replayDiff.length === 0 && codeOf(replayTaskResponse) === 'A0404',
`changed=${replayDiff.join('|') || 'none'}; replayTask=${responseEvidence(replayTaskResponse)}`, OPTIONS)
const sameKeyDifferentBody = await request(context, 'POST', pushRequest.path,
{ expectedVersion: Number(pushedLoadingPlan.version) }, pushRequest.headers)
{ expectedVersion: Number(postPushPlan.version) }, pushRequest.headers)
context.addStep(MODULE, 'push 同 key 异参返回冲突', sameKeyDifferentBody,
failure(sameKeyDifferentBody), `HTTP=${sameKeyDifferentBody.http}, code=${codeOf(sameKeyDifferentBody)}`, OPTIONS)
const newKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body,
@@ -254,19 +267,20 @@ export async function run(context) {
const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS)
const rejectedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedSnapshot = JSON.stringify({
const rejectedSnapshot = projectPushPreloadStability({
plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks,
task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) },
})
const rejectedDiff = diffPushPreloadStability(replayBaseline, rejectedSnapshot)
context.assert(MODULE, '两类非法 push 后计划版本历史关联和任务仍零漂移', rejectedTaskResponse,
failure(sameKeyDifferentBody) && failure(newKeyDuplicate)
&& replayBaseline === rejectedSnapshot && codeOf(rejectedTaskResponse) === 'A0404',
`baselineBytes=${replayBaseline.length}, rejectedBytes=${rejectedSnapshot.length}`, OPTIONS)
if (replayBaseline !== rejectedSnapshot || codeOf(rejectedTaskResponse) !== 'A0404') return
&& rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404',
`changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(sameKeyDifferentBody)}; newKey=${responseEvidence(newKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS)
if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return
const transportResponse = await request(context, 'PATCH',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo, voyageNo: `P3V-${suffix}`, expectedVersion: Number(pushedLoadingPlan.version),
containerNo, voyageNo: `P3V-${suffix}`, expectedVersion: Number(postPushPlan.version),
operationReason: `${context.marker}-bind-container-no`,
}, { 'Idempotency-Key': `${context.marker}-preload-transport`.slice(0, 128) })
const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定真实柜号', transportResponse, '', OPTIONS)
@@ -5,6 +5,7 @@ import test from 'node:test'
import { selectCustomsFlow } from './phase3-customs.mjs'
import { buildFinalizeResource } from './phase3-loading-finalize.mjs'
import { selectContainerLoadingFixture, selectPlatformChannelIsolationPair } from './phase3-container-loading.mjs'
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { AcceptanceRun, normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs'
function source(name) {
@@ -132,9 +133,53 @@ test('push replay and both conflict modes preserve the complete pre-PDA snapshot
assert.match(value, /非法 push 后回查柜次计划[\s\S]*非法 push 后回查预装历史[\s\S]*非法 push 后回查 bridge/)
assert.match(value, /两类非法 push 后计划版本历史关联(?:和)?任务仍?零漂移/)
assert.match(value, /codeOf\(rejectedTaskResponse\) === 'A0404'/)
assert.match(value, /projectPushPreloadStability/)
assert.match(value, /diffPushPreloadStability/)
assert.match(value, /首次 push 后读取事务完成态柜次计划/)
assert.match(value, /事务完成态计划版本与 bridge 一致且保持 EDITING/)
assert.match(value, /plan: postPushPlan, detail:/)
assert.match(value, /expectedVersion: Number\(postPushPlan\.version\)/)
assert.match(value, /changed=\$\{(?:pushReplayDiff|replayDiff|rejectedDiff)\.join/)
assert.doesNotMatch(value, /baselineBytes=|replayBytes=|rejectedBytes=/)
}
})
test('push stability projection sorts unordered facts and reports persisted field drift', () => {
const baseline = projectPushPreloadStability({
plan: { id: '10', bookingId: '20', status: 'EDITING', version: 2,
items: [{ id: '2', inboundId: '42', packageCount: 1, version: 1 },
{ id: '1', inboundId: '41', packageCount: 1, version: 1 }] },
detail: { summary: { planId: '10', containerBookingId: '20', status: 'EDITING', version: 2,
readiness: { preloadPushed: true, preloadLinkCount: 2, confirmationStatus: 'PENDING',
blockingReasons: ['CONTAINER_NO_MISSING', 'OCEAN_BOOKING_NOT_LINKED'] } },
items: [{ itemId: '2', inboundId: '42', packageCount: 1 }, { itemId: '1', inboundId: '41', packageCount: 1 }],
histories: [{ historyId: '2', actionType: 'PUSH' }, { historyId: '1', actionType: 'CREATE' }] },
links: [{ id: '2', inboundId: '42', loadingPlanId: '10', allocatedPackageCount: 1 },
{ id: '1', inboundId: '41', loadingPlanId: '10', allocatedPackageCount: 1 }],
task: { http: 200, code: 'A0404' },
})
const reordered = projectPushPreloadStability({
plan: { id: '10', bookingId: '20', status: 'EDITING', version: 2,
items: [{ id: '1', inboundId: '41', packageCount: 1, version: 1 },
{ id: '2', inboundId: '42', packageCount: 1, version: 1 }], updatedAt: 'ignored' },
detail: { summary: { planId: '10', containerBookingId: '20', status: 'EDITING', version: 2,
readiness: { preloadPushed: true, preloadLinkCount: 2, confirmationStatus: 'PENDING',
blockingReasons: ['OCEAN_BOOKING_NOT_LINKED', 'CONTAINER_NO_MISSING'] } },
items: [{ itemId: '1', inboundId: '41', packageCount: 1 }, { itemId: '2', inboundId: '42', packageCount: 1 }],
histories: [{ historyId: '1', actionType: 'CREATE' }, { historyId: '2', actionType: 'PUSH' }] },
links: [{ id: '1', inboundId: '41', loadingPlanId: '10', allocatedPackageCount: 1 },
{ id: '2', inboundId: '42', loadingPlanId: '10', allocatedPackageCount: 1 }],
task: { http: 200, code: 'A0404' },
})
assert.deepEqual(diffPushPreloadStability(baseline, reordered), [])
const drifted = structuredClone(reordered)
drifted.plan.version = 3
drifted.links[0].preloadVersion = 2
assert.deepEqual(diffPushPreloadStability(baseline, drifted), [
'links[0].preloadVersion:null->2', 'plan.version:2->3',
])
})
test('push orchestration is transactional and a bridge failure cannot reach PDA creation', () => {
const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java')
@@ -212,7 +257,7 @@ test('container loading arranges a simulation before linking a confirmed real bo
}
assert.doesNotMatch(value, /loading-booking-(?:submit|confirm)|ocean-bookings\/.*\/(?:submit|confirm)/)
assert.match(value, /containerAllocations\.length === 0/)
assert.match(value, /expectedVersion: Number\(pushed\.version\), operationReason: context\.marker/)
assert.match(value, /expectedVersion: Number\(postPushPlan\.version\), operationReason: context\.marker/)
assert.match(value, /function responseEvidence\(response\)[\s\S]*HTTP=\$\{response\?\.http \?\? 'missing'\}[\s\S]*code=\$\{codeOf\(response\)\}[\s\S]*requestId=\$\{response\?\.requestId \|\| 'missing'\}[\s\S]*traceId=\$\{response\?\.traceId \|\| 'missing'\}/)
assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜首次关联真实订舱', emptyLink,\s+responseEvidence\(emptyLink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyUnlink = await request/)
assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜允许解除关联', emptyUnlink,\s+responseEvidence\(emptyUnlink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyRelink = await request/)
@@ -0,0 +1,109 @@
function id(value) { return value == null ? null : String(value) }
function number(value) { return value == null || Number.isNaN(Number(value)) ? null : Number(value) }
function text(value) { return value == null ? null : String(value) }
function bool(value) { return value === true }
function sorted(values, key) {
return [...(Array.isArray(values) ? values : [])]
.sort((left, right) => key(left).localeCompare(key(right)))
}
function planItem(item) {
return {
id: id(item?.id), loadingPlanId: id(item?.loadingPlanId), inboundId: id(item?.inboundId),
inboundDetailId: id(item?.inboundDetailId), accountId: id(item?.accountId), orderId: id(item?.orderId),
packageCount: number(item?.packageCount), loadSequence: number(item?.loadSequence),
frozenAt: text(item?.frozenAt), version: number(item?.version), deleted: bool(item?.deleted),
}
}
function detailItem(item) {
return {
itemId: id(item?.itemId), accountId: id(item?.accountId), inboundId: id(item?.inboundId),
inboundDetailId: id(item?.inboundDetailId), packageCount: number(item?.packageCount),
palletCount: number(item?.palletCount), loadSequence: number(item?.loadSequence),
frozenAt: text(item?.frozenAt),
}
}
function readiness(value) {
return {
preloadPushed: bool(value?.preloadPushed), containerNoReady: bool(value?.containerNoReady),
preloadLinkCount: number(value?.preloadLinkCount), confirmedPreloadCount: number(value?.confirmedPreloadCount),
oceanBookingId: id(value?.oceanBookingId), oceanBookingStatus: text(value?.oceanBookingStatus),
pdaTaskId: id(value?.pdaTaskId), pdaTaskStatus: text(value?.pdaTaskStatus),
confirmationStatus: text(value?.confirmationStatus), canConfirm: bool(value?.canConfirm),
readyForPda: bool(value?.readyForPda),
blockingReasons: sorted(value?.blockingReasons, (item) => text(item) || '').map(text),
}
}
function history(value) {
return {
historyId: id(value?.historyId), itemId: id(value?.itemId), actionType: text(value?.actionType),
fromStatus: text(value?.fromStatus), toStatus: text(value?.toStatus),
beforeSnapshot: text(value?.beforeSnapshot), afterSnapshot: text(value?.afterSnapshot),
operationReason: text(value?.operationReason), occurredAt: text(value?.occurredAt),
}
}
function link(value) {
return {
id: id(value?.id), tenantId: id(value?.tenantId), inboundId: id(value?.inboundId),
preloadPlanId: id(value?.preloadPlanId), preloadStatus: text(value?.preloadStatus),
preloadVersion: number(value?.preloadVersion), loadingPlanId: id(value?.loadingPlanId),
containerBookingId: id(value?.containerBookingId), containerNo: text(value?.containerNo),
loadingPlanStatus: text(value?.loadingPlanStatus), loadingPlanVersion: number(value?.loadingPlanVersion),
allocatedPackageCount: number(value?.allocatedPackageCount), linkedAt: text(value?.linkedAt),
}
}
export function projectPushPreloadStability({ plan, detail, links, task }) {
const summary = detail?.summary || {}
return {
plan: {
id: id(plan?.id), bookingId: id(plan?.bookingId), status: text(plan?.status),
containerNo: text(plan?.containerNo), voyageNo: text(plan?.voyageNo), version: number(plan?.version),
items: sorted(plan?.items, (item) => id(item?.id) || '').map(planItem),
},
detail: {
summary: {
planId: id(summary?.planId), containerBookingId: id(summary?.containerBookingId),
containerNo: text(summary?.containerNo), cabinetNo: text(summary?.cabinetNo),
status: text(summary?.status), version: number(summary?.version),
skuCount: number(summary?.skuCount), totalPackageCount: number(summary?.totalPackageCount),
readiness: readiness(summary?.readiness),
},
items: sorted(detail?.items, (item) => id(item?.itemId) || '').map(detailItem),
histories: sorted(detail?.histories,
(item) => `${id(item?.historyId) || ''}:${text(item?.occurredAt) || ''}`).map(history),
},
links: sorted(links, (item) => id(item?.id) || '').map(link),
task: { http: number(task?.http), code: text(task?.code) },
}
}
function display(value) {
if (value === undefined) return 'missing'
return JSON.stringify(value)
}
export function diffPushPreloadStability(before, after) {
const changes = []
function visit(left, right, path) {
if (Object.is(left, right)) return
if (Array.isArray(left) && Array.isArray(right)) {
const length = Math.max(left.length, right.length)
for (let index = 0; index < length; index += 1) visit(left[index], right[index], `${path}[${index}]`)
return
}
if (left && right && typeof left === 'object' && typeof right === 'object') {
const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort()
for (const key of keys) visit(left[key], right[key], path ? `${path}.${key}` : key)
return
}
changes.push(`${path}:${display(left)}->${display(right)}`)
}
visit(before, after, '')
return changes
}