diff --git a/bin/pos-cli-exec-graphql.js b/bin/pos-cli-exec-graphql.js new file mode 100644 index 00000000..f0d84fc6 --- /dev/null +++ b/bin/pos-cli-exec-graphql.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +const { program } = require('commander'); +const prompts = require('prompts'); +const Gateway = require('../lib/proxy'); +const fetchAuthData = require('../lib/settings').fetchSettings; +const logger = require('../lib/logger'); + +const isProductionEnvironment = (environment) => { + return environment && (environment.toLowerCase().includes('prod') || environment.toLowerCase().includes('production')); +}; + +const confirmProductionExecution = async (environment) => { + logger.Warn(`WARNING: You are executing GraphQL on a production environment: ${environment}`); + logger.Warn('This could potentially modify production data or cause unintended side effects.'); + logger.Warn(''); + + const response = await prompts({ + type: 'confirm', + name: 'confirmed', + message: `Are you sure you want to continue executing on ${environment}?`, + initial: false + }); + + return response.confirmed; +}; + +program + .name('pos-cli exec graphql') + .argument('', 'name of environment. Example: staging') + .argument('', 'graphql query to execute as string') + .action(async (environment, graphql) => { + const authData = fetchAuthData(environment, program); + const gateway = new Gateway(authData); + + if (isProductionEnvironment(environment)) { + const confirmed = await confirmProductionExecution(environment); + if (!confirmed) { + logger.Info('Execution cancelled.'); + process.exit(0); + } + } + + try { + const response = await gateway.graph({ query: graphql }); + + if (response.errors) { + logger.Error(`GraphQL execution error: ${JSON.stringify(response.errors, null, 2)}`); + process.exit(1); + } + + if (response.data) { + logger.Print(JSON.stringify(response, null, 2)); + } + } catch (error) { + logger.Error(`Failed to execute graphql: ${error.message}`); + process.exit(1); + } + }); + +program.parse(process.argv); \ No newline at end of file diff --git a/bin/pos-cli-exec-liquid.js b/bin/pos-cli-exec-liquid.js new file mode 100644 index 00000000..0947277f --- /dev/null +++ b/bin/pos-cli-exec-liquid.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +const { program } = require('commander'); +const prompts = require('prompts'); +const Gateway = require('../lib/proxy'); +const fetchAuthData = require('../lib/settings').fetchSettings; +const logger = require('../lib/logger'); + +const isProductionEnvironment = (environment) => { + return environment && (environment.toLowerCase().includes('prod') || environment.toLowerCase().includes('production')); +}; + +const confirmProductionExecution = async (environment) => { + logger.Warn(`WARNING: You are executing liquid code on a production environment: ${environment}`); + logger.Warn('This could potentially modify production data or cause unintended side effects.'); + logger.Warn(''); + + const response = await prompts({ + type: 'confirm', + name: 'confirmed', + message: `Are you sure you want to continue executing on ${environment}?`, + initial: false + }); + + return response.confirmed; +}; + +program + .name('pos-cli exec liquid') + .argument('', 'name of environment. Example: staging') + .argument('', 'liquid code to execute as string') + .action(async (environment, code) => { + const authData = fetchAuthData(environment, program); + const gateway = new Gateway(authData); + + if (isProductionEnvironment(environment)) { + const confirmed = await confirmProductionExecution(environment); + if (!confirmed) { + logger.Info('Execution cancelled.'); + process.exit(0); + } + } + + try { + const response = await gateway.liquid({ content: code }); + + if (response.error) { + logger.Error(`Liquid execution error: ${response.error}`); + process.exit(1); + } + + if (response.result) { + logger.Print(response.result); + } + } catch (error) { + logger.Error(`Failed to execute liquid: ${error.message}`); + process.exit(1); + } + }); + +program.parse(process.argv); \ No newline at end of file diff --git a/bin/pos-cli-exec.js b/bin/pos-cli-exec.js new file mode 100644 index 00000000..e4df94fb --- /dev/null +++ b/bin/pos-cli-exec.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +const { program } = require('commander'); + +program + .name('pos-cli exec') + .command('liquid ', 'execute liquid code on instance') + .command('graphql ', 'execute graphql query on instance') + .parse(process.argv); \ No newline at end of file diff --git a/bin/pos-cli.js b/bin/pos-cli.js index 78d4d467..1463d9b9 100755 --- a/bin/pos-cli.js +++ b/bin/pos-cli.js @@ -24,6 +24,7 @@ program .command('data', 'export, import or clean data on instance') .command('deploy ', 'deploy code to environment').alias('d') .command('env', 'manage environments') + .command('exec', 'execute code on instance') .command('gui', 'gui for content editor, graphql, logs') .command('generate', 'generates files') .command('init', 'initialize directory structure') diff --git a/test/deploy.test.js b/test/deploy.test.js index 73ecdd38..5dabe827 100644 --- a/test/deploy.test.js +++ b/test/deploy.test.js @@ -105,7 +105,7 @@ describe('Server errors', () => { test('Error in form', async () => { const { stderr } = await run('incorrect_form'); expect(stderr).toMatch( - 'Unknown properties: hello. Available properties are: api_call_notifications, async_callback_actions, authorization_policies, body, callback_actions, default_payload, email_notifications, fields, flash_alert, flash_notice, live_reindex, metadata, name, redirect_to, request_allowed, resource, resource_owner, response_headers, return_to, sms_notifications, spam_protection.' + 'Unknown properties in `form_configurations/hello.liquid`: hello. Available properties are: api_call_notifications, async_callback_actions, authorization_policies, body, callback_actions, default_payload, email_notifications, fields, flash_alert, flash_notice, live_reindex, metadata, name, redirect_to, request_allowed, resource, resource_owner, response_headers, return_to, sms_notifications, spam_protection.' ); }); diff --git a/test/exec-graphql.test.js b/test/exec-graphql.test.js new file mode 100644 index 00000000..2763a5a9 --- /dev/null +++ b/test/exec-graphql.test.js @@ -0,0 +1,131 @@ +jest.mock('../lib/apiRequest', () => ({ + apiRequest: jest.fn() +})); + +const Gateway = require('../lib/proxy'); + +describe('Gateway graph method', () => { + const { apiRequest } = require('../lib/apiRequest'); + + test('calls apiRequest with correct parameters', async () => { + const mockResponse = { + "data": { + "records": { + "results": [] + } + } + }; + apiRequest.mockResolvedValue(mockResponse); + + const gateway = new Gateway({ url: 'http://example.com', token: '1234', email: 'test@example.com' }); + const query = '{ records(per_page: 20) { results { id } } }'; + const result = await gateway.graph({ query }); + + expect(apiRequest).toHaveBeenCalledWith({ + method: 'POST', + uri: 'http://example.com/api/graph', + json: { query }, + forever: true, + request: expect.any(Function) + }); + expect(result).toEqual(mockResponse); + }); + + test('handles graphql execution error', async () => { + const mockErrorResponse = { + errors: [ + { + message: 'Syntax Error: Expected Name, found ', + locations: [{ line: 1, column: 40 }] + } + ] + }; + apiRequest.mockResolvedValue(mockErrorResponse); + + const gateway = new Gateway({ url: 'http://example.com', token: '1234', email: 'test@example.com' }); + const query = '{ records(per_page: 20) { results { id } '; // Missing closing brace + const result = await gateway.graph({ query }); + + expect(result).toEqual(mockErrorResponse); + }); +}); + +describe('exec graphql CLI', () => { + const exec = require('./utils/exec'); + const cliPath = require('./utils/cliPath'); + + const env = Object.assign(process.env, { + CI: true, + MPKIT_URL: 'http://example.com', + MPKIT_TOKEN: '1234', + MPKIT_EMAIL: 'foo@example.com' + }); + + test('requires graphql argument', async () => { + const { code, stderr } = await exec(`${cliPath} exec graphql staging`, { env }); + + expect(code).toEqual(1); + expect(stderr).toMatch("error: missing required argument 'graphql'"); + }); + + test('cancels execution on production environment when user says no', async () => { + const { code, stdout, stderr } = await exec(`echo "n" | ${cliPath} exec graphql production "{ records { results { id } } }"`, { env }); + + expect(code).toEqual(0); + expect(stdout).toMatch('Execution cancelled.'); + }); + + test('proceeds with execution on production environment when user confirms', async () => { + const { code, stdout, stderr } = await exec(`echo "y" | ${cliPath} exec graphql production "{ records { results { id } } }"`, { env }); + + // This will fail because the mock API isn't set up, but we want to check it doesn't cancel + expect(stdout).not.toMatch('Execution cancelled.'); + expect(stderr).not.toMatch('Execution cancelled.'); + }); + + test('does not prompt for non-production environments', async () => { + const { code, stdout, stderr } = await exec(`${cliPath} exec graphql staging "{ records { results { id } } }"`, { env }); + + expect(stdout).not.toMatch('WARNING: You are executing GraphQL on a production environment'); + expect(stdout).not.toMatch('Execution cancelled.'); + }); +}); + +// Integration test - requires real platformOS instance +describe('exec graphql integration', () => { + const exec = require('./utils/exec'); + const cliPath = require('./utils/cliPath'); + + // Only run if real credentials are available + const hasRealCredentials = process.env.MPKIT_URL && + process.env.MPKIT_TOKEN && + !process.env.MPKIT_URL.includes('example.com'); + + (hasRealCredentials ? test : test.skip)('executes graphql query on real instance', async () => { + const query = '{ records(per_page: 20) { results { id } } }'; + const { stdout, stderr, code } = await exec(`${cliPath} exec graphql dev "${query}"`, { + env: process.env, + timeout: 30000 + }); + + expect(code).toEqual(0); + expect(stderr).toBe(''); + + // Parse JSON response + const response = JSON.parse(stdout); + expect(response).toHaveProperty('data'); + expect(response.data).toHaveProperty('records'); + expect(Array.isArray(response.data.records.results)).toBe(true); + }, 30000); + + (hasRealCredentials ? test : test.skip)('handles graphql syntax error on real instance', async () => { + const invalidQuery = '{ records(per_page: 20) { results { id } '; // Missing closing brace + const { stdout, stderr, code } = await exec(`${cliPath} exec graphql dev "${invalidQuery}"`, { + env: process.env, + timeout: 30000 + }); + + expect(code).toEqual(1); + expect(stderr).toMatch('GraphQL execution error'); + }, 30000); +}); \ No newline at end of file diff --git a/test/exec-liquid.test.js b/test/exec-liquid.test.js new file mode 100644 index 00000000..a8f64449 --- /dev/null +++ b/test/exec-liquid.test.js @@ -0,0 +1,142 @@ +jest.mock('../lib/apiRequest', () => ({ + apiRequest: jest.fn() +})); + +const Gateway = require('../lib/proxy'); + +describe('Gateway liquid method', () => { + const { apiRequest } = require('../lib/apiRequest'); + + test('calls apiRequest with correct parameters', async () => { + apiRequest.mockResolvedValue({ result: 'HELLO WORLD', error: null }); + + const gateway = new Gateway({ url: 'http://example.com', token: '1234', email: 'test@example.com' }); + const result = await gateway.liquid({ content: "{{ 'hello world' | upcase }}" }); + + expect(apiRequest).toHaveBeenCalledWith({ + method: 'POST', + uri: 'http://example.com/api/app_builder/liquid_exec', + json: { content: "{{ 'hello world' | upcase }}" }, + forever: true, + request: expect.any(Function) + }); + expect(result).toEqual({ result: 'HELLO WORLD', error: null }); + }); + + test('handles liquid execution error', async () => { + apiRequest.mockResolvedValue({ result: null, error: 'Liquid syntax error' }); + + const gateway = new Gateway({ url: 'http://example.com', token: '1234', email: 'test@example.com' }); + const result = await gateway.liquid({ content: "{{ 'hello world' | invalid_filter }}" }); + + expect(result).toEqual({ result: null, error: 'Liquid syntax error' }); + }); +}); + +describe('exec liquid CLI', () => { + const exec = require('./utils/exec'); + const cliPath = require('./utils/cliPath'); + + const env = Object.assign(process.env, { + CI: true, + MPKIT_URL: 'http://example.com', + MPKIT_TOKEN: '1234', + MPKIT_EMAIL: 'foo@example.com' + }); + + test('requires code argument', async () => { + const { code, stderr } = await exec(`${cliPath} exec liquid staging`, { env }); + + expect(code).toEqual(1); + expect(stderr).toMatch("error: missing required argument 'code'"); + }); + + test('cancels execution on production environment when user says no', async () => { + const { code, stdout, stderr } = await exec(`echo "n" | ${cliPath} exec liquid production "{{ 'hello' | upcase }}"`, { env }); + + expect(code).toEqual(0); + expect(stdout).toMatch('Execution cancelled.'); + }); + + test('proceeds with execution on production environment when user confirms', async () => { + const { code, stdout, stderr } = await exec(`echo "y" | ${cliPath} exec liquid production "{{ 'hello' | upcase }}"`, { env }); + + // This will fail because the mock API isn't set up, but we want to check it doesn't cancel + expect(stdout).not.toMatch('Execution cancelled.'); + expect(stderr).not.toMatch('Execution cancelled.'); + }); + + test('does not prompt for non-production environments', async () => { + const { code, stdout, stderr } = await exec(`${cliPath} exec liquid staging "{{ 'hello' | upcase }}"`, { env }); + + expect(stdout).not.toMatch('WARNING: You are executing liquid code on a production environment'); + expect(stdout).not.toMatch('Execution cancelled.'); + }); +}); + +// Integration test - requires real platformOS instance +describe('exec liquid integration', () => { + const exec = require('./utils/exec'); + const cliPath = require('./utils/cliPath'); + + // Only run if real credentials are available + const hasRealCredentials = process.env.MPKIT_URL && + process.env.MPKIT_TOKEN && + !process.env.MPKIT_URL.includes('example.com'); + + (hasRealCredentials ? test : test.skip)('executes liquid code on real instance', async () => { + const { stdout, stderr, code } = await exec(`${cliPath} exec liquid dev "{{ 'hello' | upcase }}"`, { + env: process.env, + timeout: 30000 + }); + + expect(code).toEqual(0); + expect(stdout).toMatch('HELLO'); + expect(stderr).toBe(''); + }, 30000); + + (hasRealCredentials ? test : test.skip)('handles liquid syntax error on real instance', async () => { + const { stdout, stderr, code } = await exec(`${cliPath} exec liquid dev "{{ 'hello' | invalid_filter }}"`, { + env: process.env, + timeout: 30000 + }); + + expect(code).toEqual(1); + expect(stderr).toMatch('Liquid execution error'); + }, 30000); + + (hasRealCredentials ? test : test.skip)('executes {{ \'now\' | to_time }} and returns current time', async () => { + const beforeTime = new Date(); + const { stdout, stderr, code } = await exec(`${cliPath} exec liquid dev "{{ 'now' | to_time }}"`, { + env: process.env, + timeout: 30000 + }); + const afterTime = new Date(); + + expect(code).toEqual(0); + expect(stderr).toBe(''); + + // Parse the returned time - liquid to_time returns ISO format like "2023-01-01 12:00:00 +0000" + const returnedTimeStr = stdout.trim(); + const returnedTime = new Date(returnedTimeStr); + + // Check that the returned time is within 1 second of the current time + const timeDiff = Math.abs(returnedTime.getTime() - beforeTime.getTime()); + expect(timeDiff).toBeLessThanOrEqual(1000); // 1 second in milliseconds + + // Also check it's not in the future beyond our test window + const futureDiff = afterTime.getTime() - returnedTime.getTime(); + expect(futureDiff).toBeGreaterThanOrEqual(0); + expect(futureDiff).toBeLessThanOrEqual(1000); + }, 30000); + + (hasRealCredentials ? test : test.skip)('handles unknown tag error', async () => { + const { stdout, stderr, code } = await exec(`${cliPath} exec liquid dev "{% hello %}"`, { + env: process.env, + timeout: 30000 + }); + + expect(code).toEqual(1); + expect(stderr).toMatch('Liquid execution error: Liquid syntax error: Unknown tag \'hello\''); + }, 30000); +}); diff --git a/test/fixtures/deploy/correct_with_assets/app/assets/bar.js b/test/fixtures/deploy/correct_with_assets/app/assets/bar.js new file mode 100644 index 00000000..a693db7f --- /dev/null +++ b/test/fixtures/deploy/correct_with_assets/app/assets/bar.js @@ -0,0 +1 @@ +// Test asset file diff --git a/test/fixtures/deploy/modules_update/app/pos-modules.json b/test/fixtures/deploy/modules_update/app/pos-modules.json index 970703eb..55aee05b 100644 --- a/test/fixtures/deploy/modules_update/app/pos-modules.json +++ b/test/fixtures/deploy/modules_update/app/pos-modules.json @@ -1,5 +1,5 @@ { "modules": { - "core": "1.5.5" + "core": "2.0.7" } } \ No newline at end of file diff --git a/test/fixtures/deploy/modules_update/app/pos-modules.lock.json b/test/fixtures/deploy/modules_update/app/pos-modules.lock.json index 970703eb..55aee05b 100644 --- a/test/fixtures/deploy/modules_update/app/pos-modules.lock.json +++ b/test/fixtures/deploy/modules_update/app/pos-modules.lock.json @@ -1,5 +1,5 @@ { "modules": { - "core": "1.5.5" + "core": "2.0.7" } } \ No newline at end of file diff --git a/test/sync.test.js b/test/sync.test.js index d90f5738..4edb9c63 100644 --- a/test/sync.test.js +++ b/test/sync.test.js @@ -3,6 +3,7 @@ const exec = require('./utils/exec'); const cliPath = require('./utils/cliPath'); const path = require('path'); +const fs = require('fs'); const stepTimeout = 3500; @@ -28,6 +29,15 @@ const kill = p => { jest.retryTimes(2); +// Store original content to restore after tests +const barJsPath = path.join(cwd('correct_with_assets'), 'app/assets/bar.js'); +const originalBarJsContent = fs.readFileSync(barJsPath, 'utf8'); + +afterAll(() => { + // Restore bar.js to original content after all tests + fs.writeFileSync(barJsPath, originalBarJsContent); +}); + describe('Happy path', () => { test('sync assets', async () => {