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
46 changes: 39 additions & 7 deletions src/commands/app/pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ const DEFAULTS = {
DEPLOY_YAML_FILE_NAME: 'deploy.yaml'
}

// matches Node process warnings, e.g.
// (node:1234) [DEP0040] DeprecationWarning: The `punycode` module is deprecated.
// (Use `node --trace-deprecation ...` to show where the warning was created)
const NODE_WARNING_LINE = /^\(node:\d+\)|^\(Use `node --trace-/

/**
* Removes Node process warning lines from a child process' stderr.
*
* @param {string} stderr the raw stderr of a child process
* @returns {string} the stderr with any Node process warnings removed
*/
function stripNodeWarnings (stderr) {
return (stderr ?? '')
.split('\n')
.filter(line => !NODE_WARNING_LINE.test(line))
.join('\n')
.trim()
}

class Pack extends BaseCommand {
async run () {
const { args, flags } = await this.parse(Pack)
Expand Down Expand Up @@ -195,21 +214,34 @@ class Pack extends BaseCommand {
if (command) {
try {
this.spinner.start('Getting api-mesh config...')
const { stdout, stderr } = await execa('aio', ['api-mesh', 'get', '--json'], { cwd: process.cwd() })

if (stderr) {
throw new Error(stderr)
// the child's stderr is used below to detect the "no mesh" case, so Node process
// warnings (e.g. DEP0040 punycode) must not pollute it. NODE_NO_WARNINGS is used
// rather than NODE_OPTIONS so that a user-set NODE_OPTIONS is preserved (execa
// merges `env` with process.env by default).
const { stdout, stderr } = await execa('aio', ['api-mesh', 'get', '--json'], {
cwd: process.cwd(),
env: { NODE_NO_WARNINGS: '1' }
})

// defensively strip any Node process-warning lines that still reach stderr
const meshStderr = stripNodeWarnings(stderr)

if (meshStderr) {
throw new Error(meshStderr)
}

meshConfig = JSON.parse(stdout).meshConfig
aioLogger.debug(`api-mesh:get - ${JSON.stringify(meshConfig, null, 2)}`)
this.spinner.succeed('Got api-mesh config')
} catch (err) {
// Ignore error if no mesh found, otherwise throw
if (err?.message.includes('Error: Unable to get mesh config.')) {
// Ignore error if no mesh found, otherwise throw.
// Thrown execa errors carry the child's output on `stderr`, which is not always
// reflected in `message` (and `message` is absent entirely for non-Error throws).
const details = [err?.message, err?.stderr].filter(Boolean).join('\n')
if (details.includes('Unable to get mesh config.')) {
aioLogger.debug('No api-mesh config found')
} else {
console.error(err)
aioLogger.debug(`api-mesh:get failed - ${details || err}`)
throw err
}
}
Expand Down
92 changes: 91 additions & 1 deletion test/commands/app/pack.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,97 @@ test('createDeployYamlFile (1 extension), no api-mesh, plugin throws error', asy
}
})

await expect(command.createDeployYamlFile(extConfig)).rejects.toEqual(TypeError('Cannot read properties of undefined (reading \'includes\')'))
// the thrown value carries the "no mesh" message on `stderr` and has no `message`,
// so it must be treated as "no mesh found" rather than crashing on `message.includes`
await command.createDeployYamlFile(extConfig)

await expect(importHelper.writeFile.mock.calls[0][0]).toMatch(path.join('dist', 'app-package', 'deploy.yaml'))
await expect(importHelper.writeFile.mock.calls[0][1]).toMatchFixture('pack/2.deploy.no-mesh.yaml')
await expect(importHelper.writeFile.mock.calls[0][2]).toMatchObject({ overwrite: true })
})

test('createDeployYamlFile (1 extension), api-mesh stderr has Node process warnings', async () => {
const extConfig = fixtureJson('pack/2.all.config.json')
const meshOutput = fixtureFile('pack/3.api-mesh.get.json')

const command = new TheCommand()
command.argv = []
command.config = {
findCommand: jest.fn().mockReturnValue({}),
runCommand: jest.fn(),
runHook: jest.fn().mockResolvedValue({ successes: [] })
}

execa.mockImplementationOnce((cmd, args, opts) => {
expect(cmd).toEqual('aio')
expect(args).toEqual(['api-mesh', 'get', '--json'])
// Node process warnings must be suppressed in the child
expect(opts.env).toMatchObject({ NODE_NO_WARNINGS: '1' })

return {
stdout: meshOutput,
stderr: [
'(node:12345) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.',
'(Use `node --trace-deprecation ...` to show where the warning was created)'
].join('\n')
}
})

// warnings on stderr are not errors: the mesh config must still be picked up
await command.createDeployYamlFile(extConfig)

await expect(importHelper.writeFile.mock.calls[0][0]).toMatch(path.join('dist', 'app-package', 'deploy.yaml'))
await expect(importHelper.writeFile.mock.calls[0][1]).toMatchFixture('pack/2.deploy.yaml')
await expect(importHelper.writeFile.mock.calls[0][2]).toMatchObject({ overwrite: true })
})

test('createDeployYamlFile (1 extension), api-mesh real error mixed with Node process warnings', async () => {
const extConfig = fixtureJson('pack/2.all.config.json')

const command = new TheCommand()
command.argv = []
command.config = {
findCommand: jest.fn().mockReturnValue({}),
runCommand: jest.fn(),
runHook: jest.fn().mockResolvedValue({ successes: [] })
}

execa.mockImplementationOnce((cmd, args) => {
expect(cmd).toEqual('aio')
expect(args).toEqual(['api-mesh', 'get', '--json'])

return {
stderr: [
'(node:12345) [DEP0040] DeprecationWarning: The `punycode` module is deprecated.',
'Error: api-mesh service is unavailable'
].join('\n')
}
})

// the warning is stripped, the real error is still surfaced
await expect(command.createDeployYamlFile(extConfig)).rejects.toEqual(Error('Error: api-mesh service is unavailable'))
})

test('createDeployYamlFile (1 extension), api-mesh throws a value with no message or stderr', async () => {
const extConfig = fixtureJson('pack/2.all.config.json')

const command = new TheCommand()
command.argv = []
command.config = {
findCommand: jest.fn().mockReturnValue({}),
runCommand: jest.fn(),
runHook: jest.fn().mockResolvedValue({ successes: [] })
}

execa.mockImplementationOnce((cmd, args) => {
expect(cmd).toEqual('aio')
expect(args).toEqual(['api-mesh', 'get', '--json'])
// eslint-disable-next-line no-throw-literal
throw { code: 'ENOENT' }
})

// must rethrow the original value, not crash while inspecting it
await expect(command.createDeployYamlFile(extConfig)).rejects.toEqual({ code: 'ENOENT' })
})

test('createDeployYamlFile (1 extension), api-mesh get call throws non 404 error', async () => {
Expand Down