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-status-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/cli": major
---

Normalize migration status JSON with gid and shopGid identifiers and flat per-shop results.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
migrationCancellationJsonOutputSchema,
migrationListJsonOutputSchema,
migrationSubmissionJsonOutputSchema,
migrationStatusJsonOutputSchema,
} 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 Down Expand Up @@ -292,6 +293,16 @@ describe('subscription migration operation commands', () => {
expect(watchMigrationOperations).not.toHaveBeenCalled()
})

test('status reports failed operations without changing the command exit code', async () => {
const failedOperation = {...completedOperation, status: 'FAILED' as const}
vi.mocked(getMigrationOperations).mockResolvedValue([failedOperation])

await expect(Status.run(['--id', failedOperation.id, '--json'])).resolves.toEqual({app})

expect(outputOperations).toHaveBeenCalledWith([failedOperation], true)
expect(process.exitCode).toBeUndefined()
})

test('cancel presents every repeated ID in exactly one JSON document', async () => {
const secondOperation = {...completedOperation, id: 'gid://shopify/AppSubscriptionMigrationOperation/2'}
const result: MigrationCancellationResult = {
Expand Down Expand Up @@ -471,6 +482,12 @@ describe('subscription migration command metadata', () => {
expect(Schedule.description).toContain('```json')
})

test('status exposes and documents its JSON output schema', () => {
expect(Status.jsonOutputSchema).toBe(migrationStatusJsonOutputSchema)
expect(Status.description).toContain('`MigrationStatusResult` schema')
expect(Status.description).toContain('```json')
})

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

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

test('cancel documents its JSON output schema', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
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 {
projectMigrationOperation,
projectMigrationSubmissionResult,
} from '../../../services/subscription-migrations/result-codec.js'
import {outputOperations} from '../../../services/subscription-migrations/command-output.js'
import {watchMigrationOperations} from '../../../services/subscription-migrations/watch-operations.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
import {beforeEach, describe, expect, test, vi} from 'vitest'
// eslint-disable-next-line n/prefer-global/console
import {Console} from 'node:console'
import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js'

const isUnitTest = vi.hoisted(() => vi.fn(() => false))
Expand Down Expand Up @@ -181,3 +189,63 @@ describe('migration submission JSON output', () => {
}
})
})

describe('migration status JSON output', () => {
test('writes the final result to stdout and typed watch progress to stderr', async () => {
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
// Use Node's console so Vitest's console capture does not bypass stderr.
const warn = vi.spyOn(console, 'warn').mockImplementation(new Console(process.stdout, process.stderr).warn)
const running = {
id: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one',
status: 'RUNNING' as const,
total: 1,
results: {edges: []},
}
const completed = {...running, status: 'COMPLETED' as const}

try {
await runWithCommandEventsForCommand(['--json'], async () => {
const operations = await watchMigrationOperations({
clientId: 'client-id',
operationIds: [running.id],
waitForOperations: async ({onUpdate}) => {
await onUpdate?.([running])
await onUpdate?.([completed])
expect(stdout).not.toHaveBeenCalled()
return [completed]
},
})
outputOperations(operations, true)
})

expect(stdout).toHaveBeenCalledOnce()
expect(stdout.mock.calls[0]?.[0]).toBe(
`${JSON.stringify({operations: [projectMigrationOperation(completed)]}, null, 2)}\n`,
)
const events = stderr.mock.calls.map(([content]) => JSON.parse(content as string))
expect(events).toEqual([
expect.objectContaining({
type: 'progress',
status: 'started',
message: 'Polling subscription migration operations',
}),
expect.objectContaining({
type: 'progress',
status: 'updated',
message: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one: RUNNING (0/1 settled)',
}),
expect.objectContaining({
type: 'progress',
status: 'updated',
message: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one: COMPLETED (0/1 settled)',
}),
expect.objectContaining({type: 'progress', status: 'completed'}),
])
} finally {
warn.mockRestore()
stdout.mockRestore()
stderr.mockRestore()
}
})
})
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {statusFlags} from './flags.js'
import {migrationStatusJsonOutputSchema} from '../../../services/subscription-migrations/types.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {outputOperations} from '../../../services/subscription-migrations/command-output.js'
import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js'
import {watchMigrationOperations} from '../../../services/subscription-migrations/watch-operations.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {jsonFlag} from '@shopify/cli-kit/node/cli'

export default class Status extends AppLinkedCommand {
static summary = 'Checks the status of app subscription migration operations.'
Expand All @@ -18,15 +20,19 @@ Use \`--json\` to output every operation and its per-shop results as structured

Run the command from an app project. By default, it uses the Client ID from the active app configuration. Use \`--path\` to select an app directory or \`--config\` to select a configuration. Pass \`--client-id\` to select a different app within the project. Use \`--reset\` to relink the app.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static examples = [
'<%= config.bin %> <%= command.id %> --id <operation-id>',
'<%= config.bin %> <%= command.id %> --path ../my-app --config staging --id <operation-id-1> --id <operation-id-2> --watch',
'<%= config.bin %> <%= command.id %> --client-id <client-id> --id <operation-id> --json',
]

static flags = {...statusFlags}
static flags = {...statusFlags, ...jsonFlag}

static get jsonOutputSchema() {
return migrationStatusJsonOutputSchema
}

async run(): Promise<AppLinkedCommandOutput> {
const {flags} = await this.parse(Status)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {projectMigrationOperation} from './result-codec.js'
import {migrationStatusJsonOutputSchema} from './types.js'
import {formatMigrationOperationsStatus, outputOperations} from './command-output.js'
import {outputResult} from '@shopify/cli-kit/node/output'
import {renderInfo} from '@shopify/cli-kit/node/ui'
Expand All @@ -12,7 +14,7 @@ vi.mock('@shopify/cli-kit/node/ui')

function operation(id: string, status: MigrationOperation['status'] = 'RUNNING'): MigrationOperation {
return {
id,
id: `gid://shopify/AppSubscriptionMigrationOperation/${id}`,
status,
total: 2,
results: {edges: [{node: {shopId: 'gid://shopify/Shop/1', code: 'SCHEDULED'}}]},
Expand All @@ -29,7 +31,7 @@ describe('operation command output', () => {
const operations = [operation('one', 'COMPLETED'), operation('two', 'RUNNING')]

expect(formatMigrationOperationsStatus(operations)).toBe(
'one: COMPLETED (1/2 settled) · two: RUNNING (1/2 settled)',
'gid://shopify/AppSubscriptionMigrationOperation/one: COMPLETED (1/2 settled) · gid://shopify/AppSubscriptionMigrationOperation/two: RUNNING (1/2 settled)',
)
})

Expand All @@ -39,10 +41,12 @@ describe('operation command output', () => {
outputOperations(operations, true)

expect(outputResult).toHaveBeenCalledOnce()
expect(outputResult).toHaveBeenCalledWith(JSON.stringify({operations}, null, 2))
expect(outputResult).toHaveBeenCalledWith(
JSON.stringify({operations: operations.map(projectMigrationOperation)}, null, 2),
)
const jsonDocument = vi.mocked(outputResult).mock.calls[0]?.[0]
if (typeof jsonDocument !== 'string') throw new Error('Expected operations output to be one JSON document')
expect(JSON.parse(jsonDocument)).toEqual({operations})
expect(JSON.parse(jsonDocument)).toEqual({operations: operations.map(projectMigrationOperation)})
expect(renderInfo).not.toHaveBeenCalled()
})

Expand All @@ -53,8 +57,37 @@ describe('operation command output', () => {

expect(renderInfo).toHaveBeenCalledWith({
headline: 'Subscription migration operations.',
body: ['one: COMPLETED (1/2 settled)', 'two: RUNNING (1/2 settled)'],
body: [
'gid://shopify/AppSubscriptionMigrationOperation/one: COMPLETED (1/2 settled)',
'gid://shopify/AppSubscriptionMigrationOperation/two: RUNNING (1/2 settled)',
],
})
expect(outputResult).not.toHaveBeenCalled()
})
})

describe('migration status JSON contract', () => {
test('encodes an empty operation list', () => {
outputOperations([], true)
expect(outputResult).toHaveBeenCalledWith(JSON.stringify({operations: []}, null, 2))
})

test('accepts future upstream resource statuses and result codes', () => {
const value = {
...projectMigrationOperation(operation('one')),
status: 'QUEUED',
results: [{shopGid: 'gid://shopify/Shop/1', code: 'FUTURE_CODE'}],
}
expect(migrationStatusJsonOutputSchema.validate({operations: [value]})).toEqual({operations: [value]})
})

test.each([
{...projectMigrationOperation(operation('one')), total: '2'},
{...projectMigrationOperation(operation('one')), total: -1},
{...projectMigrationOperation(operation('one')), unexpected: true},
{...projectMigrationOperation(operation('one')), results: [{shopGid: 'shop-one', code: 'SCHEDULED'}]},
{...projectMigrationOperation(operation('one')), results: null},
])('rejects invalid operations: %j', (value) => {
expect(() => migrationStatusJsonOutputSchema.validate({operations: [value]})).toThrow()
})
})
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {projectMigrationOperation} from './result-codec.js'
import {migrationStatusJsonOutputSchema} from './types.js'
import {outputResult} from '@shopify/cli-kit/node/output'
import {renderInfo} from '@shopify/cli-kit/node/ui'
import type {MigrationOperation} from '../../models/subscription-migrations.js'
Expand All @@ -8,7 +10,7 @@ export function formatMigrationOperationsStatus(operations: MigrationOperation[]

export function outputOperations(operations: MigrationOperation[], json: boolean): void {
if (json) {
outputResult(JSON.stringify({operations}, null, 2))
outputResult(migrationStatusJsonOutputSchema.encode({operations: operations.map(projectMigrationOperation)}))
return
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type {MigrationSubmissionResult, MigrationSubmissionJsonOutput} from './types.js'
import type {MigrationSubmissionResult, MigrationSubmissionJsonOutput, MigrationStatusResult} from './types.js'
import type {MigratableSubscription, MigrationOperation} from '../../models/subscription-migrations.js'
import type {MigrationUserError} from './partners-api.js'

export function projectMigrationOperation(operation: MigrationOperation) {
export function projectMigrationOperation(operation: MigrationOperation): MigrationStatusResult['operations'][number] {
return {
gid: operation.id,
status: operation.status,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,11 @@ export type MigrationSubmissionResult =
| {status: 'success'; submission: MigrationSubmission}
| {status: 'failed'; submission: MigrationSubmission; failure: MigrationSubmissionFailure}
| {status: 'cancelled'; changed: false; action: 'schedule' | 'unschedule'; reason: string}

export const migrationStatusJsonOutputSchema = defineJsonOutputSchema({
name: 'MigrationStatusResult',
schema: zod.object({operations: zod.array(MigrationOperationSchema)}).strict(),
definitions: {MigrationOperation: MigrationOperationSchema},
})

export type MigrationStatusResult = InferJsonOutputSchema<typeof migrationStatusJsonOutputSchema>
75 changes: 75 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5159,6 +5159,81 @@ DESCRIPTION
to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app
within the project. Use `--reset` to relink the app.

Use `--json-schema` to print the result, error, and event schemas.

Output from `--json` conforms to the `MigrationStatusResult` schema.

```json
{
"type": "object",
"properties": {
"operations": {
"type": "array",
"items": {
"$ref": "#/definitions/MigrationOperation"
}
}
},
"required": [
"operations"
],
"additionalProperties": false,
"title": "MigrationStatusResult",
"definitions": {
"MigrationOperation": {
"type": "object",
"properties": {
"gid": {
"type": "string",
"pattern": "^gid:\\/\\/shopify\\/AppSubscriptionMigrationOperation\\/[^/]+$",
"description": "The Shopify AppSubscriptionMigrationOperation GID."
},
"status": {
"type": "string",
"minLength": 1,
"description": "Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED."
},
"total": {
"type": "integer",
"minimum": 0
},
"results": {
"type": "array",
"items": {
"type": "object",
"properties": {
"shopGid": {
"type": "string",
"pattern": "^gid:\\/\\/shopify\\/Shop\\/\\d+$",
"description": "The Shopify Shop GID."
},
"code": {
"type": "string",
"minLength": 1,
"description": "The upstream per-shop migration result code."
}
},
"required": [
"shopGid",
"code"
],
"additionalProperties": false
}
}
},
"required": [
"gid",
"status",
"total",
"results"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}
```

EXAMPLES
$ shopify app subscription-migrations status --id <operation-id>

Expand Down
Loading
Loading