diff --git a/.changeset/smooth-teachers-invent.md b/.changeset/smooth-teachers-invent.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/smooth-teachers-invent.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/integration-tests/src/process-controller/execa_process_killer.ts b/packages/integration-tests/src/process-controller/execa_process_killer.ts new file mode 100644 index 0000000000..0f55e04c6f --- /dev/null +++ b/packages/integration-tests/src/process-controller/execa_process_killer.ts @@ -0,0 +1,22 @@ +import { ExecaChildProcess, execa } from 'execa'; + +/** + * Kills the given process (equivalent of sending CTRL-C) + * @param processInstance an instance of execa child process + */ +export const killExecaProcess = async (processInstance: ExecaChildProcess) => { + if (process.platform.startsWith('win')) { + if (typeof processInstance.pid !== 'number') { + throw new Error('Cannot kill the process that does not have pid'); + } + // Wait X milliseconds before sending kill in hopes of draining the node event queue + await new Promise((resolve) => setTimeout(resolve, 1500)); + // turns out killing child process on Windows is a huge PITA + // https://stackoverflow.com/questions/23706055/why-can-i-not-kill-my-child-process-in-nodejs-on-windows + // https://github.com/sindresorhus/execa#killsignal-options + // eslint-disable-next-line spellcheck/spell-checker + await execa('taskkill', ['/pid', `${processInstance.pid}`, '/f', '/t']); + } else { + processInstance.kill('SIGINT'); + } +}; diff --git a/packages/integration-tests/src/process-controller/predicated_action.ts b/packages/integration-tests/src/process-controller/predicated_action.ts new file mode 100644 index 0000000000..c63316970c --- /dev/null +++ b/packages/integration-tests/src/process-controller/predicated_action.ts @@ -0,0 +1,62 @@ +import { ExecaChildProcess } from 'execa'; + +/** + * Type of actions a user can take with their app. + */ +export enum ActionType { + SEND_INPUT_TO_PROCESS, + UPDATE_FILE_CONTENT, + ASSERT_ON_PROCESS_OUTPUT, + KILL_PROCESS, +} + +type SendInputToProcessAction = { + actionType: ActionType.SEND_INPUT_TO_PROCESS; + action: (execaProcess: ExecaChildProcess) => Promise; +}; +type KillProcess = { + actionType: ActionType.KILL_PROCESS; + action: (execaProcess: ExecaChildProcess) => Promise; +}; +type UpdateFileContentAction = { + actionType: ActionType.UPDATE_FILE_CONTENT; + action: () => Promise; +}; +type AssertOnProcessOutputAction = { + actionType: ActionType.ASSERT_ON_PROCESS_OUTPUT; + action: (processOutputLine: string) => void; +}; + +export type Action = + | SendInputToProcessAction + | KillProcess + | UpdateFileContentAction + | AssertOnProcessOutputAction; + +/** + * Type of predicates based on which to execute actions. An action can only be associated with a predicate + */ +export enum PredicateType { + MATCHES_STRING_PREDICATE, +} + +type MatchesStringPredicate = { + predicateType: PredicateType.MATCHES_STRING_PREDICATE; + predicate: (line: string) => boolean; +}; + +export type Predicate = MatchesStringPredicate; + +/** + * Contains a predicate that the process controller should evaluate, + * then an optional action is executed by the process controller if predicate is true. IFTTT + */ +export type PredicatedAction = { + ifThis: Predicate; + /** + * String that should be sent once the predicate is true + * + * If we need to do things like send multiple keystrokes in response to a single prompt, we will likely need to expand this to an array of values to send + */ + then?: Action; +}; diff --git a/packages/integration-tests/src/process-controller/predicated_action_macros.ts b/packages/integration-tests/src/process-controller/predicated_action_macros.ts new file mode 100644 index 0000000000..700bc79980 --- /dev/null +++ b/packages/integration-tests/src/process-controller/predicated_action_macros.ts @@ -0,0 +1,64 @@ +import { PredicatedActionBuilder } from './predicated_action_queue_builder.js'; + +/** + * Convenience predicated actions that can be used to build up more complex CLI flows. + * By composing flows from reusable macros we will hopefully avoid the situation in the + * classic CLI E2E tests where changing one CLI prompt requires updates to 97742 different E2E prompts + */ + +/** + * Reusable predicates: Wait for sandbox to finish and emit "✨ Total time: xx.xxs" + */ +export const waitForSandboxDeploymentToPrintTotalTime = () => + new PredicatedActionBuilder().waitForLineIncludes('Total time'); + +/** + * Reusable predicates: Wait for sandbox to become idle and emit "Watching for file changes..." + */ +export const waitForSandboxToBecomeIdle = () => + new PredicatedActionBuilder().waitForLineIncludes( + 'Watching for file changes...' + ); + +/** + * Reusable predicated action: Wait for sandbox delete to prompt to delete all the resource and respond with yes + */ +export const confirmDeleteSandbox = () => + new PredicatedActionBuilder() + .waitForLineIncludes( + 'Are you sure you want to delete all the resources in your sandbox environment' + ) + .sendYes(); + +/** + * Reusable predicated action: Wait for sandbox to prompt on quitting to delete all the resource and respond with no + */ +export const rejectCleanupSandbox = () => + new PredicatedActionBuilder() + .waitForLineIncludes( + 'Would you like to delete all the resources in your sandbox environment' + ) + .sendNo(); + +/** + * Reusable predicated action: Wait for sandbox to become idle and then update the + * backend code which should trigger sandbox again + */ +export const updateFileContent = (from: URL, to: URL) => { + return waitForSandboxToBecomeIdle().updateFileContent(from, to); +}; + +/** + * Reusable predicated action: Wait for sandbox to become idle and then quit it (CTRL-C) + */ +export const interruptSandbox = () => waitForSandboxToBecomeIdle().sendCtrlC(); + +/** + * Reusable predicated action: Wait for sandbox to finish deployment and assert that the deployment time is less + * than the threshold. + */ +export const ensureDeploymentTimeLessThan = (seconds: number) => { + return waitForSandboxDeploymentToPrintTotalTime().ensureDeploymentTimeLessThan( + seconds + ); +}; diff --git a/packages/integration-tests/src/process-controller/predicated_action_queue_builder.ts b/packages/integration-tests/src/process-controller/predicated_action_queue_builder.ts new file mode 100644 index 0000000000..c489136e47 --- /dev/null +++ b/packages/integration-tests/src/process-controller/predicated_action_queue_builder.ts @@ -0,0 +1,167 @@ +import { + ActionType, + PredicateType, + PredicatedAction, +} from './predicated_action.js'; +import os from 'os'; +import fs from 'fs/promises'; + +import { killExecaProcess } from './execa_process_killer.js'; +import { ExecaChildProcess } from 'execa'; + +export const CONTROL_C = '\x03'; +/** + * Builder for a queue of Actions + */ +export class PredicatedActionBuilder { + private readonly predicatedActionQueue: PredicatedAction[] = []; + + /** + * Append the action queue from another builder to this builder + */ + append = (builder: PredicatedActionBuilder) => { + this.predicatedActionQueue.push(...builder.getPredicatedActionQueue()); + return this; + }; + + /** + * Add a new predicated action to the queue with a predicate that matches a given string + */ + waitForLineIncludes = (str: string) => { + this.predicatedActionQueue.push({ + ifThis: { + predicateType: PredicateType.MATCHES_STRING_PREDICATE, + predicate: (line) => line.includes(str), + }, + }); + return this; + }; + + /** + * Adds a wait for ms milliseconds + */ + waitFor = async (ms: number) => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }; + + /** + * Update the last predicated action to send str to the running process with no newline + */ + send = (str: string) => { + this.getLastPredicatedAction().then = { + actionType: + str === CONTROL_C + ? ActionType.KILL_PROCESS + : ActionType.SEND_INPUT_TO_PROCESS, + action: async (execaProcess: ExecaChildProcess) => { + if (str === CONTROL_C) { + await killExecaProcess(execaProcess); + } else { + execaProcess.stdin?.write(str); + } + }, + }; + return this; + }; + + /** + * Update the last predicated action to update backend code by copying files from + * `from` location to `to` location. + */ + updateFileContent = (from: URL, to: URL) => { + this.getLastPredicatedAction().then = { + actionType: ActionType.UPDATE_FILE_CONTENT, + action: async () => { + await fs.cp(from, to, { + recursive: true, + }); + }, + }; + return this; + }; + + /** + * Update the last predicated action to validate that the deployment time is less than the one specified + */ + ensureDeploymentTimeLessThan = (seconds: number) => { + this.getLastPredicatedAction().then = { + actionType: ActionType.ASSERT_ON_PROCESS_OUTPUT, + action: (strWithDeploymentTime: string) => { + const regex = /^✨ {2}Total time: (\d*.\d*).*$/; + const deploymentTime = strWithDeploymentTime.match(regex); + if ( + deploymentTime && + deploymentTime.length > 1 && + !isNaN(+deploymentTime[1]) + ) { + if (+deploymentTime[1] <= seconds) { + return; + } + throw new Error( + `Deployment time ${+deploymentTime[1]} seconds is higher than the threshold of ${seconds}` + ); + } else { + throw new Error( + `Could not determine the deployment time. String was ${strWithDeploymentTime}` + ); + } + }, + }; + return this; + }; + + /** + * Send line with a newline at the end + */ + sendLine = (line: string) => { + this.send(`${line}${os.EOL}`); + return this; + }; + + /** + * Send `N\n` + */ + sendNo = () => { + this.sendLine('N'); + return this; + }; + + /** + * Send `Y\n` + */ + sendYes = () => { + this.sendLine('Y'); + return this; + }; + + /** + * Send SIGINT to the child process + */ + sendCtrlC = () => { + this.send(CONTROL_C); + return this; + }; + + /** + * Get the currently queued actions + */ + getPredicatedActionQueue = (): PredicatedAction[] => { + return this.predicatedActionQueue; + }; + + getLastPredicatedAction = () => { + if (this.predicatedActionQueue.length === 0) { + throw new Error('Must have a predicate to execute the action'); + } + // this assertion is safe because we checked the length above + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const lastPredicatedAction = this.predicatedActionQueue.at(-1)!; + if (typeof lastPredicatedAction.then === 'function') { + throw new Error( + 'An action is already registered to the last predicate in the queue. Update the same action.' + ); + } + + return lastPredicatedAction; + }; +} diff --git a/packages/integration-tests/src/process-controller/process_controller.ts b/packages/integration-tests/src/process-controller/process_controller.ts index 7082f644f6..e04b175b53 100644 --- a/packages/integration-tests/src/process-controller/process_controller.ts +++ b/packages/integration-tests/src/process-controller/process_controller.ts @@ -1,8 +1,8 @@ import { Options, execa } from 'execa'; import readline from 'readline'; -import { CONTROL_C } from './stdio_interaction_macros.js'; -import { StdioInteractionQueueBuilder } from './stdio_interaction_queue_builder.js'; - +import { PredicatedActionBuilder } from './predicated_action_queue_builder.js'; +import { ActionType } from './predicated_action.js'; +import { killExecaProcess } from './execa_process_killer.js'; /** * Provides an abstractions for sending and receiving data on stdin/out of a child process * @@ -17,8 +17,8 @@ import { StdioInteractionQueueBuilder } from './stdio_interaction_queue_builder. * then send "yes" on stdin of the process */ export class ProcessController { - private readonly interactions: StdioInteractionQueueBuilder = - new StdioInteractionQueueBuilder(); + private readonly interactions: PredicatedActionBuilder = + new PredicatedActionBuilder(); /** * Initialize a process controller for the specified command and args. * @@ -32,7 +32,7 @@ export class ProcessController { private readonly options?: Pick ) {} - do = (interactions: StdioInteractionQueueBuilder) => { + do = (interactions: PredicatedActionBuilder) => { this.interactions.append(interactions); return this; }; @@ -41,13 +41,14 @@ export class ProcessController { * Execute the sequence of actions queued on the process */ run = async () => { - const interactionQueue = this.interactions.getStdioInteractionQueue(); + const interactionQueue = this.interactions.getPredicatedActionQueue(); const execaProcess = execa(this.command, this.args, { reject: false, ...this.options, }); - const pid = execaProcess.pid; - if (typeof pid !== 'number') { + let errorThrownFromActions = undefined; + let expectKilled = false; + if (typeof execaProcess.pid !== 'number') { throw new Error('Could not determine child process id'); } @@ -64,41 +65,46 @@ export class ProcessController { } const reader = readline.createInterface(execaProcess.stdout); - let expectKilled = false; - for await (const line of reader) { const currentInteraction = interactionQueue[0]; - if (!currentInteraction?.predicate(line)) { - continue; - } - // if we got here, the line matched the predicate - // now we need to send the payload of the action (if any) - if (typeof currentInteraction.payload === 'string') { - if (currentInteraction.payload === CONTROL_C) { - if (process.platform.startsWith('win')) { - // Wait X milliseconds before sending kill in hopes of draining the node event queue - await new Promise((resolve) => setTimeout(resolve, 1500)); - // turns out killing child process on Windows is a huge PITA - // https://stackoverflow.com/questions/23706055/why-can-i-not-kill-my-child-process-in-nodejs-on-windows - // https://github.com/sindresorhus/execa#killsignal-options - // eslint-disable-next-line spellcheck/spell-checker - await execa('taskkill', ['/pid', `${pid}`, '/f', '/t']); - } else { - execaProcess.kill('SIGINT'); + try { + // For now we only have one predicate type. If we add more predicate types in the future, we will have to + // turn this into a predicate executor (Similar to the switch-case for actions below) + if (currentInteraction?.ifThis.predicate(line)) { + switch (currentInteraction.then?.actionType) { + case ActionType.SEND_INPUT_TO_PROCESS: + await currentInteraction.then.action(execaProcess); + break; + case ActionType.KILL_PROCESS: + expectKilled = true; + await currentInteraction.then.action(execaProcess); + break; + case ActionType.UPDATE_FILE_CONTENT: + await currentInteraction.then.action(); + break; + case ActionType.ASSERT_ON_PROCESS_OUTPUT: + currentInteraction.then.action(line); + break; + default: + break; } - expectKilled = true; } else { - execaProcess.stdin?.write(currentInteraction.payload); + continue; } + } catch (error) { + await killExecaProcess(execaProcess); + execaProcess.stdin?.write('N'); + errorThrownFromActions = error; } // advance the queue interactionQueue.shift(); } const result = await execaProcess; - if (expectKilled) { - return; - } else if (result.failed) { + + if (errorThrownFromActions) { + throw errorThrownFromActions; + } else if (result.failed && !expectKilled) { throw new Error(result.stdout); } }; diff --git a/packages/integration-tests/src/process-controller/stdio_interaction.ts b/packages/integration-tests/src/process-controller/stdio_interaction.ts deleted file mode 100644 index 4992c4fa6f..0000000000 --- a/packages/integration-tests/src/process-controller/stdio_interaction.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Contains a predicate that the process controller should wait for on stdout, then an optional string that should be sent on stdin after the predicate matches - */ -export type StdioInteraction = { - predicate: (line: string) => boolean; - /** - * String that should be sent once the predicate is true - * - * If we need to do things like send multiple keystrokes in response to a single prompt, we will likely need to expand this to an array of values to send - */ - payload?: string; -}; diff --git a/packages/integration-tests/src/process-controller/stdio_interaction_macros.ts b/packages/integration-tests/src/process-controller/stdio_interaction_macros.ts deleted file mode 100644 index 19d64ed2d3..0000000000 --- a/packages/integration-tests/src/process-controller/stdio_interaction_macros.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { StdioInteractionQueueBuilder } from './stdio_interaction_queue_builder.js'; - -export const CONTROL_C = '\x03'; - -/** - * Convenience actions that can be used to build up more complex CLI flows. - * By composing flows from reusable macros we will hopefully avoid the situation in the classic CLI E2E tests where changing one CLI prompt requires updates to 97742 different E2E prompts - */ - -export const confirmDeleteSandbox = new StdioInteractionQueueBuilder() - .waitForLineIncludes( - 'Are you sure you want to delete all the resources in your sandbox environment' - ) - .sendYes(); - -export const rejectCleanupSandbox = new StdioInteractionQueueBuilder() - .waitForLineIncludes( - 'Would you like to delete all the resources in your sandbox environment' - ) - .sendNo(); - -export const waitForSandboxDeployment = - new StdioInteractionQueueBuilder().waitForLineIncludes('Total time'); - -export const interruptSandbox = new StdioInteractionQueueBuilder() - .waitForLineIncludes('[Sandbox] Watching for file changes') - .sendCtrlC(); diff --git a/packages/integration-tests/src/process-controller/stdio_interaction_queue_builder.ts b/packages/integration-tests/src/process-controller/stdio_interaction_queue_builder.ts deleted file mode 100644 index 79083128f6..0000000000 --- a/packages/integration-tests/src/process-controller/stdio_interaction_queue_builder.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { StdioInteraction } from './stdio_interaction.js'; -import os from 'os'; -import { CONTROL_C } from './stdio_interaction_macros.js'; - -/** - * Builder for a queue of LineActions - */ -export class StdioInteractionQueueBuilder { - private readonly stdioInteractionQueue: StdioInteraction[] = []; - - /** - * Append the action queue from another builder to this builder - */ - append = (builder: StdioInteractionQueueBuilder) => { - this.stdioInteractionQueue.push(...builder.getStdioInteractionQueue()); - return this; - }; - - /** - * Add a new action to the queue to wait for a line that includes str - */ - waitForLineIncludes = (str: string) => { - this.stdioInteractionQueue.push({ - predicate: (line) => line.includes(str), - }); - return this; - }; - - /** - * Send str with no newline - */ - send = (str: string) => { - if (this.stdioInteractionQueue.length === 0) { - throw new Error('Must wait for a line before sending'); - } - // this assertion is safe because we checked the length above - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const lastInteraction = this.stdioInteractionQueue.at(-1)!; - if (typeof lastInteraction.payload === 'string') { - throw new Error( - 'A string to send is already registered to the last action in the queue. Add a new action to the queue.' - ); - } - lastInteraction.payload = str; - return this; - }; - - /** - * Send line with a newline at the end - */ - sendLine = (line: string) => { - this.send(`${line}${os.EOL}`); - return this; - }; - - /** - * Send `N\n` - */ - sendNo = () => { - this.sendLine('N'); - return this; - }; - - /** - * Send `Y\n` - */ - sendYes = () => { - this.sendLine('Y'); - return this; - }; - - /** - * Send SIGINT to the child process - */ - sendCtrlC = () => { - this.send(CONTROL_C); - return this; - }; - - /** - * Get the currently queued actions - */ - getStdioInteractionQueue = (): StdioInteraction[] => { - return this.stdioInteractionQueue; - }; -} diff --git a/packages/integration-tests/src/test-e2e/deployment.test.ts b/packages/integration-tests/src/test-e2e/deployment.test.ts index 4c69686547..e13203e998 100644 --- a/packages/integration-tests/src/test-e2e/deployment.test.ts +++ b/packages/integration-tests/src/test-e2e/deployment.test.ts @@ -5,16 +5,18 @@ import { amplifyCli } from '../process-controller/process_controller.js'; import assert from 'node:assert'; import { confirmDeleteSandbox, + ensureDeploymentTimeLessThan, interruptSandbox, rejectCleanupSandbox, - waitForSandboxDeployment, -} from '../process-controller/stdio_interaction_macros.js'; + updateFileContent, + waitForSandboxDeploymentToPrintTotalTime, +} from '../process-controller/predicated_action_macros.js'; import { createEmptyAmplifyProject } from '../create_empty_amplify_project.js'; import { createTestDirectoryBeforeAndCleanupAfter, getTestDir, } from '../setup_test_directory.js'; -import { pathToFileURL } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; import { shortUuid } from '../short_uuid.js'; import { CloudFormationClient, @@ -39,10 +41,20 @@ void describe('amplify deploys', () => { const testProjects = [ { name: 'data-storage-auth-with-triggers', - initialAmplifyDirPath: new URL( + amplifyPath: new URL( '../../test-projects/data-storage-auth-with-triggers/amplify', import.meta.url ), + updates: [ + { + amplifyPath: new URL( + '../../test-projects/data-storage-auth-with-triggers/update-1', + import.meta.url + ), + fileToUpdate: 'data/resource.ts', + deploymentThresholdInSeconds: 80, + }, + ], assertions: async () => { const { default: clientConfig } = await import( pathToFileURL( @@ -67,21 +79,57 @@ void describe('amplify deploys', () => { void describe('sandbox', () => { afterEach(async () => { await amplifyCli(['sandbox', 'delete'], testProjectRoot) - .do(confirmDeleteSandbox) + .do(confirmDeleteSandbox()) .run(); await fs.rm(testProjectRoot, { recursive: true }); }); testProjects.forEach((testProject) => { - void it(testProject.name, async () => { - await fs.cp(testProject.initialAmplifyDirPath, testAmplifyDir, { + void it(`${testProject.name} deploys with sandbox on startup`, async () => { + await fs.cp(testProject.amplifyPath, testAmplifyDir, { recursive: true, }); await amplifyCli(['sandbox'], testProjectRoot) - .do(waitForSandboxDeployment) - .do(interruptSandbox) - .do(rejectCleanupSandbox) + .do(waitForSandboxDeploymentToPrintTotalTime()) + .do(interruptSandbox()) + .do(rejectCleanupSandbox()) + .run(); + + await testProject.assertions(); + }); + }); + + testProjects.forEach((testProject) => { + void it(`${testProject.name} hot swaps a change`, async () => { + await fs.cp(testProject.amplifyPath, testAmplifyDir, { + recursive: true, + }); + + const processController = amplifyCli( + ['sandbox', '--dirToWatch', 'amplify'], + testProjectRoot + ).do(waitForSandboxDeploymentToPrintTotalTime()); + + for (const update of testProject.updates) { + const fileToUpdate = pathToFileURL( + path.join(fileURLToPath(update.amplifyPath), update.fileToUpdate) + ); + const updateSource = pathToFileURL( + path.join(testAmplifyDir, update.fileToUpdate) + ); + + processController + .do(updateFileContent(fileToUpdate, updateSource)) + .do( + ensureDeploymentTimeLessThan(update.deploymentThresholdInSeconds) + ); + } + + // Execute the process. + await processController + .do(interruptSandbox()) + .do(rejectCleanupSandbox()) .run(); await testProject.assertions(); @@ -109,7 +157,7 @@ void describe('amplify deploys', () => { testProjects.forEach((testProject) => { void it(testProject.name, async () => { - await fs.cp(testProject.initialAmplifyDirPath, testAmplifyDir, { + await fs.cp(testProject.amplifyPath, testAmplifyDir, { recursive: true, }); diff --git a/packages/integration-tests/test-projects/data-storage-auth-with-triggers/update-1/data/resource.ts b/packages/integration-tests/test-projects/data-storage-auth-with-triggers/update-1/data/resource.ts new file mode 100644 index 0000000000..c89ae4cc0e --- /dev/null +++ b/packages/integration-tests/test-projects/data-storage-auth-with-triggers/update-1/data/resource.ts @@ -0,0 +1,15 @@ +import { defineData } from '@aws-amplify/backend-graphql'; + +const schema = ` + input AMPLIFY {globalAuthRule: AuthRule = { allow: public }} # FOR TESTING ONLY! + + type Todo @model { + id: ID! + name: String! + description: String + otherField: String + newFieldAdded: String + } +`; + +export const data = defineData({ schema });