Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2080,3 +2080,59 @@ describe('isSkillsAddInvocation — incur alias awareness', () => {
expect(isSkillsAddInvocation(['cart', 'create'])).toBe(false)
})
})

describe('checkout fulfillment declaration warning', () => {
it('warns before dispatch without blocking a pickup checkout update', async () => {
const events: string[] = []
const cli = createUcpCli({
resolveSession: passthroughSession,
updateCheckout: async (_business, input, options) => {
events.push('discover')
options._onDiscover?.({
profile: {
ucp: {
capabilities: {
'dev.ucp.shopping.fulfillment': [
{
version: '2026-08-25',
config: { allows_method_combinations: [['shipping']] },
},
],
},
},
},
protocol: { version: '2026-08-25' },
negotiated: {},
} as never)
events.push('dispatch')
expect(input).toMatchObject({
id: 'checkout-1',
checkout: { fulfillment: { methods: [{ type: 'pickup' }] } },
})
return { fulfillment: { methods: [] } }
},
})
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
events.push('warning')
expect(String(chunk)).toContain('pickup')
expect(String(chunk)).toContain('merchant may drop it')
return true
})
try {
const { output, exitCode } = await serveCli(cli, [
'checkout',
'update',
'checkout-1',
'--business',
'https://shop.example.com',
'--input',
JSON.stringify({ fulfillment: { methods: [{ type: 'pickup' }] } }),
])
expect(exitCode, output).toBe(0)
expect(JSON.parse(output).result.fulfillment.methods).toEqual([])
expect(events).toEqual(['discover', 'warning', 'dispatch'])
} finally {
stderr.mockRestore()
}
})
})
10 changes: 9 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Cli, middleware, z } from 'incur'

import { buildCta } from './cli/cta.js'
import { type DoctorDeps, runDoctor } from './cli/doctor.js'
import { unsupportedFulfillmentMethods } from './cli/fulfillment-warning.js'
import { buildOperationInput } from './cli/input.js'
import { buildProfileCli, type ProfileCliDependencies } from './cli/profile.js'
import { buildProfileSwitchCta, localProfilesSpeaking } from './cli/profile-hint.js'
Expand All @@ -32,7 +33,7 @@ import { listProfiles, readUserProfile } from './core/profile-store.js'
import { describeProxyState } from './core/proxy.js'
import { SUPPORTED_VERSIONS } from './core/releases.js'
import { acceptsHttpsUrl, parseHttpsUrl } from './core/url.js'
import { setVerboseWriter, vlog } from './core/verbose.js'
import { setVerboseWriter, uwarn, vlog } from './core/verbose.js'
import { ErrorCodes, UcpError } from './lib/errors.js'
import { omitUndefined } from './lib/omit-undefined.js'
import type { CtaBlock, Transport } from './lib/types.js'
Expand Down Expand Up @@ -526,6 +527,13 @@ export function createUcpCli(deps: UcpCliDependencies = {}) {
...(c.options.dryRun ? { dryRun: true } : {}),
_onDiscover: (d) => {
discovered = d
if (bodyKey === 'checkout') {
for (const method of unsupportedFulfillmentMethods(d, merged)) {
uwarn(
`checkout requested fulfillment method "${method}" is not in the merchant's declared allows_method_combinations; the merchant may drop it. The call will still be sent. Check the returned fulfillment and buyer state.`,
)
}
}
},
})
// Dry-run short-circuits the regular envelope: no escalation/CTA
Expand Down
53 changes: 53 additions & 0 deletions src/cli/fulfillment-warning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { DiscoveredBusiness } from '../core/discover.js'
import { unsupportedFulfillmentMethods } from './fulfillment-warning.js'

const fulfillment = 'dev.ucp.shopping.fulfillment'
const combination = (methods: unknown) => ({
version: '2026-08-25',
config: { allows_method_combinations: methods },
})
function discovery(entries: unknown): DiscoveredBusiness {
return {
profile: { ucp: { capabilities: { [fulfillment]: entries } } },
protocol: { version: '2026-08-25' },
} as DiscoveredBusiness
}
function checkout(types: unknown[]): Record<string, unknown> {
return { checkout: { fulfillment: { methods: types.map((type) => ({ type })) } } }
}

describe('unsupportedFulfillmentMethods', () => {
it('identifies pickup absent from shipping-only combinations, without flagging shipping', () => {
expect(
unsupportedFulfillmentMethods(
discovery([combination([['shipping']])]),
checkout(['shipping', 'pickup', 'pickup']),
),
).toEqual(['pickup'])
})
it('uses the union of declared combinations, including mixed choices', () => {
const d = discovery([combination([['shipping'], ['shipping', 'pickup']])])
expect(unsupportedFulfillmentMethods(d, checkout(['pickup', 'shipping']))).toEqual([])
expect(unsupportedFulfillmentMethods(d, checkout(['courier', 'pickup']))).toEqual(['courier'])
})
it('does not infer an unsupported method from absent, malformed, ambiguous, or other-version config', () => {
for (const entries of [
undefined,
[],
[combination([])],
[combination([['shipping'], [1]])],
[combination([['shipping']]), combination([['pickup']])],
[{ version: '2026-04-08', config: { allows_method_combinations: [['shipping']] } }],
]) {
expect(unsupportedFulfillmentMethods(discovery(entries), checkout(['pickup']))).toEqual([])
}
})
it('ignores non-checkout payloads', () => {
expect(
unsupportedFulfillmentMethods(discovery([combination([['shipping']])]), {
cart: { fulfillment: { methods: [{ type: 'pickup' }] } },
}),
).toEqual([])
})
})
44 changes: 44 additions & 0 deletions src/cli/fulfillment-warning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Advisory only: discovery predicts merchant capabilities, but the server's
// response is authoritative. Never suppress or rewrite a checkout call here.
import type { DiscoveredBusiness } from '../core/discover.js'

const FULFILLMENT = 'dev.ucp.shopping.fulfillment'

function record(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}

/** Return only method types definitely absent from an unambiguous declaration. */
export function unsupportedFulfillmentMethods(
discovered: DiscoveredBusiness,
input: Record<string, unknown>,
): string[] {
const checkout = record(input.checkout)
const fulfillment = record(checkout?.fulfillment)
const methods = record(fulfillment)?.methods
if (!Array.isArray(methods)) return []

const entries = record(discovered.profile.ucp.capabilities)?.[FULFILLMENT]
if (!Array.isArray(entries)) return []
const matching = entries.filter((entry) => record(entry)?.version === discovered.protocol.version)
// Several matching entries can disagree. Do not claim absence in that case.
if (matching.length !== 1) return []
const combinations = record(record(matching[0])?.config)?.allows_method_combinations
if (!Array.isArray(combinations) || combinations.length === 0) return []
const supported = new Set<string>()
for (const combination of combinations) {
if (!Array.isArray(combination) || combination.length === 0) return []
for (const type of combination) {
if (typeof type !== 'string' || type.length === 0) return []
supported.add(type)
}
}
const requested = new Set<string>()
for (const method of methods) {
const type = record(method)?.type
if (typeof type === 'string' && type.length > 0 && !supported.has(type)) requested.add(type)
}
return [...requested]
}