Skip to content

Commit cbb5184

Browse files
Merge pull request #8574 from Shopify/gonzalo/json-app-logs-sources
Add typed JSON output to app logs sources
2 parents fb6e8fc + 4254977 commit cbb5184

12 files changed

Lines changed: 449 additions & 48 deletions

File tree

‎docs-shopify.dev/generated/generated_docs_data_v2.json‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2837,9 +2837,18 @@
28372837
"description": "The name of the app configuration.",
28382838
"isOptional": true,
28392839
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
2840+
},
2841+
{
2842+
"filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts",
2843+
"syntaxKind": "PropertySignature",
2844+
"name": "-j, --json",
2845+
"value": "''",
2846+
"description": "Output the result as JSON. Automatically disables color output.",
2847+
"isOptional": true,
2848+
"environmentValue": "SHOPIFY_FLAG_JSON"
28402849
}
28412850
],
2842-
"value": "export interface applogssources {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
2851+
"value": "export interface applogssources {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
28432852
}
28442853
},
28452854
"applogs": {
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import {testAppLinked, testFunctionExtension} from '../../../models/app/app.test-data.js'
2+
import {AppErrors} from '../../../models/app/loader.js'
3+
import {Config} from '@oclif/core'
4+
import {afterEach, expect, test, vi} from 'vitest'
5+
import {formatSection} from '@shopify/cli-kit/node/output'
6+
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
7+
// eslint-disable-next-line n/prefer-global/console
8+
import {Console} from 'node:console'
9+
10+
vi.mock('../../../services/app-context.js')
11+
12+
const originalUnitTestEnvironment = process.env.SHOPIFY_UNIT_TEST
13+
14+
afterEach(() => {
15+
if (originalUnitTestEnvironment === undefined) {
16+
delete process.env.SHOPIFY_UNIT_TEST
17+
} else {
18+
process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnvironment
19+
}
20+
mockAndCaptureOutput().clear()
21+
vi.resetModules()
22+
})
23+
24+
// Captures the real standard streams so JSON and text output are proven at the process boundary.
25+
function captureStandardStreams() {
26+
const stdout: string[] = []
27+
const stderr: string[] = []
28+
29+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => {
30+
stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
31+
return true
32+
}) as typeof process.stdout.write)
33+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => {
34+
stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
35+
return true
36+
}) as typeof process.stderr.write)
37+
// Vitest intercepts console.warn; use Node's console to exercise the captured streams.
38+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(new Console(process.stdout, process.stderr).warn)
39+
40+
return {
41+
stdout: () => stdout.join(''),
42+
stderr: () => stderr.join(''),
43+
restore: () => {
44+
warnSpy.mockRestore()
45+
stdoutSpy.mockRestore()
46+
stderrSpy.mockRestore()
47+
},
48+
}
49+
}
50+
51+
async function runCommand(argv: string[], app = testAppLinked()) {
52+
const {linkedAppContext} = await import('../../../services/app-context.js')
53+
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
54+
const {default: Sources} = await import('./sources.js')
55+
return new Sources(argv, await Config.load()).run()
56+
}
57+
58+
test('writes one JSON document to stdout without terminal text', async () => {
59+
process.env.SHOPIFY_UNIT_TEST = 'false'
60+
vi.resetModules()
61+
const app = testAppLinked({
62+
allExtensions: [
63+
await testFunctionExtension({config: {...(await testFunctionExtension()).configuration, handle: 'discount'}}),
64+
],
65+
})
66+
const streams = captureStandardStreams()
67+
try {
68+
await expect(runCommand(['--json'], app)).resolves.toEqual({app})
69+
} finally {
70+
streams.restore()
71+
}
72+
const extension = app.allExtensions[0]!
73+
expect(JSON.parse(streams.stdout())).toEqual([
74+
{
75+
source: 'extensions.discount',
76+
namespace: 'extensions',
77+
handle: 'discount',
78+
name: extension.configuration.name,
79+
type: extension.type,
80+
externalType: extension.externalType,
81+
humanName: extension.humanName,
82+
uid: extension.uid,
83+
directory: extension.directory,
84+
configurationPath: extension.configurationPath,
85+
configuration: extension.configuration,
86+
entrySourceFilePath: extension.entrySourceFilePath,
87+
outputPath: extension.outputPath,
88+
surface: extension.surface,
89+
features: extension.features,
90+
...(extension.dependency === undefined ? {} : {dependency: extension.dependency}),
91+
},
92+
])
93+
expect(streams.stderr()).toBe('')
94+
})
95+
96+
test('writes an empty JSON array when no sources exist', async () => {
97+
process.env.SHOPIFY_UNIT_TEST = 'false'
98+
vi.resetModules()
99+
const streams = captureStandardStreams()
100+
try {
101+
await runCommand(['--json'])
102+
} finally {
103+
streams.restore()
104+
}
105+
expect(streams.stdout()).toBe('[]\n')
106+
expect(streams.stderr()).toBe('')
107+
})
108+
109+
test('keeps namespace sections on stdout in text mode', async () => {
110+
process.env.SHOPIFY_UNIT_TEST = 'false'
111+
vi.resetModules()
112+
const app = testAppLinked({
113+
allExtensions: [
114+
await testFunctionExtension({config: {...(await testFunctionExtension()).configuration, handle: 'discount'}}),
115+
],
116+
})
117+
const streams = captureStandardStreams()
118+
try {
119+
await runCommand([], app)
120+
} finally {
121+
streams.restore()
122+
}
123+
expect(streams.stdout()).toBe(`${formatSection('extensions', 'extensions.discount')}\n`)
124+
expect(streams.stderr()).toBe('')
125+
})
126+
127+
test.each([{argv: []}, {argv: ['--json']}])(
128+
'exits with status 2 before output for invalid apps (%j)',
129+
async ({argv}) => {
130+
const errors = new AppErrors()
131+
errors.addError({file: 'shopify.app.toml', message: 'Invalid app'})
132+
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
133+
throw new Error('exit')
134+
})
135+
try {
136+
await expect(runCommand(argv, testAppLinked({errors}))).rejects.toThrow('exit')
137+
expect(exit).toHaveBeenCalledWith(2)
138+
expect(mockAndCaptureOutput().output()).toBe('')
139+
} finally {
140+
exit.mockRestore()
141+
}
142+
},
143+
)
144+
145+
test('exposes the schema in help', async () => {
146+
const {default: Sources} = await import('./sources.js')
147+
const {appLogSourcesJsonOutputSchema} = await import('../../../services/app-logs/sources/types.js')
148+
expect(Sources.jsonOutputSchema).toBe(appLogSourcesJsonOutputSchema)
149+
expect(Sources.descriptionForHelp()).toContain('`AppLogSourcesResult` schema')
150+
})

‎packages/app/src/cli/commands/app/app-logs/sources.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
import {appFlags} from '../../../flags.js'
22
import {linkedAppContext} from '../../../services/app-context.js'
33
import {sources} from '../../../services/app-logs/sources.js'
4+
import {appLogSourcesJsonOutputSchema} from '../../../services/app-logs/sources/types.js'
5+
import {renderAppLogSourcesResult} from '../../../services/app-logs/sources/result.js'
46
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
5-
import {globalFlags} from '@shopify/cli-kit/node/cli'
7+
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
68

79
export default class Sources extends AppLinkedCommand {
810
static summary = 'Print out a list of sources that may be used with the logs command.'
911

1012
static descriptionWithMarkdown = `The output source names can be used with the \`--source\` argument of \`shopify app logs\` to filter log output. Currently only function extensions are supported as sources.`
1113

14+
static get jsonOutputSchema() {
15+
return appLogSourcesJsonOutputSchema
16+
}
17+
1218
static description = this.descriptionForHelp()
1319

1420
static flags = {
1521
...globalFlags,
1622
...appFlags,
23+
...jsonFlag,
1724
}
1825

1926
public async run(): Promise<AppLinkedCommandOutput> {
@@ -27,7 +34,7 @@ export default class Sources extends AppLinkedCommand {
2734
})
2835

2936
if (app.errors.isEmpty()) {
30-
sources(app)
37+
renderAppLogSourcesResult(sources(app), flags.json ? 'json' : 'text')
3138
} else {
3239
process.exit(2)
3340
}
Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,41 @@
1-
import {sourcesForApp} from './utils.js'
21
import {sources} from './sources.js'
3-
import {testApp} from '../../models/app/app.test-data.js'
4-
import {outputResult, formatSection} from '@shopify/cli-kit/node/output'
5-
import {describe, test, vi, expect} from 'vitest'
6-
7-
vi.mock('@shopify/cli-kit/node/output')
8-
vi.mock('./utils.js')
9-
10-
describe('sources', () => {
11-
test('prints sources by namespace', async () => {
12-
// Given
13-
vi.mocked(sourcesForApp).mockReturnValue(['extensions.source1', 'extensions.source2', 'arbitrary.text'])
14-
vi.mocked(formatSection).mockReturnValue('formatted section')
2+
import {sourcesForApp} from './utils.js'
3+
import {testApp, testFunctionExtension, testUIExtension} from '../../models/app/app.test-data.js'
4+
import {expect, test} from 'vitest'
155

16-
// When
17-
sources(testApp())
6+
test('returns function sources in extension order', async () => {
7+
const first = await testFunctionExtension({
8+
config: {...(await testFunctionExtension()).configuration, handle: 'first'},
9+
})
10+
const second = await testFunctionExtension({
11+
config: {...(await testFunctionExtension()).configuration, handle: 'second'},
12+
})
13+
const ui = await testUIExtension()
1814

19-
// Then
20-
expect(formatSection).toHaveBeenCalledWith('extensions', 'extensions.source1\nextensions.source2')
21-
expect(formatSection).toHaveBeenCalledWith('arbitrary', 'arbitrary.text')
22-
expect(outputResult).toHaveBeenCalledWith('formatted section')
15+
const app = testApp({allExtensions: [first, ui, second]})
16+
const result = sources(app)
17+
expect(result.map(({source}) => source)).toEqual(sourcesForApp(app))
18+
expect(result.map(({source}) => source)).toEqual(['extensions.first', 'extensions.second'])
19+
expect(result[0]).toEqual({
20+
source: 'extensions.first',
21+
namespace: 'extensions',
22+
handle: 'first',
23+
name: first.configuration.name,
24+
type: first.type,
25+
externalType: first.externalType,
26+
humanName: first.humanName,
27+
uid: first.uid,
28+
directory: first.directory,
29+
configurationPath: first.configurationPath,
30+
configuration: first.configuration,
31+
entrySourceFilePath: first.entrySourceFilePath,
32+
outputPath: first.outputPath,
33+
surface: first.surface,
34+
features: first.features,
35+
dependency: first.dependency,
2336
})
2437
})
38+
39+
test('returns an empty collection without function extensions', () => {
40+
expect(sources(testApp())).toEqual([])
41+
})
Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
1-
import {sourcesForApp} from './utils.js'
1+
import {AppLogSourcesResult} from './sources/types.js'
22
import {AppInterface} from '../../models/app/app.js'
3-
import {formatSection, outputResult} from '@shopify/cli-kit/node/output'
43

5-
export function sources(app: AppInterface) {
6-
const sources = sourcesForApp(app)
7-
const sourcesByNamespace = new Map<string, string[]>()
8-
sources.forEach((source) => {
9-
const tokens = source.split('.')
10-
11-
if (tokens.length >= 2) {
12-
const sourceNamespace = tokens[0]!
13-
14-
if (!sourcesByNamespace.has(sourceNamespace)) {
15-
sourcesByNamespace.set(sourceNamespace, [])
16-
}
17-
18-
sourcesByNamespace.set(sourceNamespace, [...sourcesByNamespace.get(sourceNamespace)!, source])
19-
}
20-
})
21-
22-
for (const [namespace, sources] of sourcesByNamespace) {
23-
outputResult(formatSection(namespace, sources.join('\n')))
24-
}
4+
export function sources(app: AppInterface): AppLogSourcesResult {
5+
return app.allExtensions
6+
.filter((extension) => extension.isFunctionExtension)
7+
.map((extension) => ({
8+
source: `extensions.${extension.configuration.handle}`,
9+
namespace: 'extensions',
10+
handle: extension.handle,
11+
name: extension.name,
12+
type: extension.type,
13+
externalType: extension.externalType,
14+
humanName: extension.humanName,
15+
uid: extension.uid,
16+
directory: extension.directory,
17+
configurationPath: extension.configurationPath,
18+
configuration: extension.configuration,
19+
entrySourceFilePath: extension.entrySourceFilePath,
20+
outputPath: extension.outputPath,
21+
surface: extension.surface,
22+
features: extension.features,
23+
dependency: extension.dependency,
24+
}))
2525
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {renderAppLogSourcesResult} from './result.js'
2+
import {sources} from '../sources.js'
3+
import {testApp, testFunctionExtension} from '../../../models/app/app.test-data.js'
4+
import {outputResult, formatSection} from '@shopify/cli-kit/node/output'
5+
import {describe, test, vi, expect} from 'vitest'
6+
7+
vi.mock('@shopify/cli-kit/node/output')
8+
9+
describe('sources', () => {
10+
test('prints sources by namespace', async () => {
11+
// Given
12+
vi.mocked(formatSection).mockReturnValue('formatted section')
13+
14+
// When
15+
const extension = await testFunctionExtension()
16+
const result = sources(testApp({allExtensions: [extension]}))[0]!
17+
renderAppLogSourcesResult(
18+
[
19+
{...result, source: 'extensions.source1'},
20+
{...result, source: 'extensions.source2'},
21+
],
22+
'text',
23+
)
24+
25+
// Then
26+
expect(formatSection).toHaveBeenCalledWith('extensions', 'extensions.source1\nextensions.source2')
27+
expect(outputResult).toHaveBeenCalledWith('formatted section')
28+
})
29+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {appLogSourcesJsonOutputSchema, type AppLogSourcesResult} from './types.js'
2+
import {formatSection, outputResult} from '@shopify/cli-kit/node/output'
3+
4+
export function renderAppLogSourcesResult(result: AppLogSourcesResult, format: 'json' | 'text'): void {
5+
if (format === 'json') {
6+
outputResult(appLogSourcesJsonOutputSchema.encode(result))
7+
return
8+
}
9+
10+
const sourcesByNamespace = new Map<string, string[]>()
11+
result.forEach(({source}) => {
12+
const tokens = source.split('.')
13+
14+
if (tokens.length >= 2) {
15+
const sourceNamespace = tokens[0]!
16+
17+
if (!sourcesByNamespace.has(sourceNamespace)) {
18+
sourcesByNamespace.set(sourceNamespace, [])
19+
}
20+
21+
sourcesByNamespace.set(sourceNamespace, [...sourcesByNamespace.get(sourceNamespace)!, source])
22+
}
23+
})
24+
25+
for (const [namespace, sources] of sourcesByNamespace) {
26+
outputResult(formatSection(namespace, sources.join('\n')))
27+
}
28+
}

0 commit comments

Comments
 (0)