Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/normalize-migration-submission-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/cli": major
---

Normalize migration submission JSON with outcome statuses, changed, GID fields, flattened results, fieldPath errors, and successful cancellation results.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import List from './list.js'
import Schedule from './schedule.js'
import Status from './status.js'
import Unschedule from './unschedule.js'
import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.js'
import {appFlags} from '../../../flags.js'
import {commands} from '../../../index.js'
import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js'
Expand All @@ -11,6 +12,7 @@ import {cancelMigrationOperations} from '../../../services/subscription-migratio
import {
migrationCancellationJsonOutputSchema,
migrationListJsonOutputSchema,
migrationSubmissionJsonOutputSchema,
} from '../../../services/subscription-migrations/types.js'
import {outputOperations} from '../../../services/subscription-migrations/command-output.js'
import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js'
Expand All @@ -22,8 +24,10 @@ import {outputResult} from '@shopify/cli-kit/node/output'
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import type {MigrationOperation} from '../../../models/subscription-migrations.js'
import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js'
import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js'
import type {
MigrationCancellationResult,
MigrationSubmissionResult,
} from '../../../services/subscription-migrations/types.js'

vi.mock('../../../services/app-context.js')
vi.mock('../../../services/subscription-migrations/cancel-operations.js', async (importOriginal) => ({
Expand Down Expand Up @@ -117,7 +121,7 @@ describe('subscription migration submission commands', () => {
})
expect(outputResult).toHaveBeenCalledOnce()
expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual(
successfulSubmissionResult.submission,
projectMigrationSubmissionResult(successfulSubmissionResult),
)
expect(result).toEqual({app})
})
Expand Down Expand Up @@ -197,10 +201,9 @@ describe('subscription migration submission commands', () => {

expect(process.exitCode).toBe(1)
expect(outputResult).toHaveBeenCalledOnce()
expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({
...failedResult.submission,
failure: failedResult.failure,
})
expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual(
projectMigrationSubmissionResult(failedResult),
)
expect(renderWarning).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -462,6 +465,12 @@ describe('subscription migration command metadata', () => {
expect(List.description).toContain('```json')
})

test('schedule exposes and documents the submission JSON output schema', () => {
expect(Schedule.jsonOutputSchema).toBe(migrationSubmissionJsonOutputSchema)
expect(Schedule.description).toContain('`MigrationSubmissionResult` schema')
expect(Schedule.description).toContain('```json')
})

test('cancel exposes its JSON output schema', () => {
expect(Cancel.jsonOutputSchema).toBe(migrationCancellationJsonOutputSchema)
})
Expand Down Expand Up @@ -585,7 +594,7 @@ describe('subscription migration command metadata', () => {
},
)

test.each([Schedule, Unschedule, Status])('$name has no fenced-code markers in its plain description', (Command) => {
test.each([Unschedule, Status])('$name has no fenced-code markers in its plain description', (Command) => {
expect(Command.description).not.toContain('```')
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js'
import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js'
import {
migrationCancellationJsonOutputSchema,
migrationSubmissionJsonOutputSchema,
} from '../../../services/subscription-migrations/types.js'
import {describe, expect, test} from 'vitest'
import type {MigrationOperation} from '../../../models/subscription-migrations.js'
import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js'
import type {
MigrationCancellationResult,
MigrationSubmission,
MigrationSubmissionResult,
} from '../../../services/subscription-migrations/submit-migration-plan.js'
} from '../../../services/subscription-migrations/types.js'

function operation(id: string): MigrationOperation {
return {id, status: 'RUNNING', total: 1, results: {edges: []}}
Expand All @@ -29,69 +32,78 @@ function submission(): MigrationSubmission {
}

describe('subscription migration result codecs', () => {
test('encodes a successful submission with the existing JSON shape', () => {
const value = submission()
const result: MigrationSubmissionResult = {status: 'success', submission: value}

const document = encodeMigrationSubmissionResult(result)
const publicSubmission = {
clientId: 'client-id',
action: 'schedule',
inputDigest: 'input-digest',
total: 1,
operations: [
{
batchIndex: 0,
batchPayloadDigest: 'batch-digest',
operation: {
gid: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one',
status: 'RUNNING',
total: 1,
results: [],
},
},
],
}

expect(JSON.parse(document)).toEqual(value)
expect(document).toBe(JSON.stringify(value, null, 2))
test('encodes a successful submission with the public projection', () => {
const document = encodeMigrationSubmissionResult({status: 'success', submission: submission()})
const expected = {status: 'success', changed: true, ...publicSubmission}
expect(JSON.parse(document)).toEqual(expected)
expect(document).toBe(JSON.stringify(expected, null, 2))
expect(document).not.toContain('idempotencyKey')
})

test('encodes accepted submission evidence and failure details in one JSON document', () => {
const value = submission()
test('retains accepted work and projects submission diagnostics', () => {
const result: MigrationSubmissionResult = {
status: 'failed',
submission: value,
submission: submission(),
failure: {
type: 'submission',
batchIndex: 1,
userErrors: [{message: 'Rejected remaining shops', field: ['input']}],
},
}

const document = encodeMigrationSubmissionResult(result)

expect(JSON.parse(document)).toEqual({
...value,
expect(JSON.parse(encodeMigrationSubmissionResult(result))).toEqual({
status: 'partial',
changed: true,
...publicSubmission,
failure: {
type: 'submission',
batchIndex: 1,
userErrors: [{message: 'Rejected remaining shops', field: ['input']}],
userErrors: [{message: 'Rejected remaining shops', fieldPath: ['input']}],
},
})
})

test('encodes terminal operation failure evidence in one JSON document', () => {
test('retains terminal upstream failure states inside the resource', () => {
const value = submission()
value.operations[0]!.operation = {...value.operations[0]!.operation, status: 'FAILED'}
value.operations[0]!.operation.status = 'FAILED'
const result: MigrationSubmissionResult = {
status: 'failed',
submission: value,
failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']},
failure: {
type: 'operations',
operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one'],
},
}

const document = encodeMigrationSubmissionResult(result)

expect(JSON.parse(document)).toEqual({
...value,
failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']},
})
expect(document).toBe(
JSON.stringify(
expect(JSON.parse(encodeMigrationSubmissionResult(result))).toEqual({
status: 'partial',
changed: true,
...publicSubmission,
operations: [
{
...value,
failure: {
type: 'operations',
operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one'],
},
...publicSubmission.operations[0],
operation: {...publicSubmission.operations[0]!.operation, status: 'FAILED'},
},
null,
2,
),
)
],
failure: {type: 'operations', operationGids: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']},
})
})

test('rejects cancellation documents with an invalid outcome', () => {
Expand Down Expand Up @@ -215,3 +227,47 @@ describe('subscription migration result codecs', () => {
)
})
})

describe('migration submission JSON contract', () => {
test('every generated JSON Schema reference resolves within the document', () => {
const schema = migrationSubmissionJsonOutputSchema.jsonSchema

JSON.parse(JSON.stringify(schema), (key, value: unknown) => {
if (key === '$ref' && typeof value === 'string') {
const referencedSchema = value
.slice(2)
.split('/')
.map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'))
.reduce<unknown>(
(node, part) =>
typeof node === 'object' && node !== null ? (node as Record<string, unknown>)[part] : undefined,
schema,
)
expect(referencedSchema, `Unresolved JSON Schema reference: ${value}`).not.toBeUndefined()
}
return value
})
})

test('reports an empty successful submission as an unchanged object', () => {
const value = {...submission(), total: 0, operations: []}
expect(JSON.parse(encodeMigrationSubmissionResult({status: 'success', submission: value}))).toEqual({
status: 'success',
changed: false,
...value,
})
})

test('rejects invalid public submission fields', () => {
const valid = JSON.parse(encodeMigrationSubmissionResult({status: 'success', submission: submission()}))
for (const fields of [
{action: 'cancel'},
{total: '1'},
{total: -1},
{unexpected: true},
{operations: [{batchIndex: -1}]},
]) {
expect(() => migrationSubmissionJsonOutputSchema.validate({...valid, ...fields})).toThrow()
}
})
})
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import {
migrationCancellationJsonOutputSchema,
migrationSubmissionJsonOutputSchema,
type MigrationCancellationResult,
type MigrationSubmissionResult,
} from '../../../services/subscription-migrations/types.js'
import {
projectMigrationOperation,
projectMigrationSubmissionResult,
projectMigrationUserErrors,
} from '../../../services/subscription-migrations/result-codec.js'
import {errorToJson} from '@shopify/cli-kit/node/error/serialization'
import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js'

export function encodeMigrationSubmissionResult(result: MigrationSubmissionResult): string {
const document =
result.status === 'success'
? result.submission
: {
...result.submission,
failure: result.failure,
}
return JSON.stringify(document, null, 2)
return migrationSubmissionJsonOutputSchema.encode(projectMigrationSubmissionResult(result))
}

export function encodeMigrationCancellationResult(result: MigrationCancellationResult): string {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {presentMigrationCancellationResult} from './result-presenter.js'
import {presentMigrationCancellationResult, presentMigrationSubmissionResult} from './result-presenter.js'
import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js'
import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {beforeEach, describe, expect, test, vi} from 'vitest'
import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js'
Expand Down Expand Up @@ -141,3 +142,42 @@ describe('migration cancellation JSON output', () => {
},
)
})

describe('migration submission JSON output', () => {
test.each([false, true])('writes one final failure document with watch=%s', (watch) => {
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const submission = {
clientId: 'client-id',
action: 'schedule' as const,
inputDigest: 'input-digest',
total: 2,
operations: [
{
batchIndex: 0,
batchPayloadDigest: 'batch-digest',
operation: {
id: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one',
status: 'RUNNING' as const,
total: 1,
results: {edges: []},
},
},
],
}
const failure = {type: 'submission' as const, batchIndex: 1, userErrors: [{message: 'Rejected', field: null}]}

try {
expect(presentMigrationSubmissionResult({status: 'failed', submission, failure}, {json: true, watch})).toBe(1)

expect(stdout).toHaveBeenCalledOnce()
expect(stdout.mock.calls[0]?.[0]).toBe(
`${JSON.stringify(projectMigrationSubmissionResult({status: 'failed', submission, failure}), null, 2)}\n`,
)
expect(stderr).not.toHaveBeenCalled()
} finally {
stdout.mockRestore()
stderr.mockRestore()
}
})
})
Loading
Loading