Skip to content

Commit 7ab6f92

Browse files
authored
Merge pull request #3617 from Shopify/lopert.runs-selector
Add replay command
2 parents 5f485dc + a5364d1 commit 7ab6f92

File tree

15 files changed

+583
-16
lines changed

15 files changed

+583
-16
lines changed

docs-shopify.dev/commands/interfaces/app-function-run.interface.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface appfunctionrun {
77
'-c, --config <value>'?: string
88

99
/**
10-
* Name of the wasm export to invoke.
10+
* Name of the WebAssembly export to invoke.
1111
* @environment SHOPIFY_FLAG_EXPORT
1212
*/
1313
'-e, --export <value>'?: string

docs-shopify.dev/generated/generated_docs_data.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -859,7 +859,7 @@
859859
"syntaxKind": "PropertySignature",
860860
"name": "-e, --export <value>",
861861
"value": "string",
862-
"description": "Name of the wasm export to invoke.",
862+
"description": "Name of the WebAssembly export to invoke.",
863863
"isOptional": true,
864864
"environmentValue": "SHOPIFY_FLAG_EXPORT"
865865
},
@@ -882,7 +882,7 @@
882882
"environmentValue": "SHOPIFY_FLAG_JSON"
883883
}
884884
],
885-
"value": "export interface appfunctionrun {\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Name of the wasm export to invoke.\n * @environment SHOPIFY_FLAG_EXPORT\n */\n '-e, --export <value>'?: string\n\n /**\n * The input JSON to pass to the function. If omitted, standard input is used.\n * @environment SHOPIFY_FLAG_INPUT\n */\n '-i, --input <value>'?: string\n\n /**\n * Log the run result as a JSON object.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your function directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Increase the verbosity of the logs.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
885+
"value": "export interface appfunctionrun {\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Name of the WebAssembly export to invoke.\n * @environment SHOPIFY_FLAG_EXPORT\n */\n '-e, --export <value>'?: string\n\n /**\n * The input JSON to pass to the function. If omitted, standard input is used.\n * @environment SHOPIFY_FLAG_INPUT\n */\n '-i, --input <value>'?: string\n\n /**\n * Log the run result as a JSON object.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your function directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Increase the verbosity of the logs.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
886886
}
887887
}
888888
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {functionFlags, inFunctionContext} from '../../../services/function/common.js'
2+
import {replay} from '../../../services/function/replay.js'
3+
import {appFlags} from '../../../flags.js'
4+
import {showApiKeyDeprecationWarning} from '../../../prompts/deprecation-warnings.js'
5+
import {environmentVariableNames} from '../../../constants.js'
6+
import Command from '@shopify/cli-kit/node/base-command'
7+
import {globalFlags} from '@shopify/cli-kit/node/cli'
8+
import {Flags} from '@oclif/core'
9+
import {getEnvironmentVariables} from '@shopify/cli-kit/node/environment'
10+
import {isTruthy} from '@shopify/cli-kit/node/context/utilities'
11+
12+
export default class FunctionReplay extends Command {
13+
static hidden = true
14+
static summary = 'Replays a function run from an app log.'
15+
16+
static descriptionWithMarkdown = `Runs the function from your current directory for [testing purposes](https://shopify.dev/docs/apps/functions/testing-and-debugging). To learn how you can monitor and debug functions when errors occur, refer to [Shopify Functions error handling](https://shopify.dev/docs/api/functions/errors).`
17+
18+
static description = this.descriptionWithoutMarkdown()
19+
20+
static flags = {
21+
...globalFlags,
22+
...appFlags,
23+
...functionFlags,
24+
'api-key': Flags.string({
25+
hidden: true,
26+
description: "Application's API key",
27+
env: 'SHOPIFY_FLAG_API_KEY',
28+
exclusive: ['config'],
29+
}),
30+
'client-id': Flags.string({
31+
hidden: false,
32+
description: "Application's Client ID",
33+
env: 'SHOPIFY_FLAG_CLIENT_ID',
34+
exclusive: ['config'],
35+
}),
36+
export: Flags.string({
37+
char: 'e',
38+
hidden: false,
39+
description: 'Name of the WebAssembly export to invoke.',
40+
default: '_start',
41+
env: 'SHOPIFY_FLAG_EXPORT',
42+
}),
43+
json: Flags.boolean({
44+
char: 'j',
45+
hidden: false,
46+
description: 'Output the function run result as a JSON object.',
47+
env: 'SHOPIFY_FLAG_JSON',
48+
}),
49+
}
50+
51+
public async run() {
52+
const env = getEnvironmentVariables()
53+
const logPollingEnabled = isTruthy(env[environmentVariableNames.enableAppLogPolling])
54+
55+
if (!logPollingEnabled) {
56+
throw new Error(
57+
'This command is not released yet. You can experiment with it by setting SHOPIFY_CLI_ENABLE_APP_LOG_POLLING=1 in your env.',
58+
)
59+
}
60+
61+
const {flags} = await this.parse(FunctionReplay)
62+
if (flags['api-key']) {
63+
await showApiKeyDeprecationWarning()
64+
}
65+
const apiKey = flags['client-id'] || flags['api-key']
66+
67+
await inFunctionContext({
68+
path: flags.path,
69+
userProvidedConfigName: flags.config,
70+
callback: async (app, ourFunction) => {
71+
await replay({
72+
app,
73+
extension: ourFunction,
74+
apiKey,
75+
stdout: flags.stdout,
76+
path: flags.path,
77+
json: flags.json,
78+
export: flags.export,
79+
})
80+
},
81+
})
82+
}
83+
}

packages/app/src/cli/commands/app/function/run.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export default class FunctionRun extends Command {
2424
export: Flags.string({
2525
char: 'e',
2626
hidden: false,
27-
description: 'Name of the wasm export to invoke.',
27+
description: 'Name of the WebAssembly export to invoke.',
2828
default: '_start',
2929
env: 'SHOPIFY_FLAG_EXPORT',
3030
}),

packages/app/src/cli/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import DraftExtensionsPush from './commands/app/draft-extensions/push.js'
77
import EnvPull from './commands/app/env/pull.js'
88
import EnvShow from './commands/app/env/show.js'
99
import FunctionBuild from './commands/app/function/build.js'
10+
import FunctionReplay from './commands/app/function/replay.js'
1011
import FunctionRun from './commands/app/function/run.js'
1112
import FetchSchema from './commands/app/function/schema.js'
1213
import FunctionTypegen from './commands/app/function/typegen.js'
@@ -38,6 +39,7 @@ export const commands = {
3839
'app:env:show': EnvShow,
3940
'app:generate:schema': GenerateSchema,
4041
'app:function:build': FunctionBuild,
42+
'app:function:replay': FunctionReplay,
4143
'app:function:run': FunctionRun,
4244
'app:function:schema': FetchSchema,
4345
'app:function:typegen': FunctionTypegen,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {selectFunctionRunPrompt} from './replay.js'
2+
import {FunctionRunData} from '../../services/function/replay.js'
3+
import {describe, expect, vi, test} from 'vitest'
4+
import {renderAutocompletePrompt} from '@shopify/cli-kit/node/ui'
5+
6+
vi.mock('@shopify/cli-kit/node/ui')
7+
8+
const RUN1: FunctionRunData = {
9+
shop_id: 69665030382,
10+
api_client_id: 124042444801,
11+
payload: {
12+
input: '{}',
13+
input_bytes: 136,
14+
output: '{}',
15+
output_bytes: 195,
16+
function_id: '34236fa9-42f5-4bb6-adaf-956e12fff0b0',
17+
logs: '',
18+
fuel_consumed: 458206,
19+
},
20+
event_type: 'function_run',
21+
cursor: '2024-05-31T15:29:47.291530Z',
22+
status: 'success',
23+
log_timestamp: '2024-05-31T15:29:46.741270Z',
24+
identifier: 'abcdef',
25+
}
26+
27+
const RUN2: FunctionRunData = {
28+
shop_id: 69665030382,
29+
api_client_id: 124042444801,
30+
payload: {
31+
input: '{}',
32+
input_bytes: 136,
33+
output: '{}',
34+
output_bytes: 195,
35+
function_id: '34236fa9-42f5-4bb6-adaf-956e12fff0b0',
36+
logs: '',
37+
fuel_consumed: 458206,
38+
},
39+
event_type: 'function_run',
40+
cursor: '2024-05-31T15:29:47.291530Z',
41+
status: 'success',
42+
log_timestamp: '2024-05-31T15:29:46.741270Z',
43+
identifier: 'abc123',
44+
}
45+
46+
describe('selectFunctionRun', () => {
47+
test('returns run if user selects one', async () => {
48+
// Given
49+
const runs = [RUN1, RUN2]
50+
vi.mocked(renderAutocompletePrompt).mockResolvedValue(RUN2)
51+
52+
// When
53+
const got = await selectFunctionRunPrompt(runs)
54+
55+
// Then
56+
expect(got).toEqual(RUN2)
57+
expect(renderAutocompletePrompt).toHaveBeenCalledWith({
58+
message: 'Which function run would you like to replay locally?',
59+
choices: [
60+
{label: `${RUN1.log_timestamp} (${RUN1.status}) - ${RUN1.identifier}`, value: RUN1},
61+
{label: `${RUN2.log_timestamp} (${RUN2.status}) - ${RUN2.identifier}`, value: RUN2},
62+
],
63+
})
64+
})
65+
66+
test('returns undefined if no runs', async () => {
67+
// Given
68+
const runs: FunctionRunData[] = []
69+
70+
// When
71+
const got = await selectFunctionRunPrompt(runs)
72+
73+
// Then
74+
expect(got).toEqual(undefined)
75+
})
76+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {FunctionRunData} from '../../services/function/replay.js'
2+
import {renderAutocompletePrompt} from '@shopify/cli-kit/node/ui'
3+
4+
export async function selectFunctionRunPrompt(functionRuns: FunctionRunData[]): Promise<FunctionRunData | undefined> {
5+
if (functionRuns.length === 0) return undefined
6+
const toAnswer = (functionRun: FunctionRunData) => {
7+
return {
8+
label: `${functionRun.log_timestamp} (${functionRun.status}) - ${functionRun.identifier}`,
9+
value: functionRun,
10+
}
11+
}
12+
13+
const functionRunsList = functionRuns.map(toAnswer)
14+
15+
const selectedRun = await renderAutocompletePrompt({
16+
message: 'Which function run would you like to replay locally?',
17+
choices: functionRunsList,
18+
})
19+
return selectedRun
20+
}

packages/app/src/cli/services/app-logs/write-app-logs.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@ describe('writeAppLogsToFile', () => {
2929
const logData = expectedLogDataFromAppEvent(APP_LOG)
3030

3131
// determine the fileName and path
32-
const fileName = `app_logs_${APP_LOG.log_timestamp}.json`
32+
const fileName = '20240522_150641_827Z'
3333
const path = joinPath(API_KEY, fileName)
3434

3535
// When
3636
await writeAppLogsToFile({appLog: APP_LOG, apiKey: API_KEY, stdout})
3737

3838
// Then
39-
expect(writeLog).toHaveBeenCalledWith(path, logData)
39+
expect(writeLog).toHaveBeenCalledWith(expect.stringContaining(path), logData)
4040
expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Log: '))
4141
})
4242

packages/app/src/cli/services/app-logs/write-app-logs.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {AppEventData} from './poll-app-logs.js'
22
import {joinPath} from '@shopify/cli-kit/node/path'
33
import {writeLog, getLogsDir} from '@shopify/cli-kit/node/logs'
4-
4+
import {randomUUID} from '@shopify/cli-kit/node/crypto'
55
import {Writable} from 'stream'
66

77
export const writeAppLogsToFile = async ({
@@ -13,9 +13,12 @@ export const writeAppLogsToFile = async ({
1313
apiKey: string
1414
stdout: Writable
1515
}) => {
16-
const fileName = `app_logs_${appLog.log_timestamp}.json`
16+
const identifier = randomUUID().substring(0, 6)
17+
18+
const formattedTimestamp = formatTimestampToFilename(appLog.log_timestamp)
19+
const fileName = `${formattedTimestamp}_${identifier}.json`
1720
const path = joinPath(apiKey, fileName)
18-
const fullOutputPath = joinPath(getLogsDir, path)
21+
const fullOutputPath = joinPath(getLogsDir(), path)
1922

2023
try {
2124
const toSaveData = {
@@ -32,3 +35,15 @@ export const writeAppLogsToFile = async ({
3235
throw error
3336
}
3437
}
38+
39+
function formatTimestampToFilename(logTimestamp: string): string {
40+
const date = new Date(logTimestamp)
41+
const year = date.getUTCFullYear()
42+
const month = (date.getUTCMonth() + 1).toString().padStart(2, '0')
43+
const day = date.getUTCDate().toString().padStart(2, '0')
44+
const hours = date.getUTCHours().toString().padStart(2, '0')
45+
const minutes = date.getUTCMinutes().toString().padStart(2, '0')
46+
const seconds = date.getUTCSeconds().toString().padStart(2, '0')
47+
const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0')
48+
return `${year}${month}${day}_${hours}${minutes}${seconds}_${milliseconds}Z`
49+
}

0 commit comments

Comments
 (0)