diff --git a/.eslint-dictionary.json b/.eslint-dictionary.json index 1583085bdb5..a060ae28f65 100644 --- a/.eslint-dictionary.json +++ b/.eslint-dictionary.json @@ -161,6 +161,7 @@ "formatter", "frontend", "frontends", + "frontest", "fsext", "func", "funcs", diff --git a/.kiro/settings/mcp.json b/.kiro/settings/mcp.json index 82ec3d63ac8..a78fec1ef50 100644 --- a/.kiro/settings/mcp.json +++ b/.kiro/settings/mcp.json @@ -1,37 +1,38 @@ { - "mcpServers": { - "aws-infrastructure-as-code": { - "command": "uvx", - "args": [ - "awslabs.aws-iac-mcp-server@latest" - ], - "env": { - "FASTMCP_LOG_LEVEL": "ERROR" - }, - "disabled": false, - "autoApprove": [ - "search_cdk_documentation", - "search_cdk_samples_and_constructs", - "search_cloudformation_documentation", - "cdk_best_practices", - "read_iac_documentation_page" - ] - }, - "aws-mcp": { - "command": "uvx", - "timeout": 100000, - "transport": "stdio", - "args": [ - "mcp-proxy-for-aws@latest", - "https://aws-mcp.us-east-1.api.aws/mcp", - "--metadata" - ], - "disabled": false, - "autoApprove": [ - "search_documentation", - "list_regions", - "get_regional_availability" - ] - } + "mcpServers": { + "aws-infrastructure-as-code": { + "command": "uvx", + "args": [ + "awslabs.aws-iac-mcp-server@latest" + ], + "env": { + "FASTMCP_LOG_LEVEL": "ERROR" + }, + "disabled": false, + "autoApprove": [ + "search_cdk_documentation", + "search_cdk_samples_and_constructs", + "search_cloudformation_documentation", + "cdk_best_practices", + "read_iac_documentation_page" + ] + }, + "aws-mcp": { + "command": "uvx", + "timeout": 100000, + "transport": "stdio", + "args": [ + "mcp-proxy-for-aws@latest", + "https://aws-mcp.us-east-1.api.aws/mcp", + "--metadata" + ], + "disabled": false, + "autoApprove": [ + "search_documentation", + "list_regions", + "get_regional_availability", + "aws___search_documentation" + ] } -} + } +} \ No newline at end of file diff --git a/amplify-migration-apps/README.md b/amplify-migration-apps/README.md index 868b3e2a3ce..cec5359d8fd 100644 --- a/amplify-migration-apps/README.md +++ b/amplify-migration-apps/README.md @@ -22,6 +22,19 @@ Each app directory follows this layout: ``` / +├── backend/ # Backend assets (schema, function code, configure.sh) +├── migration/ +│ ├── config.json # E2E system configuration (optional) +│ ├── post-generate.ts # Fixups after gen2-migration generate +│ ├── post-push.ts # Fixups after amplify push (optional) +│ └── post-refactor.ts # Fixups after gen2-migration refactor +├── tests/ # Jest test suites for validating deployed stacks +│ ├── signup.ts # Cognito user provisioning (app-specific) +│ ├── jest.setup.ts # Jest setup (retry config) +│ ├── api.test.ts # GraphQL / REST API tests +│ ├── storage.test.ts # S3 / DynamoDB storage tests +│ └── ... # Additional category-specific test files +├── jest.config.js # Jest configuration ├── _snapshot.pre.generate/ # Input for `gen2-migration generate` test (Gen1 app state) ├── _snapshot.post.generate/ # Expected output of `gen2-migration generate` ├── _snapshot.pre.refactor/ # Input for `gen2-migration refactor` test (CFN templates) @@ -30,17 +43,81 @@ Each app directory follows this layout: ├── .gitignore # Git ignore rules ├── package.json # Standard NodeJS based manifest ├── README.md # Deployment and migration instructions -└── ... # App-specific source files (schema, configs, etc.) +└── ... # App-specific source files ``` The Gen1 Amplify project structure (the `amplify/` directory) lives inside `_snapshot.pre.generate/`, not at the top level. The top level only contains -snapshot directories, the app manifest, and any source files needed for -deployment (e.g., `schema.graphql`, `configure.sh`). +snapshot directories, the app manifest, and source files needed for deployment. + +### `backend/` + +Contains the backend source assets for the app: the GraphQL schema, Lambda function code, +and a `configure.sh` script that copies them into the Gen1 `amplify/` directory structure. +The configure script uses `$BASH_SOURCE`-relative paths so it works regardless of the +caller's working directory. + +### `migration/config.json` + +Configuration file read by the [E2E system](../packages/amplify-gen2-migration-e2e-system/) at runtime. +Currently supports: + +```json +{ + "lock": { "skipValidations": true } +} +``` + +- `lock.skipValidations` — pass `--skip-validations` to `gen2-migration lock`. + +If the file does not exist, defaults are used (no skip-validations). + +### `tests/` + +Jest test suites that validate a deployed stack. Each app has its own `jest.config.js` and +test files under `tests/`. The config path is controlled by the `APP_CONFIG_PATH` environment +variable, which the `test:gen1` and `test:gen2` npm scripts set to the appropriate file +(`src/amplifyconfiguration.json` for Gen1, `amplify_outputs.json` for Gen2). + +Each app has its own `tests/signup.ts` that handles Cognito user provisioning via +`AdminCreateUser` + `AdminSetUserPassword`, tailored to the app's specific auth +configuration (email vs phone sign-in, user pool groups, etc.). + +### `migration/post-generate.ts` and `migration/post-refactor.ts` + +Optional scripts that apply app-specific fixups the migration CLI cannot automate. Examples: + +- Converting CommonJS Lambda functions to ESM syntax +- Updating frontend imports from `aws-exports` to `amplify_outputs.json` +- Setting `branchName` to `sandbox` for DynamoDB table mappings +- Uncommenting resource names to preserve original Gen1 names after refactor + +Both scripts export a function and accept `appPath` as a CLI argument: + +```typescript +export async function postGenerate(appPath: string): Promise; +export async function postRefactor(appPath: string): Promise; +``` + +If a script does not exist for an app, the E2E system silently skips the step. > Some apps don't have `_snapshot.post.refactor/` because refactor doesn't work > for them yet. +### `migration/pre-push.ts` and `migration/post-sandbox.ts` + +Optional scripts for additional lifecycle hooks: + +- `pre-push.ts` — runs before `amplify push`. Use for fixups that require the Amplify + app to be initialized but not yet deployed (e.g., substituting the real Amplify app ID + into configuration files). +- `post-sandbox.ts` — runs after the first `npx ampx sandbox --once` deploy. Use for + fixups that require the Gen2 stack to exist (e.g., writing secrets to SSM Parameter + Store using the deployed stack name). + +Both accept `appPath` as a CLI argument. If a script does not exist for an app, the +E2E system silently skips the step. + ### `_snapshot.pre.generate/` A copy of the Gen1 app as it exists before running `gen2-migration generate`. This is the @@ -349,3 +426,34 @@ Always review the diff after updating to make sure the changes are intentional. > because it detects the diff before writing the updated files. Run the tests a second time > (without `--updateSnapshot`) to verify the snapshots are now correct. + +## Integration Testing (E2E) + +The [E2E system](../packages/amplify-gen2-migration-e2e-system/) automates the full migration +workflow for a single app: Gen1 deploy, migration, Gen2 deploy, and validation at each stage. +It deploys real AWS resources, so you need valid credentials. + +```bash +# Build the Amplify CLI (if using the development binary) +cd packages/amplify-cli && yarn build + +# Optionally point to your dev CLI (falls back to monorepo build, then global install) +export AMPLIFY_PATH=$(pwd)/.bin/amplify-dev + +# Run the full migration for a specific app +cd packages/amplify-gen2-migration-e2e-system +npx tsx src/cli.ts --app project-boards --profile default +``` + +This will: +1. Deploy the Gen1 app via `amplify push` +2. Run `test:gen1` to validate the Gen1 stack +3. Execute the full `gen2-migration` workflow (assess, lock, generate, deploy, refactor, redeploy) +4. Run `test:gen1` and `test:gen2` after each deployment + +The system automatically runs npm scripts from the app's `package.json` at the right +points in the workflow — `post-generate` after generate, `post-refactor` after refactor, +and `post-push` after push. Scripts that resolve to `true` (no-op) are effectively skipped. + +See the [E2E system README](../packages/amplify-gen2-migration-e2e-system/README.md) for +CLI options and troubleshooting. diff --git a/amplify-migration-apps/_test-common/README.md b/amplify-migration-apps/_test-common/README.md deleted file mode 100644 index a31c1a404d7..00000000000 --- a/amplify-migration-apps/_test-common/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# \_test-common - -Shared test utilities for the Amplify migration test apps. These helpers handle Cognito user provisioning, test execution, and common type definitions used across all apps in `amplify-migration-apps/`. - -## Files - -| File | Description | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `test-apps-test-utils.ts` | Shared type definitions (`AmplifyConfig`, `TestUser`, `TestCredentials`, etc.) and barrel re-exports for backwards compatibility. | -| `signup.ts` | `provisionTestUser` — creates a test user via `AdminCreateUser` and sets a permanent password. Generates unique credentials dynamically. Supports email, phone, and username signin patterns. Works even when self-signup is disabled on the user pool. | -| `runner.ts` | `TestRunner` class — runs async test functions, collects failures, and prints a summary. | - -## Usage - -```typescript -import { TestRunner } from '../_test-common/runner'; -import { provisionTestUser } from '../_test-common/signup'; -import testCredentials from '../_test-common/test-credentials.json'; -``` - -`provisionTestUser` reads the Amplify config to determine the correct Cognito auth flow (which attribute is the signin identifier, which attributes are required at signup) and provisions a confirmed user via admin APIs. It does **not** call `signIn` — the caller handles that in its own module scope so the Amplify auth singleton retains the tokens. diff --git a/amplify-migration-apps/_test-common/package.json b/amplify-migration-apps/_test-common/package.json deleted file mode 100644 index ce2abc42121..00000000000 --- a/amplify-migration-apps/_test-common/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "shared-test-utils", - "private": true, - "version": "0.0.0", - "type": "module", - "dependencies": { - "@aws-sdk/client-cognito-identity-provider": "^3.936.0", - "aws-amplify": "^6.15.8" - } -} diff --git a/amplify-migration-apps/_test-common/runner.ts b/amplify-migration-apps/_test-common/runner.ts deleted file mode 100644 index a2a0dcb681b..00000000000 --- a/amplify-migration-apps/_test-common/runner.ts +++ /dev/null @@ -1,61 +0,0 @@ -export interface TestFailure { - name: string; - message: string; - stack?: string; -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - if (typeof error === 'object' && error !== null) { - // Handle GraphQL-style errors: { errors: [{ message: "..." }] } - if ('errors' in error) { - const gqlErrors = (error as { errors: unknown }).errors; - if (Array.isArray(gqlErrors) && gqlErrors.length > 0) { - const first = gqlErrors[0] as { message?: string }; - if (typeof first.message === 'string') { - return first.message; - } - } - } - return JSON.stringify(error, null, 2); - } - return String(error); -} - -export class TestRunner { - readonly failures: TestFailure[] = []; - - async runTest(name: string, testFn: () => Promise): Promise { - try { - const result = await testFn(); - return result; - } catch (error: unknown) { - const stack = error instanceof Error ? error.stack : undefined; - this.failures.push({ name, message: getErrorMessage(error), stack }); - return null; - } - } - - printSummary(): void { - console.log('\n' + '='.repeat(50)); - console.log('📊 TEST SUMMARY'); - console.log('='.repeat(50)); - - if (this.failures.length === 0) { - console.log('\n✅ All tests passed!'); - } else { - console.log(`\n❌ ${this.failures.length} test(s) failed:\n`); - this.failures.forEach((f) => { - console.log(` • ${f.name}`); - console.log(` Error: ${f.message}`); - if (f.stack) { - console.log(` Stack: ${f.stack}`); - } - console.log(''); - }); - process.exit(1); - } - } -} diff --git a/amplify-migration-apps/_test-common/signup.ts b/amplify-migration-apps/_test-common/signup.ts deleted file mode 100644 index d28c1cd2a75..00000000000 --- a/amplify-migration-apps/_test-common/signup.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { - CognitoIdentityProviderClient, - AdminCreateUserCommand, - AdminSetUserPasswordCommand, - type AttributeType, -} from '@aws-sdk/client-cognito-identity-provider'; -import { randomBytes } from 'crypto'; -import type { AmplifyConfig, SigninIdentifier, SignupAttribute, TestUser } from './test-apps-test-utils'; - -interface ResolvedAuthConfig { - signinIdentifier: SigninIdentifier; - signupAttributes: SignupAttribute[]; -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - if (typeof error === 'object' && error !== null) { - if ('errors' in error) { - const gqlErrors = (error as { errors: unknown }).errors; - if (Array.isArray(gqlErrors) && gqlErrors.length > 0) { - const first = gqlErrors[0] as { message?: string }; - if (typeof first.message === 'string') { - return first.message; - } - } - } - return JSON.stringify(error, null, 2); - } - return String(error); -} - -function resolveSigninIdentifier(usernameAttributes: string[]): SigninIdentifier { - const normalized = usernameAttributes.map((a) => a.toUpperCase()); - if (normalized.includes('PHONE_NUMBER') || normalized.includes('PHONE')) return 'phone'; - if (normalized.includes('EMAIL')) return 'email'; - return 'username'; -} - -function resolveSignupAttributes(signupAttributes: string[]): SignupAttribute[] { - const mapping: Record = { - EMAIL: 'email', - PHONE_NUMBER: 'phone', - PHONE: 'phone', - USERNAME: 'username', - }; - const mapped = signupAttributes.map((attr) => mapping[attr.toUpperCase()]).filter((a): a is SignupAttribute => a !== undefined); - return mapped.length > 0 ? mapped : ['email']; -} - -interface GeneratedCredentials { - email?: string; - phoneNumber?: string; - username?: string; - password: string; -} - -/** - * Generates only the credentials required by the resolved auth config. - * The union of signupAttributes and signinIdentifier determines which - * identity fields are needed. Password is always generated. - */ -function generateCredentials(resolved: ResolvedAuthConfig): GeneratedCredentials { - const needed = new Set([...resolved.signupAttributes, resolved.signinIdentifier]); - return { - email: needed.has('email') ? generateTestEmail() : undefined, - phoneNumber: needed.has('phone') ? generateTestPhoneNumber() : undefined, - username: needed.has('username') ? generateTestUsername() : undefined, - password: generateTestPassword(), - }; -} - -function buildAdminCreateUserInput( - userPoolId: string, - resolved: ResolvedAuthConfig, - credentials: GeneratedCredentials, -): { UserPoolId: string; Username: string; TemporaryPassword: string; UserAttributes: AttributeType[]; MessageAction: 'SUPPRESS' } { - const { signinIdentifier, signupAttributes } = resolved; - - const identifierValueMap: Record = { - email: credentials.email, - phone: credentials.phoneNumber, - username: credentials.username, - }; - const username = identifierValueMap[signinIdentifier] ?? ''; - - const attributeMap: Record = { - email: credentials.email ? { Name: 'email', Value: credentials.email } : undefined, - phone: credentials.phoneNumber ? { Name: 'phone_number', Value: credentials.phoneNumber } : undefined, - username: credentials.username ? { Name: 'username', Value: credentials.username } : undefined, - }; - - // When phone or username is used as the Username, Cognito already - // associates it with the user, so including it again in UserAttributes - // would be redundant. Email is the exception — Cognito requires it in - // UserAttributes for verification even when it's also the Username. - const userAttributes: AttributeType[] = signupAttributes - .filter((attr) => { - if (signinIdentifier === 'phone' && attr === 'phone') return false; - if (signinIdentifier === 'username' && attr === 'username') return false; - return true; - }) - .map((attr) => attributeMap[attr]) - .filter((a): a is AttributeType => a !== undefined); - - // Mark email/phone as verified so the user can sign in immediately - if (credentials.email) { - userAttributes.push({ Name: 'email_verified', Value: 'true' }); - } - if (credentials.phoneNumber) { - userAttributes.push({ Name: 'phone_number_verified', Value: 'true' }); - } - - return { - UserPoolId: userPoolId, - Username: username, - TemporaryPassword: credentials.password, - UserAttributes: userAttributes, - MessageAction: 'SUPPRESS', - }; -} - -/** - * Generates a unique random suffix for test credentials. - */ -function randomSuffix(): string { - return randomBytes(4).toString('hex'); -} - -function generateTestEmail(): string { - return `testuser-${randomSuffix()}@test.example.com`; -} - -function generateTestPhoneNumber(): string { - // Generate a random 7-digit number for the local part - const local = Math.floor(1000000 + Math.random() * 9000000); - return `+1555${local}`; -} - -function generateTestUsername(): string { - return `testuser-${randomSuffix()}`; -} - -function generateTestPassword(): string { - // Meets Cognito default policy: uppercase, lowercase, digit, special, 8+ chars - return `Test${randomSuffix()}!Aa1`; -} - -/** - * Provisions a test user via AdminCreateUser and sets a permanent password. - * Uses admin APIs so it works even when self-signup is disabled on the user pool. - * Generates unique credentials dynamically for each invocation. - * Does NOT sign in — the caller should handle signIn in its own module scope - * so the Amplify auth singleton has the tokens available for API/Storage calls. - * Returns the username to use for signIn. - */ -export async function provisionTestUser(config: AmplifyConfig): Promise<{ signinValue: string; testUser: TestUser }> { - // Support both Gen1 (aws_user_pools_id) and Gen2 (auth.user_pool_id) config formats - const gen2Auth = (config as any)?.auth; - const userPoolId = config.aws_user_pools_id ?? gen2Auth?.user_pool_id; - const region = config.aws_cognito_region ?? gen2Auth?.aws_region; - - const resolved: ResolvedAuthConfig = { - signinIdentifier: resolveSigninIdentifier(config.aws_cognito_username_attributes ?? gen2Auth?.username_attributes ?? []), - signupAttributes: resolveSignupAttributes(config.aws_cognito_signup_attributes ?? gen2Auth?.standard_required_attributes ?? []), - }; - - const credentials = generateCredentials(resolved); - - const createUserInput = buildAdminCreateUserInput(userPoolId ?? '', resolved, credentials); - const signinValue = createUserInput.Username; - - console.log(`\n🔑 Creating test user: ${createUserInput.Username}`); - - const cognitoClient = new CognitoIdentityProviderClient({ region }); - - // Step 1: AdminCreateUser - try { - await cognitoClient.send(new AdminCreateUserCommand(createUserInput)); - console.log('✅ AdminCreateUser succeeded'); - } catch (error) { - console.error('❌ AdminCreateUser failed:', getErrorMessage(error)); - return process.exit(1); - } - - // Step 2: AdminSetUserPassword (set permanent password, moves user out of FORCE_CHANGE_PASSWORD) - try { - await cognitoClient.send( - new AdminSetUserPasswordCommand({ - UserPoolId: userPoolId, - Username: createUserInput.Username, - Password: credentials.password, - Permanent: true, - }), - ); - console.log('✅ AdminSetUserPassword succeeded'); - } catch (error) { - console.error('❌ AdminSetUserPassword failed:', getErrorMessage(error)); - return process.exit(1); - } - - const testUser: TestUser = { - username: signinValue, - password: credentials.password, - ...(credentials.email !== undefined && { email: credentials.email }), - ...(credentials.phoneNumber !== undefined && { phoneNumber: credentials.phoneNumber }), - }; - - return { signinValue, testUser }; -} diff --git a/amplify-migration-apps/_test-common/test-apps-test-utils.ts b/amplify-migration-apps/_test-common/test-apps-test-utils.ts deleted file mode 100644 index 60b3e41f7dc..00000000000 --- a/amplify-migration-apps/_test-common/test-apps-test-utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type SigninIdentifier = 'email' | 'phone' | 'username'; - -export type SignupAttribute = 'email' | 'phone' | 'username'; - -export interface AmplifyConfig { - aws_user_pools_id?: string; - aws_user_pools_web_client_id?: string; - aws_cognito_region?: string; - aws_cognito_username_attributes?: string[]; - aws_cognito_signup_attributes?: string[]; -} - -export interface TestUser { - username: string; - password: string; - email?: string; - phoneNumber?: string; -} - -// Re-export runner and signup for backwards compatibility -export { TestRunner, type TestFailure } from './runner'; -export { provisionTestUser } from './signup'; diff --git a/amplify-migration-apps/backend-only/_snapshot.post.generate/package.json b/amplify-migration-apps/backend-only/_snapshot.post.generate/package.json index 0b6c9c21637..f73d414bfec 100644 --- a/amplify-migration-apps/backend-only/_snapshot.post.generate/package.json +++ b/amplify-migration-apps/backend-only/_snapshot.post.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/backend-only", + "name": "@amplify-migration-apps/backend-only-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/backend-only/_snapshot.pre.generate/package.json b/amplify-migration-apps/backend-only/_snapshot.pre.generate/package.json index e52d3e6f7b3..3e8aa480a2a 100644 --- a/amplify-migration-apps/backend-only/_snapshot.pre.generate/package.json +++ b/amplify-migration-apps/backend-only/_snapshot.pre.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/backend-only", + "name": "@amplify-migration-apps/backend-only-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/backend-only/backend/configure.sh b/amplify-migration-apps/backend-only/backend/configure.sh new file mode 100755 index 00000000000..49650fe5a8f --- /dev/null +++ b/amplify-migration-apps/backend-only/backend/configure.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -euxo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cp -f ${script_dir}/schema.graphql ${script_dir}/../amplify/backend/api/backendonly/schema.graphql +cp -f ${script_dir}/quotegenerator.js ${script_dir}/../amplify/backend/function/quotegeneratorbe/src/index.js +cp -f ${script_dir}/quotegenerator.package.json ${script_dir}/../amplify/backend/function/quotegeneratorbe/src/package.json diff --git a/amplify-migration-apps/backend-only/quotegenerator.js b/amplify-migration-apps/backend-only/backend/quotegenerator.js similarity index 100% rename from amplify-migration-apps/backend-only/quotegenerator.js rename to amplify-migration-apps/backend-only/backend/quotegenerator.js diff --git a/amplify-migration-apps/backend-only/quotegenerator.package.json b/amplify-migration-apps/backend-only/backend/quotegenerator.package.json similarity index 100% rename from amplify-migration-apps/backend-only/quotegenerator.package.json rename to amplify-migration-apps/backend-only/backend/quotegenerator.package.json diff --git a/amplify-migration-apps/backend-only/schema.graphql b/amplify-migration-apps/backend-only/backend/schema.graphql similarity index 100% rename from amplify-migration-apps/backend-only/schema.graphql rename to amplify-migration-apps/backend-only/backend/schema.graphql diff --git a/amplify-migration-apps/backend-only/configure.sh b/amplify-migration-apps/backend-only/configure.sh deleted file mode 100755 index 917a144909d..00000000000 --- a/amplify-migration-apps/backend-only/configure.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -cp -f schema.graphql ./amplify/backend/api/backendonly/schema.graphql -cp -f quotegenerator.js ./amplify/backend/function/quotegeneratorbe/src/index.js -cp -f quotegenerator.package.json ./amplify/backend/function/quotegeneratorbe/src/package.json diff --git a/amplify-migration-apps/backend-only/migration-config.json b/amplify-migration-apps/backend-only/migration-config.json deleted file mode 100644 index 13aa52b222b..00000000000 --- a/amplify-migration-apps/backend-only/migration-config.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "app": { - "name": "backend-only", - "description": "Backend-only app with auth, GraphQL API, storage, and function", - "framework": "none" - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["API_KEY", "AMAZON_COGNITO_USER_POOLS"] - }, - "auth": { - "signInMethods": ["email"], - "socialProviders": [] - }, - "storage": { - "buckets": [ - { - "name": "images", - "access": ["auth", "guest"] - } - ] - }, - "function": { - "functions": [ - { - "name": "quotegeneratorbe", - "runtime": "nodejs", - "template": "hello-world" - } - ] - } - } -} diff --git a/amplify-migration-apps/backend-only/migration/post-generate.ts b/amplify-migration-apps/backend-only/migration/post-generate.ts new file mode 100644 index 00000000000..38b522938ed --- /dev/null +++ b/amplify-migration-apps/backend-only/migration/post-generate.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env npx ts-node +/** + * Post-generate script for backend-only app. + * + * Applies manual edits required after `amplify gen2-migration generate`: + * 1. Update branchName in amplify/data/resource.ts to the value of AWS_BRANCH + * env var, or the current git branch if AWS_BRANCH is not set + * 2. Convert quotegeneratorbe function from CommonJS to ESM + */ + +import { execSync } from 'child_process'; +import fs from 'fs/promises'; +import path from 'path'; + +function resolveTargetBranch(): string { + if (process.env.AWS_BRANCH) { + return process.env.AWS_BRANCH; + } + return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); +} + +async function updateBranchName(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); + + const content = await fs.readFile(resourcePath, 'utf-8'); + + const targetBranch = resolveTargetBranch(); + + const updated = content.replace( + /branchName:\s*['"]([^'"]+)['"]/, + `branchName: '${targetBranch}'`, + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function convertQuotegeneratorToESM(appPath: string): Promise { + const handlerPath = path.join(appPath, 'amplify', 'function', 'quotegeneratorbe', 'index.js'); + + const content = await fs.readFile(handlerPath, 'utf-8'); + + let updated = content.replace( + /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + updated = updated.replace( + /module\.exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + await fs.writeFile(handlerPath, updated, 'utf-8'); +} + +export async function postGenerate(appPath: string): Promise { + await updateBranchName(appPath); + await convertQuotegeneratorToESM(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postGenerate(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/backend-only/migration/post-refactor.ts b/amplify-migration-apps/backend-only/migration/post-refactor.ts new file mode 100644 index 00000000000..8b12586c557 --- /dev/null +++ b/amplify-migration-apps/backend-only/migration/post-refactor.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env npx ts-node +/** + * Post-refactor script for backend-only app. + * + * Applies manual edits required after `amplify gen2-migration refactor`: + * 1. Uncomment s3Bucket.bucketName in amplify/backend.ts to sync with deployed template + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function uncommentS3BucketName(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + + const content = await fs.readFile(backendPath, 'utf-8'); + + const updated = content.replace( + /\/\/\s*(s3Bucket\.bucketName\s*=\s*['"][^'"]+['"];?)/g, + '$1', + ); + + await fs.writeFile(backendPath, updated, 'utf-8'); +} + +export async function postRefactor(appPath: string): Promise { + await uncommentS3BucketName(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRefactor(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/backend-only/package.json b/amplify-migration-apps/backend-only/package.json index e52d3e6f7b3..d01a494e107 100644 --- a/amplify-migration-apps/backend-only/package.json +++ b/amplify-migration-apps/backend-only/package.json @@ -7,7 +7,20 @@ "hoistingLimits": "workspaces" }, "scripts": { + "configure": "./backend/configure.sh", "sanitize": "tsx ../sanitize.ts", - "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit" + "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit", + "test:gen1": "true", + "test:gen2": "true", + "test:e2e": "cd ../../packages/amplify-gen2-migration-e2e-system && npx tsx src/cli.ts --app backend-only --profile ${AWS_PROFILE:-default}", + "pre-push": "true", + "post-generate": "npx tsx migration/post-generate.ts", + "post-refactor": "npx tsx migration/post-refactor.ts", + "post-sandbox": "true", + "pre-sandbox": "true", + "post-push": "true" + }, + "devDependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.936.0" } } diff --git a/amplify-migration-apps/backend-only/post-generate.ts b/amplify-migration-apps/backend-only/post-generate.ts deleted file mode 100644 index df2d45b919a..00000000000 --- a/amplify-migration-apps/backend-only/post-generate.ts +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env npx ts-node -/** - * Post-generate script for backend-only app. - * - * Applies manual edits required after `amplify gen2-migration generate`: - * 1. Update branchName in amplify/data/resource.ts to "sandbox" - * 2. Convert quotegeneratorbe function from CommonJS to ESM - * 3. Fix missing awsRegion in GraphQL API userPoolConfig - */ - -import fs from 'fs/promises'; -import path from 'path'; - -interface PostGenerateOptions { - appPath: string; - envName?: string; -} - -async function updateBranchName(appPath: string): Promise { - const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); - - console.log(`Updating branchName in ${resourcePath}...`); - - let content: string; - try { - content = await fs.readFile(resourcePath, 'utf-8'); - } catch { - console.log(' resource.ts not found, skipping'); - return; - } - - const targetBranch = 'sandbox'; - - const branchNameMatch = content.match(/branchName:\s*['"]([^'"]+)['"]/); - if (branchNameMatch) { - console.log(` Found branchName: '${branchNameMatch[1]}'`); - } else { - console.log(' WARNING: No branchName property found'); - } - - const updated = content.replace( - /branchName:\s*['"]([^'"]+)['"]/, - `branchName: '${targetBranch}'`, - ); - - if (updated === content) { - console.log(' No branchName found to update, skipping'); - return; - } - - await fs.writeFile(resourcePath, updated, 'utf-8'); - console.log(` Updated branchName to "${targetBranch}"`); -} - -async function convertQuotegeneratorToESM(appPath: string): Promise { - const handlerPath = path.join(appPath, 'amplify', 'function', 'quotegeneratorbe', 'index.js'); - - console.log(`Converting quotegeneratorbe to ESM in ${handlerPath}...`); - - let content: string; - try { - content = await fs.readFile(handlerPath, 'utf-8'); - } catch { - console.log(' index.js not found, skipping'); - return; - } - - let updated = content.replace( - /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, - 'export async function handler($1) {', - ); - - updated = updated.replace( - /module\.exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, - 'export async function handler($1) {', - ); - - if (updated === content) { - console.log(' No CommonJS exports found, skipping'); - return; - } - - await fs.writeFile(handlerPath, updated, 'utf-8'); - console.log(' Converted to ESM syntax'); -} - -async function fixUserPoolRegionInGraphqlApi(appPath: string): Promise { - const backendPath = path.join(appPath, 'amplify', 'backend.ts'); - - console.log(`Fixing user pool region in GraphQL API config in ${backendPath}...`); - - let content: string; - try { - content = await fs.readFile(backendPath, 'utf-8'); - } catch { - console.log(' backend.ts not found, skipping'); - return; - } - - const updated = content.replace( - /userPoolConfig:\s*\{\s*userPoolId:\s*backend\.auth\.resources\.userPool\.userPoolId,?\s*\}/g, - `userPoolConfig: { - userPoolId: backend.auth.resources.userPool.userPoolId, - awsRegion: backend.auth.stack.region, - }`, - ); - - if (updated === content) { - console.log(' No userPoolConfig found to fix, skipping'); - return; - } - - await fs.writeFile(backendPath, updated, 'utf-8'); - console.log(' Added awsRegion to userPoolConfig'); -} - -export async function postGenerate(options: PostGenerateOptions): Promise { - const { appPath } = options; - - console.log(`Running post-generate for backend-only at ${appPath}`); - console.log(''); - - await updateBranchName(appPath); - await convertQuotegeneratorToESM(appPath); - await fixUserPoolRegionInGraphqlApi(appPath); - - console.log(''); - console.log('Post-generate completed'); -} - -const isMainModule = import.meta.url === `file://${process.argv[1]}`; -if (isMainModule) { - const appPath = process.argv[2] || process.cwd(); - const envName = process.argv[3] || 'main'; - - postGenerate({ appPath, envName }).catch((error) => { - console.error('Post-generate failed:', error); - process.exit(1); - }); -} diff --git a/amplify-migration-apps/backend-only/post-refactor.ts b/amplify-migration-apps/backend-only/post-refactor.ts deleted file mode 100644 index 44bdc4e5876..00000000000 --- a/amplify-migration-apps/backend-only/post-refactor.ts +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env npx ts-node -/** - * Post-refactor script for backend-only app. - * - * Applies manual edits required after `amplify gen2-migration refactor`: - * 1. Uncomment s3Bucket.bucketName in amplify/backend.ts to sync with deployed template - */ - -import fs from 'fs/promises'; -import path from 'path'; - -interface PostRefactorOptions { - appPath: string; - envName?: string; -} - -/** - * Uncomment the s3Bucket.bucketName line in backend.ts. - * - * The generate step produces a commented line like: - * // s3Bucket.bucketName = 'bucket-name-here'; - * - * After refactor, we need to uncomment it to sync with the deployed template. - */ -async function uncommentS3BucketName(appPath: string): Promise { - const backendPath = path.join(appPath, 'amplify', 'backend.ts'); - - console.log(`Uncommenting s3Bucket.bucketName in ${backendPath}...`); - - let content: string; - try { - content = await fs.readFile(backendPath, 'utf-8'); - } catch { - console.log(' backend.ts not found, skipping'); - return; - } - - const updated = content.replace( - /\/\/\s*(s3Bucket\.bucketName\s*=\s*['"][^'"]+['"];?)/g, - '$1', - ); - - if (updated === content) { - console.log(' No commented s3Bucket.bucketName found, skipping'); - return; - } - - await fs.writeFile(backendPath, updated, 'utf-8'); - console.log(' Uncommented s3Bucket.bucketName'); -} - -export async function postRefactor(options: PostRefactorOptions): Promise { - const { appPath } = options; - - console.log(`Running post-refactor for backend-only at ${appPath}`); - console.log(''); - - await uncommentS3BucketName(appPath); - - console.log(''); - console.log('Post-refactor completed'); -} - -const isMainModule = import.meta.url === `file://${process.argv[1]}`; -if (isMainModule) { - const appPath = process.argv[2] || process.cwd(); - const envName = process.argv[3] || 'main'; - - postRefactor({ appPath, envName }).catch((error) => { - console.error('Post-refactor failed:', error); - process.exit(1); - }); -} diff --git a/amplify-migration-apps/discussions/README.md b/amplify-migration-apps/discussions/README.md index dffb23cd7d6..46544345f6e 100644 --- a/amplify-migration-apps/discussions/README.md +++ b/amplify-migration-apps/discussions/README.md @@ -407,32 +407,14 @@ git checkout -b gen2-main npx amplify gen2-migration generate ``` -**Edit in `./amplify/data/resource.ts`:** - -```diff -- branchName: "main" -+ branchName: "gen2-main" -``` - -**Edit in `./amplify/storage/fetchuseractivity/index.js`:** - -```diff -- exports.handler = async (event) => { -+ export async function handler(event) { -``` - -**Edit in `./amplify/storage/recorduseractivity/index.js`:** - -```diff -- exports.handler = async (event) => { -+ export async function handler(event) { +```console +npm run post-generate ``` -**Edit in `./src/main.js`:** - -```diff -- import awsconfig from './aws-exports'; -+ import awsconfig from '../amplify_outputs.json'; +```console +rm -rf node_modules package-lock.json +npm install +npm install --package-lock-only ``` ```console diff --git a/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/storage/recorduseractivity/index.js b/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/storage/recorduseractivity/index.js index a2e23eb16c5..76d090eb84a 100644 --- a/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/storage/recorduseractivity/index.js +++ b/amplify-migration-apps/discussions/_snapshot.post.generate/amplify/storage/recorduseractivity/index.js @@ -20,7 +20,7 @@ exports.handler = async (event) => { for (const record of event.Records ?? []) { const eventName = record.eventName; - const image = record.dynamodb.NewImage; + const image = record.dynamodb.NewImage ?? record.dynamodb.OldImage; const createdByUserId = image.createdByUserId.S; const typename = image.__typename.S; const activityType = `${eventName}_${typename}`; diff --git a/amplify-migration-apps/discussions/_snapshot.post.generate/package.json b/amplify-migration-apps/discussions/_snapshot.post.generate/package.json index b5b67a4accd..361bc9f7ada 100644 --- a/amplify-migration-apps/discussions/_snapshot.post.generate/package.json +++ b/amplify-migration-apps/discussions/_snapshot.post.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/discussions", + "name": "@amplify-migration-apps/discussions-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/discussions/_snapshot.pre.generate/amplify/#current-cloud-backend/function/recorduseractivity/src/index.js b/amplify-migration-apps/discussions/_snapshot.pre.generate/amplify/#current-cloud-backend/function/recorduseractivity/src/index.js index a2e23eb16c5..76d090eb84a 100644 --- a/amplify-migration-apps/discussions/_snapshot.pre.generate/amplify/#current-cloud-backend/function/recorduseractivity/src/index.js +++ b/amplify-migration-apps/discussions/_snapshot.pre.generate/amplify/#current-cloud-backend/function/recorduseractivity/src/index.js @@ -20,7 +20,7 @@ exports.handler = async (event) => { for (const record of event.Records ?? []) { const eventName = record.eventName; - const image = record.dynamodb.NewImage; + const image = record.dynamodb.NewImage ?? record.dynamodb.OldImage; const createdByUserId = image.createdByUserId.S; const typename = image.__typename.S; const activityType = `${eventName}_${typename}`; diff --git a/amplify-migration-apps/discussions/_snapshot.pre.generate/amplify/backend/function/recorduseractivity/src/index.js b/amplify-migration-apps/discussions/_snapshot.pre.generate/amplify/backend/function/recorduseractivity/src/index.js index a2e23eb16c5..76d090eb84a 100644 --- a/amplify-migration-apps/discussions/_snapshot.pre.generate/amplify/backend/function/recorduseractivity/src/index.js +++ b/amplify-migration-apps/discussions/_snapshot.pre.generate/amplify/backend/function/recorduseractivity/src/index.js @@ -20,7 +20,7 @@ exports.handler = async (event) => { for (const record of event.Records ?? []) { const eventName = record.eventName; - const image = record.dynamodb.NewImage; + const image = record.dynamodb.NewImage ?? record.dynamodb.OldImage; const createdByUserId = image.createdByUserId.S; const typename = image.__typename.S; const activityType = `${eventName}_${typename}`; diff --git a/amplify-migration-apps/discussions/_snapshot.pre.generate/package.json b/amplify-migration-apps/discussions/_snapshot.pre.generate/package.json index a43104ec77a..3c2f40da14b 100644 --- a/amplify-migration-apps/discussions/_snapshot.pre.generate/package.json +++ b/amplify-migration-apps/discussions/_snapshot.pre.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/discussions", + "name": "@amplify-migration-apps/discussions-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/discussions/backend/configure-schema.sh b/amplify-migration-apps/discussions/backend/configure-schema.sh new file mode 100755 index 00000000000..055d2805864 --- /dev/null +++ b/amplify-migration-apps/discussions/backend/configure-schema.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -euxo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cp -f ${script_dir}/schema.graphql ${script_dir}/../amplify/backend/api/discussions/schema.graphql diff --git a/amplify-migration-apps/discussions/backend/configure.sh b/amplify-migration-apps/discussions/backend/configure.sh new file mode 100755 index 00000000000..c823e9b6e19 --- /dev/null +++ b/amplify-migration-apps/discussions/backend/configure.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -euxo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cp -f ${script_dir}/schema.graphql ${script_dir}/../amplify/backend/api/discussions/schema.graphql +cp -f ${script_dir}/fetchuseractivity.cjs ${script_dir}/../amplify/backend/function/fetchuseractivity/src/index.js +cp -f ${script_dir}/fetchuseractivity.package.json ${script_dir}/../amplify/backend/function/fetchuseractivity/src/package.json +cp -f ${script_dir}/recorduseractivity.cjs ${script_dir}/../amplify/backend/function/recorduseractivity/src/index.js +cp -f ${script_dir}/recorduseractivity.package.json ${script_dir}/../amplify/backend/function/recorduseractivity/src/package.json diff --git a/amplify-migration-apps/discussions/fetchuseractivity.cjs b/amplify-migration-apps/discussions/backend/fetchuseractivity.cjs similarity index 100% rename from amplify-migration-apps/discussions/fetchuseractivity.cjs rename to amplify-migration-apps/discussions/backend/fetchuseractivity.cjs diff --git a/amplify-migration-apps/discussions/fetchuseractivity.package.json b/amplify-migration-apps/discussions/backend/fetchuseractivity.package.json similarity index 100% rename from amplify-migration-apps/discussions/fetchuseractivity.package.json rename to amplify-migration-apps/discussions/backend/fetchuseractivity.package.json diff --git a/amplify-migration-apps/discussions/recorduseractivity.cjs b/amplify-migration-apps/discussions/backend/recorduseractivity.cjs similarity index 94% rename from amplify-migration-apps/discussions/recorduseractivity.cjs rename to amplify-migration-apps/discussions/backend/recorduseractivity.cjs index a2e23eb16c5..76d090eb84a 100644 --- a/amplify-migration-apps/discussions/recorduseractivity.cjs +++ b/amplify-migration-apps/discussions/backend/recorduseractivity.cjs @@ -20,7 +20,7 @@ exports.handler = async (event) => { for (const record of event.Records ?? []) { const eventName = record.eventName; - const image = record.dynamodb.NewImage; + const image = record.dynamodb.NewImage ?? record.dynamodb.OldImage; const createdByUserId = image.createdByUserId.S; const typename = image.__typename.S; const activityType = `${eventName}_${typename}`; diff --git a/amplify-migration-apps/discussions/recorduseractivity.package.json b/amplify-migration-apps/discussions/backend/recorduseractivity.package.json similarity index 100% rename from amplify-migration-apps/discussions/recorduseractivity.package.json rename to amplify-migration-apps/discussions/backend/recorduseractivity.package.json diff --git a/amplify-migration-apps/discussions/schema.graphql b/amplify-migration-apps/discussions/backend/schema.graphql similarity index 100% rename from amplify-migration-apps/discussions/schema.graphql rename to amplify-migration-apps/discussions/backend/schema.graphql diff --git a/amplify-migration-apps/discussions/configure-functions.sh b/amplify-migration-apps/discussions/configure-functions.sh deleted file mode 100755 index 71c47e8aeed..00000000000 --- a/amplify-migration-apps/discussions/configure-functions.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -cp -f fetchuseractivity.cjs ./amplify/backend/function/fetchuseractivity/src/index.js -cp -f fetchuseractivity.package.json ./amplify/backend/function/fetchuseractivity/src/package.json -cp -f recorduseractivity.cjs ./amplify/backend/function/recorduseractivity/src/index.js -cp -f recorduseractivity.package.json ./amplify/backend/function/recorduseractivity/src/package.json diff --git a/amplify-migration-apps/discussions/configure-schema.sh b/amplify-migration-apps/discussions/configure-schema.sh deleted file mode 100755 index 5d853fb5827..00000000000 --- a/amplify-migration-apps/discussions/configure-schema.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -cp -f schema.graphql ./amplify/backend/api/discussions/schema.graphql diff --git a/amplify-migration-apps/discussions/configure.sh b/amplify-migration-apps/discussions/configure.sh deleted file mode 100755 index d48c8296478..00000000000 --- a/amplify-migration-apps/discussions/configure.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -./configure-schema.sh -./configure-functions.sh \ No newline at end of file diff --git a/amplify-migration-apps/discussions/gen1-test-script.ts b/amplify-migration-apps/discussions/gen1-test-script.ts deleted file mode 100644 index 322b36d468e..00000000000 --- a/amplify-migration-apps/discussions/gen1-test-script.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Gen1 Test Script for Discussions App - * - * This script tests all functionality for Amplify Gen1: - * 1. GraphQL Queries (Topics, Posts, Comments) - * 2. Topic CRUD Operations - * 3. Post CRUD Operations - * 4. Comment CRUD Operations - * 5. User Activity Tracking - * 6. S3 Storage (Avatars) - * 7. Bookmarks DDB - * 8. Cleanup (Delete Test Data) - * - * Credentials are provisioned automatically via Cognito AdminCreateUser + AdminSetUserPassword. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import amplifyconfig from './src/amplifyconfiguration.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Discussions App Gen1 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. GraphQL Queries (Topics, Posts, Comments)'); - console.log(' 2. Topic CRUD Operations'); - console.log(' 3. Post CRUD Operations'); - console.log(' 4. Comment CRUD Operations'); - console.log(' 5. User Activity Tracking'); - console.log(' 6. S3 Storage (Avatars)'); - console.log(' 7. Bookmarks DDB'); - console.log(' 8. Cleanup (Delete Test Data)'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - // Sign in from this module so the auth tokens are available to api/storage - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { - runQueryTests, - runTopicMutationTests, - runPostMutationTests, - runCommentMutationTests, - runActivityTests, - runStorageTests, - runBookmarksTests, - runCleanupTests, - } = createTestOrchestrator(testFunctions, runner); - - // Get current user ID for activity tests - const currentUser = await getCurrentUser(); - - // Part 1: Query tests - await runQueryTests(); - - // Part 2: Topic mutations - const topicId = await runTopicMutationTests(); - - // Part 3: Post mutations (requires topic) - let postId: string | null = null; - if (topicId) { - postId = await runPostMutationTests(topicId); - } - - // Part 4: Comment mutations (requires post) - let commentId: string | null = null; - if (postId) { - commentId = await runCommentMutationTests(postId); - } - - // Part 5: Activity tests - await runActivityTests(currentUser.userId); - - // Part 6: S3 Storage (Avatars) - await runStorageTests(); - - // Part 7: Bookmarks DDB (requires a post to bookmark) - if (postId) { - await runBookmarksTests(currentUser.userId, postId); - } - - // Part 8: Cleanup - await runCleanupTests(topicId, postId, commentId); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/discussions/gen2-test-script.ts b/amplify-migration-apps/discussions/gen2-test-script.ts deleted file mode 100644 index d855e407fe6..00000000000 --- a/amplify-migration-apps/discussions/gen2-test-script.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Gen2 Test Script for Discussions App - * - * This script tests all functionality for Amplify Gen2: - * 1. GraphQL Queries (Topics, Posts, Comments) - * 2. Topic CRUD Operations - * 3. Post CRUD Operations - * 4. Comment CRUD Operations - * 5. User Activity Tracking - * 6. S3 Storage (Avatars) - * 7. Bookmarks DDB - * 8. Cleanup (Delete Test Data) - * - * Credentials are provisioned automatically via Cognito AdminCreateUser. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import amplifyconfig from './src/amplify_outputs.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify with Gen2 outputs -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Discussions App Gen2 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. GraphQL Queries (Topics, Posts, Comments)'); - console.log(' 2. Topic CRUD Operations'); - console.log(' 3. Post CRUD Operations'); - console.log(' 4. Comment CRUD Operations'); - console.log(' 5. User Activity Tracking'); - console.log(' 6. S3 Storage (Avatars)'); - console.log(' 7. Bookmarks DDB'); - console.log(' 8. Cleanup (Delete Test Data)'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { - runQueryTests, - runTopicMutationTests, - runPostMutationTests, - runCommentMutationTests, - runActivityTests, - runStorageTests, - runBookmarksTests, - runCleanupTests, - } = createTestOrchestrator(testFunctions, runner); - - // Get current user ID for activity tests - const currentUser = await getCurrentUser(); - - // Part 1: Query tests - await runQueryTests(); - - // Part 2: Topic mutations - const topicId = await runTopicMutationTests(); - - // Part 3: Post mutations (requires topic) - let postId: string | null = null; - if (topicId) { - postId = await runPostMutationTests(topicId); - } - - // Part 4: Comment mutations (requires post) - let commentId: string | null = null; - if (postId) { - commentId = await runCommentMutationTests(postId); - } - - // Part 5: Activity tests - await runActivityTests(currentUser.userId); - - // Part 6: S3 Storage (Avatars) - await runStorageTests(); - - // Part 7: Bookmarks DDB (requires a post to bookmark) - if (postId) { - await runBookmarksTests(currentUser.userId, postId); - } - - // Part 8: Cleanup - await runCleanupTests(topicId, postId, commentId); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/discussions/jest.config.js b/amplify-migration-apps/discussions/jest.config.js new file mode 100644 index 00000000000..e4e58a1f775 --- /dev/null +++ b/amplify-migration-apps/discussions/jest.config.js @@ -0,0 +1,25 @@ +/** @type {import('jest').Config} */ +export default { + testMatch: ['/tests/**/*.test.ts'], + modulePathIgnorePatterns: ['/_snapshot', '/amplify'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + extensionsToTreatAsEsm: ['.ts'], + transform: { + '^.+\\.[tj]s$': ['ts-jest', { + useESM: true, + tsconfig: { + target: 'ES2022', + module: 'ES2022', + moduleResolution: 'node', + esModuleInterop: true, + allowJs: true, + noEmit: true, + strict: true, + skipLibCheck: true, + types: ['node', 'jest'], + }, + }], + }, + testTimeout: 30_000, + setupFilesAfterEnv: ['/tests/jest.setup.ts'], +}; diff --git a/amplify-migration-apps/discussions/migration-config.json b/amplify-migration-apps/discussions/migration-config.json deleted file mode 100644 index a61a2dffa6e..00000000000 --- a/amplify-migration-apps/discussions/migration-config.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "app": { - "name": "discussions", - "description": "Discussion app with phone authentication, DynamoDB activity logging, bookmarks, and S3 avatars", - "framework": "none" - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["API_KEY"], - "customQueries": ["getUserActivity"], - "customMutations": ["logUserActivity"] - }, - "auth": { - "signInMethods": ["phone"], - "socialProviders": [] - }, - "storage": { - "buckets": [ - { - "name": "avatars", - "access": ["auth"] - } - ], - "tables": [ - { - "name": "activity", - "partitionKey": "id", - "sortKey": "userId", - "gsi": [ - { - "name": "byUserId", - "partitionKey": "userId", - "sortKey": "timestamp" - } - ] - }, - { - "name": "bookmarks", - "partitionKey": "userId", - "sortKey": "postId", - "gsi": [ - { - "name": "byPost", - "partitionKey": "postId" - } - ] - } - ] - }, - "function": { - "functions": [ - { - "name": "fetchuseractivity", - "runtime": "nodejs", - "template": "hello-world" - }, - { - "name": "recorduseractivity", - "runtime": "nodejs", - "template": "lambda-trigger", - "trigger": { - "type": "dynamodb-stream", - "source": ["Topic", "Post", "Comment"] - } - } - ] - }, - "hosting": { - "type": "amplify-console" - } - } -} diff --git a/amplify-migration-apps/discussions/migration/post-generate.ts b/amplify-migration-apps/discussions/migration/post-generate.ts new file mode 100644 index 00000000000..78ec9491641 --- /dev/null +++ b/amplify-migration-apps/discussions/migration/post-generate.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env npx ts-node +/** + * Post-generate script for discussions app. + * + * Applies manual edits required after `amplify gen2-migration generate`: + * 1. Update branchName in amplify/data/resource.ts to the value of AWS_BRANCH + * env var, or the current git branch if AWS_BRANCH is not set + * 2. Convert fetchuseractivity function from CommonJS to ESM + * 3. Convert recorduseractivity function from CommonJS to ESM + * 4. Update frontend import from aws-exports to amplify_outputs.json + */ + +import { execSync } from 'child_process'; +import fs from 'fs/promises'; +import path from 'path'; + +function resolveTargetBranch(): string { + if (process.env.AWS_BRANCH) { + return process.env.AWS_BRANCH; + } + return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); +} + +async function updateBranchName(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); + + const content = await fs.readFile(resourcePath, 'utf-8'); + + const targetBranch = resolveTargetBranch(); + + const updated = content.replace( + /branchName:\s*['"]([^'"]+)['"]/, + `branchName: '${targetBranch}'`, + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function convertFunctionToESM(appPath: string, functionName: string): Promise { + const handlerPath = path.join(appPath, 'amplify', 'storage', functionName, 'index.js'); + + const content = await fs.readFile(handlerPath, 'utf-8'); + + let updated = content.replace( + /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + updated = updated.replace( + /module\.exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + await fs.writeFile(handlerPath, updated, 'utf-8'); +} + +async function updateFrontendConfig(appPath: string): Promise { + const mainPath = path.join(appPath, 'src', 'main.js'); + + const content = await fs.readFile(mainPath, 'utf-8'); + + const updated = content.replace( + /from\s*["']\.\/aws-exports["']/g, + "from '../amplify_outputs.json'", + ); + + await fs.writeFile(mainPath, updated, 'utf-8'); +} + +export async function postGenerate(appPath: string): Promise { + await updateBranchName(appPath); + await convertFunctionToESM(appPath, 'fetchuseractivity'); + await convertFunctionToESM(appPath, 'recorduseractivity'); + await updateFrontendConfig(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postGenerate(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/discussions/migration/post-refactor.ts b/amplify-migration-apps/discussions/migration/post-refactor.ts new file mode 100644 index 00000000000..b23710b4e5d --- /dev/null +++ b/amplify-migration-apps/discussions/migration/post-refactor.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env npx ts-node +/** + * Post-refactor script for discussions app. + * + * Applies manual edits required after `amplify gen2-migration refactor`: + * 1. Add tableName to the DynamoDB table definition in backend.ts + * This ensures the refactored table keeps its original name. + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function addTableNameToActivityTable(appPath: string, envName: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + const content = await fs.readFile(backendPath, 'utf-8'); + + const tableName = `activity-${envName}`; + + const updated = content.replace( + /new Table\(storageStack,\s*["']activity["'],\s*\{\s*partitionKey:/g, + `new Table(storageStack, "activity", { tableName: "${tableName}", partitionKey:`, + ); + + await fs.writeFile(backendPath, updated, 'utf-8'); +} + +export async function postRefactor(appPath: string, envName = 'main'): Promise { + await addTableNameToActivityTable(appPath, envName); +} + +async function main(): Promise { + const [appPath = process.cwd(), envName] = process.argv.slice(2); + await postRefactor(appPath, envName); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/discussions/package.json b/amplify-migration-apps/discussions/package.json index a43104ec77a..5e7cca8e013 100644 --- a/amplify-migration-apps/discussions/package.json +++ b/amplify-migration-apps/discussions/package.json @@ -10,13 +10,25 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "configure": "./configure.sh", - "configure-schema": "./configure-schema.sh", - "configure-functions": "./configure-functions.sh", + "configure": "./backend/configure.sh", + "configure-schema": "./backend/configure-schema.sh", "sanitize": "tsx ../sanitize.ts", - "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit" + "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit", + "test:gen1": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-src/amplifyconfiguration.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:gen2": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-amplify_outputs.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:e2e": "cd ../../packages/amplify-gen2-migration-e2e-system && npx tsx src/cli.ts --app discussions --profile ${AWS_PROFILE:-default}", + "pre-push": "true", + "post-generate": "npx tsx migration/post-generate.ts", + "post-refactor": "npx tsx migration/post-refactor.ts", + "post-sandbox": "true", + "pre-sandbox": "true", + "post-push": "true" }, "devDependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.936.0", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "ts-jest": "^29.3.4", "vite": "^7.2.2" }, "dependencies": { diff --git a/amplify-migration-apps/discussions/post-generate.ts b/amplify-migration-apps/discussions/post-generate.ts deleted file mode 100644 index 8c19f9a11b7..00000000000 --- a/amplify-migration-apps/discussions/post-generate.ts +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env npx ts-node -/** - * Post-generate script for discussions app. - * - * Applies manual edits required after `amplify gen2-migration generate`: - * 1. Update branchName in amplify/data/resource.ts to "sandbox" - * 2. Convert fetchuseractivity function from CommonJS to ESM - * 3. Convert recorduseractivity function from CommonJS to ESM - * 4. Update frontend import from aws-exports to amplify_outputs.json - */ - -import fs from 'fs/promises'; -import path from 'path'; - -interface PostGenerateOptions { - appPath: string; - envName?: string; -} - -async function updateBranchName(appPath: string): Promise { - const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); - - console.log(`Updating branchName in ${resourcePath}...`); - - let content: string; - try { - content = await fs.readFile(resourcePath, 'utf-8'); - } catch { - console.log(' resource.ts not found, skipping'); - return; - } - - // For sandbox deployments, Gen2 hardcodes the branch lookup to 'sandbox' - const targetBranch = 'sandbox'; - - const branchNameMatch = content.match(/branchName:\s*['"]([^'"]+)['"]/); - if (branchNameMatch) { - console.log(` Found branchName: '${branchNameMatch[1]}'`); - } else { - console.log(' WARNING: No branchName property found'); - return; - } - - const updated = content.replace(/branchName:\s*['"]([^'"]+)['"]/, `branchName: '${targetBranch}'`); - - if (updated === content) { - console.log(' No branchName found to update, skipping'); - return; - } - - await fs.writeFile(resourcePath, updated, 'utf-8'); - console.log(` Updated branchName to "${targetBranch}"`); -} - -async function convertFunctionToESM(appPath: string, functionName: string): Promise { - // Gen2 migration puts functions in amplify/function/ (singular) - const handlerPath = path.join(appPath, 'amplify', 'function', functionName, 'index.js'); - - console.log(`Converting ${functionName} to ESM in ${handlerPath}...`); - - let content: string; - try { - content = await fs.readFile(handlerPath, 'utf-8'); - } catch { - console.log(' index.js not found, skipping'); - return; - } - - // Convert exports.handler = async (event) => { to export async function handler(event) { - let updated = content.replace(/exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, 'export async function handler($1) {'); - - // Also handle module.exports pattern - updated = updated.replace(/module\.exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, 'export async function handler($1) {'); - - if (updated === content) { - console.log(' No CommonJS exports found, skipping'); - return; - } - - await fs.writeFile(handlerPath, updated, 'utf-8'); - console.log(' Converted to ESM syntax'); -} - -async function updateFrontendConfig(appPath: string): Promise { - const mainPath = path.join(appPath, 'src', 'main.js'); - - console.log(`Updating frontend config import in ${mainPath}...`); - - let content: string; - try { - content = await fs.readFile(mainPath, 'utf-8'); - } catch { - console.log(' main.js not found, skipping'); - return; - } - - // Change: import awsconfig from './aws-exports'; - // To: import awsconfig from '../amplify_outputs.json'; - const updated = content.replace(/from\s*["']\.\/aws-exports["']/g, "from '../amplify_outputs.json'"); - - if (updated === content) { - console.log(' No aws-exports import found, skipping'); - return; - } - - await fs.writeFile(mainPath, updated, 'utf-8'); - console.log(' Updated import to amplify_outputs.json'); -} - -export async function postGenerate(options: PostGenerateOptions): Promise { - const { appPath } = options; - - console.log(`Running post-generate for discussions at ${appPath}`); - console.log(''); - - await updateBranchName(appPath); - await convertFunctionToESM(appPath, 'fetchuseractivity'); - await convertFunctionToESM(appPath, 'recorduseractivity'); - await updateFrontendConfig(appPath); - - console.log(''); - console.log('Post-generate completed'); -} - -// CLI entry point -const isMainModule = import.meta.url === `file://${process.argv[1]}`; -if (isMainModule) { - const appPath = process.argv[2] || process.cwd(); - const envName = process.argv[3] || 'main'; - - postGenerate({ appPath, envName }).catch((error) => { - console.error('Post-generate failed:', error); - process.exit(1); - }); -} diff --git a/amplify-migration-apps/discussions/post-refactor.ts b/amplify-migration-apps/discussions/post-refactor.ts deleted file mode 100644 index ec44c27aea8..00000000000 --- a/amplify-migration-apps/discussions/post-refactor.ts +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env npx ts-node -/** - * Post-refactor script for discussions app. - * - * Applies manual edits required after `amplify gen2-migration refactor`: - * 1. Add tableName to the DynamoDB table definition in backend.ts - * This ensures the refactored table keeps its original name. - */ - -import fs from 'fs/promises'; -import path from 'path'; - -interface PostRefactorOptions { - appPath: string; - envName?: string; -} - -async function addTableNameToActivityTable(appPath: string, envName: string): Promise { - const backendPath = path.join(appPath, 'amplify', 'backend.ts'); - - console.log(`Adding tableName to activity table in ${backendPath}...`); - - let content: string; - try { - content = await fs.readFile(backendPath, 'utf-8'); - } catch { - console.log(' backend.ts not found, skipping'); - return; - } - - // The generated code creates a Table without tableName: - // const activity = new Table(storageStack, "activity", { partitionKey: ... - // We need to add tableName: "activity-" to preserve the original table name - const tableName = `activity-${envName}`; - - // Pattern: new Table(storageStack, "activity", { partitionKey: - // Insert tableName right after the opening brace - const updated = content.replace( - /new Table\(storageStack,\s*["']activity["'],\s*\{\s*partitionKey:/g, - `new Table(storageStack, "activity", { tableName: "${tableName}", partitionKey:`, - ); - - if (updated === content) { - console.log(' No activity table definition found to update, skipping'); - return; - } - - await fs.writeFile(backendPath, updated, 'utf-8'); - console.log(` Added tableName: "${tableName}" to activity table`); -} - -export async function postRefactor(options: PostRefactorOptions): Promise { - const { appPath, envName = 'main' } = options; - - console.log(`Running post-refactor for discussions at ${appPath}`); - console.log(`Using envName: ${envName}`); - console.log(''); - - await addTableNameToActivityTable(appPath, envName); - - console.log(''); - console.log('Post-refactor completed'); -} - -// CLI entry point -const isMainModule = import.meta.url === `file://${process.argv[1]}`; -if (isMainModule) { - const appPath = process.argv[2] || process.cwd(); - const envName = process.argv[3] || 'main'; - - postRefactor({ appPath, envName }).catch((error) => { - console.error('Post-refactor failed:', error); - process.exit(1); - }); -} diff --git a/amplify-migration-apps/discussions/test-utils.ts b/amplify-migration-apps/discussions/test-utils.ts deleted file mode 100644 index df02dbed4ce..00000000000 --- a/amplify-migration-apps/discussions/test-utils.ts +++ /dev/null @@ -1,611 +0,0 @@ -// test-utils.ts - -import { Amplify } from 'aws-amplify'; -import { generateClient } from 'aws-amplify/api'; -import { getCurrentUser } from 'aws-amplify/auth'; -import { uploadData, getUrl, remove } from 'aws-amplify/storage'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient, PutCommand, GetCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; -import { getTopic, listTopics, getPost, listPosts, getComment, listComments, fetchUserActivity } from './src/graphql/queries'; -import { - createTopic, - updateTopic, - deleteTopic, - createPost, - updatePost, - deletePost, - createComment, - updateComment, - deleteComment, -} from './src/graphql/mutations'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import amplifyconfig from './src/amplifyconfiguration.json'; - -// Configure Amplify in this module to ensure api/storage singletons see the config -Amplify.configure(amplifyconfig); - -// ============================================================ -// Shared Test Functions Factory -// ============================================================ - -export function createTestFunctions() { - const authClient = generateClient(); - - // ============================================================ - // Query Test Functions - // ============================================================ - - async function testListTopics(): Promise { - console.log('\n📋 Testing listTopics...'); - const result = await authClient.graphql({ query: listTopics }); - const topics = (result as any).data.listTopics.items; - console.log(`✅ Found ${topics.length} topics:`); - topics.slice(0, 5).forEach((t: any) => { - const topicName = t.content.includes(':') ? t.content.split(':')[1] : t.content; - const category = t.content.includes(':') ? t.content.split(':')[0] : 'unknown'; - console.log(` - [${t.id.substring(0, 8)}...] ${topicName} (${category})`); - }); - if (topics.length > 5) console.log(` ... and ${topics.length - 5} more`); - return topics.length > 0 ? topics[0].id : null; - } - - async function testGetTopic(id: string): Promise { - console.log(`\n🔍 Testing getTopic (id: ${id.substring(0, 8)}...)...`); - const result = await authClient.graphql({ - query: getTopic, - variables: { id }, - }); - const topic = (result as any).data.getTopic; - console.log('✅ Topic:', { - id: topic.id.substring(0, 8) + '...', - content: topic.content, - createdBy: topic.createdByUserId, - }); - } - - async function testListPosts(topicId?: string): Promise { - console.log('\n📋 Testing listPosts...'); - const variables: any = {}; - if (topicId) { - variables.filter = { topicPostsId: { eq: topicId } }; - console.log(` Filtering by topic: ${topicId.substring(0, 8)}...`); - } - - const result = await authClient.graphql({ query: listPosts, variables }); - const posts = (result as any).data.listPosts.items; - console.log(`✅ Found ${posts.length} posts:`); - posts.slice(0, 5).forEach((p: any) => { - const preview = p.content?.substring(0, 50) || '(no content)'; - console.log(` - [${p.id.substring(0, 8)}...] ${preview}${p.content?.length > 50 ? '...' : ''}`); - }); - if (posts.length > 5) console.log(` ... and ${posts.length - 5} more`); - return posts.length > 0 ? posts[0].id : null; - } - - async function testGetPost(id: string): Promise { - console.log(`\n🔍 Testing getPost (id: ${id.substring(0, 8)}...)...`); - const result = await authClient.graphql({ - query: getPost, - variables: { id }, - }); - const post = (result as any).data.getPost; - console.log('✅ Post:', { - id: post.id.substring(0, 8) + '...', - content: post.content?.substring(0, 100) + (post.content?.length > 100 ? '...' : ''), - topicId: post.topicPostsId?.substring(0, 8) + '...', - createdBy: post.createdByUserId, - }); - } - - async function testListComments(postId?: string): Promise { - console.log('\n📋 Testing listComments...'); - const variables: any = {}; - if (postId) { - variables.filter = { postCommentsId: { eq: postId } }; - console.log(` Filtering by post: ${postId.substring(0, 8)}...`); - } - - const result = await authClient.graphql({ query: listComments, variables }); - const comments = (result as any).data.listComments.items; - console.log(`✅ Found ${comments.length} comments:`); - comments.slice(0, 5).forEach((c: any) => { - const preview = c.content?.substring(0, 50) || '(no content)'; - console.log(` - [${c.id.substring(0, 8)}...] ${preview}${c.content?.length > 50 ? '...' : ''}`); - }); - if (comments.length > 5) console.log(` ... and ${comments.length - 5} more`); - return comments.length > 0 ? comments[0].id : null; - } - - async function testGetComment(id: string): Promise { - console.log(`\n🔍 Testing getComment (id: ${id.substring(0, 8)}...)...`); - const result = await authClient.graphql({ - query: getComment, - variables: { id }, - }); - const comment = (result as any).data.getComment; - console.log('✅ Comment:', { - id: comment.id.substring(0, 8) + '...', - content: comment.content?.substring(0, 100) + (comment.content?.length > 100 ? '...' : ''), - postId: comment.postCommentsId?.substring(0, 8) + '...', - createdBy: comment.createdByUserId, - }); - } - - async function testFetchUserActivity(userId: string): Promise { - console.log(`\n📊 Testing fetchUserActivity (userId: ${userId.substring(0, 8)}...)...`); - const result = await authClient.graphql({ - query: fetchUserActivity, - variables: { userId }, - }); - const activities = (result as any).data.fetchUserActivity || []; - console.log(`✅ Found ${activities.length} activities:`); - activities.slice(0, 10).forEach((a: any) => { - const time = new Date(a.timestamp).toLocaleString(); - console.log(` - ${a.activityType.replace('_', ' ').toUpperCase()} at ${time}`); - }); - if (activities.length > 10) console.log(` ... and ${activities.length - 10} more`); - } - - // ============================================================ - // Mutation Test Functions - // ============================================================ - - async function testCreateTopic(): Promise { - console.log('\n🆕 Testing createTopic...'); - const publicClient = generateClient({ authMode: 'apiKey' }); - const currentUser = await getCurrentUser(); - - const topicName = `Test Topic ${Date.now()}`; - const content = `tech:${topicName}`; - - const result = await publicClient.graphql({ - query: createTopic, - variables: { - input: { - content, - createdByUserId: currentUser.userId, - }, - }, - }); - const topic = (result as any).data.createTopic; - console.log('✅ Created topic:', { - id: topic.id.substring(0, 8) + '...', - content: topic.content, - createdBy: topic.createdByUserId, - }); - return topic.id; - } - - async function testUpdateTopic(topicId: string): Promise { - console.log(`\n✏️ Testing updateTopic (id: ${topicId.substring(0, 8)}...)...`); - const publicClient = generateClient({ authMode: 'apiKey' }); - - const result = await publicClient.graphql({ - query: updateTopic, - variables: { - input: { - id: topicId, - content: `tech:Updated Topic ${Date.now()}`, - }, - }, - }); - const topic = (result as any).data.updateTopic; - console.log('✅ Updated topic:', { - id: topic.id.substring(0, 8) + '...', - content: topic.content, - }); - } - - async function testDeleteTopic(topicId: string): Promise { - console.log(`\n🗑️ Testing deleteTopic (id: ${topicId.substring(0, 8)}...)...`); - const publicClient = generateClient({ authMode: 'apiKey' }); - - const result = await publicClient.graphql({ - query: deleteTopic, - variables: { input: { id: topicId } }, - }); - const deleted = (result as any).data.deleteTopic; - console.log('✅ Deleted topic:', deleted.content); - } - - async function testCreatePost(topicId: string): Promise { - console.log('\n🆕 Testing createPost...'); - const publicClient = generateClient({ authMode: 'apiKey' }); - const currentUser = await getCurrentUser(); - - const result = await publicClient.graphql({ - query: createPost, - variables: { - input: { - content: `This is a test post created at ${new Date().toISOString()}. Testing the discussions app functionality!`, - topicPostsId: topicId, - createdByUserId: currentUser.userId, - }, - }, - }); - const post = (result as any).data.createPost; - console.log('✅ Created post:', { - id: post.id.substring(0, 8) + '...', - content: post.content.substring(0, 50) + '...', - topicId: post.topicPostsId?.substring(0, 8) + '...', - createdBy: post.createdByUserId, - }); - return post.id; - } - - async function testUpdatePost(postId: string): Promise { - console.log(`\n✏️ Testing updatePost (id: ${postId.substring(0, 8)}...)...`); - const publicClient = generateClient({ authMode: 'apiKey' }); - - const result = await publicClient.graphql({ - query: updatePost, - variables: { - input: { - id: postId, - content: `This post was updated at ${new Date().toISOString()}. Update test successful!`, - }, - }, - }); - const post = (result as any).data.updatePost; - console.log('✅ Updated post:', { - id: post.id.substring(0, 8) + '...', - content: post.content.substring(0, 50) + '...', - }); - } - - async function testDeletePost(postId: string): Promise { - console.log(`\n🗑️ Testing deletePost (id: ${postId.substring(0, 8)}...)...`); - const publicClient = generateClient({ authMode: 'apiKey' }); - - const result = await publicClient.graphql({ - query: deletePost, - variables: { input: { id: postId } }, - }); - const deleted = (result as any).data.deletePost; - console.log('✅ Deleted post:', deleted.content?.substring(0, 30) + '...'); - } - - async function testCreateComment(postId: string): Promise { - console.log('\n🆕 Testing createComment...'); - const publicClient = generateClient({ authMode: 'apiKey' }); - const currentUser = await getCurrentUser(); - - const result = await publicClient.graphql({ - query: createComment, - variables: { - input: { - content: `This is a test comment created at ${new Date().toISOString()}`, - postCommentsId: postId, - createdByUserId: currentUser.userId, - }, - }, - }); - const comment = (result as any).data.createComment; - console.log('✅ Created comment:', { - id: comment.id.substring(0, 8) + '...', - content: comment.content.substring(0, 50) + '...', - postId: comment.postCommentsId?.substring(0, 8) + '...', - createdBy: comment.createdByUserId, - }); - return comment.id; - } - - async function testUpdateComment(commentId: string): Promise { - console.log(`\n✏️ Testing updateComment (id: ${commentId.substring(0, 8)}...)...`); - const publicClient = generateClient({ authMode: 'apiKey' }); - - const result = await publicClient.graphql({ - query: updateComment, - variables: { - input: { - id: commentId, - content: `This comment was updated at ${new Date().toISOString()}`, - }, - }, - }); - const comment = (result as any).data.updateComment; - console.log('✅ Updated comment:', { - id: comment.id.substring(0, 8) + '...', - content: comment.content.substring(0, 50) + '...', - }); - } - - async function testDeleteComment(commentId: string): Promise { - console.log(`\n🗑️ Testing deleteComment (id: ${commentId.substring(0, 8)}...)...`); - const publicClient = generateClient({ authMode: 'apiKey' }); - - const result = await publicClient.graphql({ - query: deleteComment, - variables: { input: { id: commentId } }, - }); - const deleted = (result as any).data.deleteComment; - console.log('✅ Deleted comment:', deleted.content?.substring(0, 30) + '...'); - } - - // ============================================================ - // S3 Avatar Test Functions - // ============================================================ - - async function testUploadAvatar(): Promise { - console.log('\n📤 Testing uploadData (S3 avatar upload)...'); - // 1x1 transparent PNG - const testImageBase64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; - const imageBuffer = Buffer.from(testImageBase64, 'base64'); - const fileName = `test-avatar-${Date.now()}.png`; - - console.log(` Uploading to: ${fileName}`); - console.log(` File size: ${imageBuffer.length} bytes`); - - const result = await uploadData({ - key: fileName, - data: imageBuffer, - options: { contentType: 'image/png' }, - }).result; - - console.log('✅ Upload successful!'); - console.log(' Key:', result.key); - return result.key; - } - - async function testGetAvatarUrl(avatarKey: string): Promise { - console.log(`\n🔗 Testing getUrl (S3 signed URL)...`); - console.log(` Avatar key: ${avatarKey}`); - - const result = await getUrl({ - key: avatarKey, - options: { expiresIn: 3600 }, - }); - console.log('✅ Got signed URL!'); - console.log(' URL:', result.url.toString().substring(0, 80) + '...'); - return result.url.toString(); - } - - async function testRemoveAvatar(avatarKey: string): Promise { - console.log(`\n🗑️ Testing remove (S3 avatar delete)...`); - console.log(` Avatar key: ${avatarKey}`); - - await remove({ key: avatarKey }); - console.log('✅ Avatar removed successfully!'); - } - - // ============================================================ - // Bookmarks DDB Test Functions - // ============================================================ - - const ddbClient = DynamoDBDocumentClient.from( - new DynamoDBClient({ region: (amplifyconfig as any).aws_project_region }), - ); - - async function testCreateBookmark(tableName: string, userId: string, postId: string): Promise { - console.log('\n🔖 Testing PutItem (create bookmark)...'); - console.log(` Table: ${tableName}`); - console.log(` userId: ${userId.substring(0, 8)}..., postId: ${postId.substring(0, 8)}...`); - - await ddbClient.send( - new PutCommand({ - TableName: tableName, - Item: { userId, postId, createdAt: new Date().toISOString() }, - }), - ); - console.log('✅ Bookmark created!'); - } - - async function testGetBookmark(tableName: string, userId: string, postId: string): Promise { - console.log('\n🔖 Testing GetItem (read bookmark)...'); - console.log(` Table: ${tableName}`); - console.log(` userId: ${userId.substring(0, 8)}..., postId: ${postId.substring(0, 8)}...`); - - const result = await ddbClient.send( - new GetCommand({ - TableName: tableName, - Key: { userId, postId }, - }), - ); - if (!result.Item) { - throw new Error('Bookmark not found'); - } - console.log('✅ Bookmark found:', { - userId: result.Item.userId.substring(0, 8) + '...', - postId: result.Item.postId.substring(0, 8) + '...', - createdAt: result.Item.createdAt, - }); - } - - async function testDeleteBookmark(tableName: string, userId: string, postId: string): Promise { - console.log('\n🗑️ Testing DeleteItem (remove bookmark)...'); - console.log(` Table: ${tableName}`); - console.log(` userId: ${userId.substring(0, 8)}..., postId: ${postId.substring(0, 8)}...`); - - await ddbClient.send( - new DeleteCommand({ - TableName: tableName, - Key: { userId, postId }, - }), - ); - console.log('✅ Bookmark deleted!'); - } - - return { - testListTopics, - testGetTopic, - testListPosts, - testGetPost, - testListComments, - testGetComment, - testFetchUserActivity, - testCreateTopic, - testUpdateTopic, - testDeleteTopic, - testCreatePost, - testUpdatePost, - testDeletePost, - testCreateComment, - testUpdateComment, - testDeleteComment, - testUploadAvatar, - testGetAvatarUrl, - testRemoveAvatar, - testCreateBookmark, - testGetBookmark, - testDeleteBookmark, - }; -} - -function getBookmarksTableName(): string { - const schemas = (amplifyconfig as any).aws_dynamodb_table_schemas ?? []; - const entry = schemas.find((s: any) => s.tableName?.startsWith('bookmarks-')); - if (!entry) { - throw new Error('No bookmarks table found in amplifyconfiguration.json'); - } - return entry.tableName; -} - -// ============================================================ -// Shared Test Orchestration Functions -// ============================================================ - -export function createTestOrchestrator(testFunctions: ReturnType, runner: TestRunner) { - async function runQueryTests(): Promise<{ topicId: string | null; postId: string | null; commentId: string | null }> { - console.log('\n' + '='.repeat(60)); - console.log('📖 PART 1: GraphQL Queries'); - console.log('='.repeat(60)); - - // Test Topics - const topicId = await runner.runTest('listTopics', testFunctions.testListTopics); - if (topicId) await runner.runTest('getTopic', () => testFunctions.testGetTopic(topicId)); - - // Test Posts - const postId = await runner.runTest('listPosts', testFunctions.testListPosts); - if (postId) await runner.runTest('getPost', () => testFunctions.testGetPost(postId)); - - // Test Posts filtered by topic - if (topicId) { - console.log('\n--- Testing posts filtered by topic ---'); - await runner.runTest('listPostsByTopic', () => testFunctions.testListPosts(topicId)); - } - - // Test Comments - const commentId = await runner.runTest('listComments', testFunctions.testListComments); - if (commentId) await runner.runTest('getComment', () => testFunctions.testGetComment(commentId)); - - // Test Comments filtered by post - if (postId) { - console.log('\n--- Testing comments filtered by post ---'); - await runner.runTest('listCommentsByPost', () => testFunctions.testListComments(postId)); - } - - return { topicId, postId, commentId }; - } - - async function runTopicMutationTests(): Promise { - console.log('\n' + '='.repeat(60)); - console.log('📝 PART 2: Topic CRUD Operations'); - console.log('='.repeat(60)); - - const topicId = await runner.runTest('createTopic', testFunctions.testCreateTopic); - if (!topicId) { - console.log('❌ Failed to create topic, skipping remaining topic tests'); - return null; - } - - await runner.runTest('getTopic (verify create)', () => testFunctions.testGetTopic(topicId)); - await runner.runTest('updateTopic', () => testFunctions.testUpdateTopic(topicId)); - await runner.runTest('getTopic (verify update)', () => testFunctions.testGetTopic(topicId)); - - return topicId; - } - - async function runPostMutationTests(topicId: string): Promise { - console.log('\n' + '='.repeat(60)); - console.log('💬 PART 3: Post CRUD Operations'); - console.log('='.repeat(60)); - - const postId = await runner.runTest('createPost', () => testFunctions.testCreatePost(topicId)); - if (!postId) { - console.log('❌ Failed to create post, skipping remaining post tests'); - return null; - } - - await runner.runTest('getPost (verify create)', () => testFunctions.testGetPost(postId)); - await runner.runTest('updatePost', () => testFunctions.testUpdatePost(postId)); - await runner.runTest('getPost (verify update)', () => testFunctions.testGetPost(postId)); - await runner.runTest('listPosts (for topic)', () => testFunctions.testListPosts(topicId)); - - return postId; - } - - async function runCommentMutationTests(postId: string): Promise { - console.log('\n' + '='.repeat(60)); - console.log('💭 PART 4: Comment CRUD Operations'); - console.log('='.repeat(60)); - - const commentId = await runner.runTest('createComment', () => testFunctions.testCreateComment(postId)); - if (!commentId) { - console.log('❌ Failed to create comment, skipping remaining comment tests'); - return null; - } - - await runner.runTest('getComment (verify create)', () => testFunctions.testGetComment(commentId)); - await runner.runTest('updateComment', () => testFunctions.testUpdateComment(commentId)); - await runner.runTest('getComment (verify update)', () => testFunctions.testGetComment(commentId)); - await runner.runTest('listComments (for post)', () => testFunctions.testListComments(postId)); - - return commentId; - } - - async function runActivityTests(userId: string): Promise { - console.log('\n' + '='.repeat(60)); - console.log('📊 PART 5: User Activity'); - console.log('='.repeat(60)); - - await runner.runTest('fetchUserActivity', () => testFunctions.testFetchUserActivity(userId)); - } - - async function runStorageTests(): Promise { - console.log('\n' + '='.repeat(60)); - console.log('📸 PART 6: S3 Storage (Avatars)'); - console.log('='.repeat(60)); - - const avatarKey = await runner.runTest('uploadAvatar', testFunctions.testUploadAvatar); - if (avatarKey) { - await runner.runTest('getAvatarUrl', () => testFunctions.testGetAvatarUrl(avatarKey)); - await runner.runTest('removeAvatar', () => testFunctions.testRemoveAvatar(avatarKey)); - } - } - - async function runBookmarksTests(userId: string, postId: string): Promise { - console.log('\n' + '='.repeat(60)); - console.log('🔖 PART 7: Bookmarks DDB'); - console.log('='.repeat(60)); - - const tableName = getBookmarksTableName(); - console.log(` Bookmarks table: ${tableName}`); - - await runner.runTest('createBookmark', () => testFunctions.testCreateBookmark(tableName, userId, postId)); - await runner.runTest('getBookmark', () => testFunctions.testGetBookmark(tableName, userId, postId)); - await runner.runTest('deleteBookmark', () => testFunctions.testDeleteBookmark(tableName, userId, postId)); - } - - async function runCleanupTests(topicId: string | null, postId: string | null, commentId: string | null): Promise { - console.log('\n' + '='.repeat(60)); - console.log('🧹 PART 8: Cleanup (Delete Test Data)'); - console.log('='.repeat(60)); - - // Delete in reverse order of creation (comments -> posts -> topics) - if (commentId) await runner.runTest('deleteComment', () => testFunctions.testDeleteComment(commentId)); - if (postId) await runner.runTest('deletePost', () => testFunctions.testDeletePost(postId)); - if (topicId) await runner.runTest('deleteTopic', () => testFunctions.testDeleteTopic(topicId)); - } - - return { - runQueryTests, - runTopicMutationTests, - runPostMutationTests, - runCommentMutationTests, - runActivityTests, - runStorageTests, - runBookmarksTests, - runCleanupTests, - }; -} diff --git a/amplify-migration-apps/discussions/tests/api.test.ts b/amplify-migration-apps/discussions/tests/api.test.ts new file mode 100644 index 00000000000..0f507ddb3dd --- /dev/null +++ b/amplify-migration-apps/discussions/tests/api.test.ts @@ -0,0 +1,364 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { generateClient } from 'aws-amplify/api'; +import { getCurrentUser, signIn, signOut } from 'aws-amplify/auth'; +import { signUp, config } from './signup'; +import { + getTopic, listTopics, + getPost, listPosts, + getComment, listComments, + fetchUserActivity, +} from '../src/graphql/queries'; +import { + createTopic, updateTopic, deleteTopic, + createPost, updatePost, deletePost, + createComment, updateComment, deleteComment, +} from '../src/graphql/mutations'; + +const client = () => generateClient({ authMode: 'apiKey' }); + +let username: string; +let password: string; + +beforeAll(async () => { + const creds = await signUp(config); + username = creds.username; + password = creds.password; + await signIn({ username, password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('Topic', () => { + it('creates a topic with correct fields', async () => { + const currentUser = await getCurrentUser(); + const content = `tech:Test Topic ${Date.now()}`; + + const result = await client().graphql({ + query: createTopic, + variables: { input: { content, createdByUserId: currentUser.userId } }, + }); + const topic = (result as any).data.createTopic; + + expect(typeof topic.id).toBe('string'); + expect(topic.id.length).toBeGreaterThan(0); + expect(topic.content).toBe(content); + expect(topic.createdByUserId).toBe(currentUser.userId); + expect(topic.createdAt).toBeDefined(); + expect(topic.updatedAt).toBeDefined(); + }); + + it('reads a topic by id', async () => { + const currentUser = await getCurrentUser(); + const content = `tech:Read Topic ${Date.now()}`; + + const createResult = await client().graphql({ + query: createTopic, + variables: { input: { content, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createTopic; + + const getResult = await client().graphql({ query: getTopic, variables: { id: created.id } }); + const fetched = (getResult as any).data.getTopic; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.content).toBe(content); + expect(fetched.createdByUserId).toBe(currentUser.userId); + }); + + it('updates a topic and persists changes', async () => { + const currentUser = await getCurrentUser(); + + const createResult = await client().graphql({ + query: createTopic, + variables: { input: { content: `tech:Original ${Date.now()}`, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createTopic; + + const updatedContent = `tech:Updated Topic ${Date.now()}`; + await client().graphql({ + query: updateTopic, + variables: { input: { id: created.id, content: updatedContent } }, + }); + + const getResult = await client().graphql({ query: getTopic, variables: { id: created.id } }); + const fetched = (getResult as any).data.getTopic; + + expect(fetched.content).toBe(updatedContent); + }); + + it('deletes a topic', async () => { + const currentUser = await getCurrentUser(); + + const createResult = await client().graphql({ + query: createTopic, + variables: { input: { content: `tech:Delete Me ${Date.now()}`, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createTopic; + + await client().graphql({ query: deleteTopic, variables: { input: { id: created.id } } }); + + const getResult = await client().graphql({ query: getTopic, variables: { id: created.id } }); + expect((getResult as any).data.getTopic).toBeNull(); + }); + + it('lists topics including a newly created one', async () => { + const currentUser = await getCurrentUser(); + const content = `tech:List Topic ${Date.now()}`; + + const createResult = await client().graphql({ + query: createTopic, + variables: { input: { content, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createTopic; + + const listResult = await client().graphql({ query: listTopics }); + const items = (listResult as any).data.listTopics.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((t: any) => t.id === created.id); + expect(found).toBeDefined(); + expect(found.content).toBe(content); + }); +}); + +describe('Post', () => { + async function createParentTopic(): Promise { + const currentUser = await getCurrentUser(); + const result = await client().graphql({ + query: createTopic, + variables: { input: { content: `tech:Post Parent ${Date.now()}`, createdByUserId: currentUser.userId } }, + }); + return (result as any).data.createTopic.id; + } + + it('creates a post linked to a topic', async () => { + const topicId = await createParentTopic(); + const currentUser = await getCurrentUser(); + const content = `Test post created at ${new Date().toISOString()}`; + + const result = await client().graphql({ + query: createPost, + variables: { input: { content, topicPostsId: topicId, createdByUserId: currentUser.userId } }, + }); + const post = (result as any).data.createPost; + + expect(typeof post.id).toBe('string'); + expect(post.id.length).toBeGreaterThan(0); + expect(post.content).toBe(content); + expect(post.topicPostsId).toBe(topicId); + expect(post.createdByUserId).toBe(currentUser.userId); + expect(post.createdAt).toBeDefined(); + expect(post.updatedAt).toBeDefined(); + }); + + it('reads a post by id', async () => { + const topicId = await createParentTopic(); + const currentUser = await getCurrentUser(); + const content = `Read post ${Date.now()}`; + + const createResult = await client().graphql({ + query: createPost, + variables: { input: { content, topicPostsId: topicId, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createPost; + + const getResult = await client().graphql({ query: getPost, variables: { id: created.id } }); + const fetched = (getResult as any).data.getPost; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.content).toBe(content); + expect(fetched.topicPostsId).toBe(topicId); + }); + + it('updates a post and persists changes', async () => { + const topicId = await createParentTopic(); + const currentUser = await getCurrentUser(); + + const createResult = await client().graphql({ + query: createPost, + variables: { input: { content: 'Original post', topicPostsId: topicId, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createPost; + + const updatedContent = `Updated post at ${new Date().toISOString()}`; + await client().graphql({ + query: updatePost, + variables: { input: { id: created.id, content: updatedContent } }, + }); + + const getResult = await client().graphql({ query: getPost, variables: { id: created.id } }); + const fetched = (getResult as any).data.getPost; + + expect(fetched.content).toBe(updatedContent); + }); + + it('deletes a post', async () => { + const topicId = await createParentTopic(); + const currentUser = await getCurrentUser(); + + const createResult = await client().graphql({ + query: createPost, + variables: { input: { content: 'Delete me', topicPostsId: topicId, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createPost; + + await client().graphql({ query: deletePost, variables: { input: { id: created.id } } }); + + const getResult = await client().graphql({ query: getPost, variables: { id: created.id } }); + expect((getResult as any).data.getPost).toBeNull(); + }); + + it('lists posts including a newly created one', async () => { + const topicId = await createParentTopic(); + const currentUser = await getCurrentUser(); + const content = `List post ${Date.now()}`; + + const createResult = await client().graphql({ + query: createPost, + variables: { input: { content, topicPostsId: topicId, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createPost; + + const listResult = await client().graphql({ query: listPosts }); + const items = (listResult as any).data.listPosts.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((p: any) => p.id === created.id); + expect(found).toBeDefined(); + expect(found.content).toBe(content); + }); +}); + +describe('Comment', () => { + async function createParentPost(): Promise { + const currentUser = await getCurrentUser(); + const topicResult = await client().graphql({ + query: createTopic, + variables: { input: { content: `tech:Comment Parent ${Date.now()}`, createdByUserId: currentUser.userId } }, + }); + const topicId = (topicResult as any).data.createTopic.id; + + const postResult = await client().graphql({ + query: createPost, + variables: { input: { content: `Parent post ${Date.now()}`, topicPostsId: topicId, createdByUserId: currentUser.userId } }, + }); + return (postResult as any).data.createPost.id; + } + + it('creates a comment linked to a post', async () => { + const postId = await createParentPost(); + const currentUser = await getCurrentUser(); + const content = `Test comment at ${new Date().toISOString()}`; + + const result = await client().graphql({ + query: createComment, + variables: { input: { content, postCommentsId: postId, createdByUserId: currentUser.userId } }, + }); + const comment = (result as any).data.createComment; + + expect(typeof comment.id).toBe('string'); + expect(comment.id.length).toBeGreaterThan(0); + expect(comment.content).toBe(content); + expect(comment.postCommentsId).toBe(postId); + expect(comment.createdByUserId).toBe(currentUser.userId); + expect(comment.createdAt).toBeDefined(); + expect(comment.updatedAt).toBeDefined(); + }); + + it('reads a comment by id', async () => { + const postId = await createParentPost(); + const currentUser = await getCurrentUser(); + const content = `Read comment ${Date.now()}`; + + const createResult = await client().graphql({ + query: createComment, + variables: { input: { content, postCommentsId: postId, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createComment; + + const getResult = await client().graphql({ query: getComment, variables: { id: created.id } }); + const fetched = (getResult as any).data.getComment; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.content).toBe(content); + expect(fetched.postCommentsId).toBe(postId); + }); + + it('updates a comment and persists changes', async () => { + const postId = await createParentPost(); + const currentUser = await getCurrentUser(); + + const createResult = await client().graphql({ + query: createComment, + variables: { input: { content: 'Original comment', postCommentsId: postId, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createComment; + + const updatedContent = `Updated comment at ${new Date().toISOString()}`; + await client().graphql({ + query: updateComment, + variables: { input: { id: created.id, content: updatedContent } }, + }); + + const getResult = await client().graphql({ query: getComment, variables: { id: created.id } }); + const fetched = (getResult as any).data.getComment; + + expect(fetched.content).toBe(updatedContent); + }); + + it('deletes a comment', async () => { + const postId = await createParentPost(); + const currentUser = await getCurrentUser(); + + const createResult = await client().graphql({ + query: createComment, + variables: { input: { content: 'Delete me', postCommentsId: postId, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createComment; + + await client().graphql({ query: deleteComment, variables: { input: { id: created.id } } }); + + const getResult = await client().graphql({ query: getComment, variables: { id: created.id } }); + expect((getResult as any).data.getComment).toBeNull(); + }); + + it('lists comments including a newly created one', async () => { + const postId = await createParentPost(); + const currentUser = await getCurrentUser(); + const content = `List comment ${Date.now()}`; + + const createResult = await client().graphql({ + query: createComment, + variables: { input: { content, postCommentsId: postId, createdByUserId: currentUser.userId } }, + }); + const created = (createResult as any).data.createComment; + + const listResult = await client().graphql({ query: listComments }); + const items = (listResult as any).data.listComments.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((c: any) => c.id === created.id); + expect(found).toBeDefined(); + expect(found.content).toBe(content); + }); +}); + +describe('fetchUserActivity', () => { + it('returns an activity array', async () => { + const currentUser = await getCurrentUser(); + const activityClient = generateClient({ authMode: 'apiKey' }); + + const result = await activityClient.graphql({ query: fetchUserActivity, variables: { userId: currentUser.userId } }); + const activities = (result as any).data.fetchUserActivity || []; + + expect(Array.isArray(activities)).toBe(true); + }); +}); diff --git a/amplify-migration-apps/discussions/tests/jest.setup.ts b/amplify-migration-apps/discussions/tests/jest.setup.ts new file mode 100644 index 00000000000..b19ceb1bd30 --- /dev/null +++ b/amplify-migration-apps/discussions/tests/jest.setup.ts @@ -0,0 +1,2 @@ +import { jest } from '@jest/globals'; +jest.retryTimes(3); diff --git a/amplify-migration-apps/discussions/tests/signup.ts b/amplify-migration-apps/discussions/tests/signup.ts new file mode 100644 index 00000000000..b14ab795eef --- /dev/null +++ b/amplify-migration-apps/discussions/tests/signup.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Amplify } from 'aws-amplify'; +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, + AdminSetUserPasswordCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import * as fs from 'fs'; +import { randomBytes } from 'crypto'; + +import { webcrypto } from 'crypto'; +if (typeof globalThis.crypto === 'undefined') { + (globalThis as any).crypto = webcrypto; +} + +const CONFIG_PATH = process.env.APP_CONFIG_PATH; +if (!CONFIG_PATH) { + throw new Error('APP_CONFIG_PATH environment variable is required'); +} + +export const config = JSON.parse(fs.readFileSync(CONFIG_PATH, { encoding: 'utf-8' })); +Amplify.configure(config); + +export async function signUp(cfg: any): Promise<{ username: string; password: string }> { + const gen2Auth = (cfg as any)?.auth; + const userPoolId = cfg.aws_user_pools_id ?? gen2Auth?.user_pool_id; + const region = cfg.aws_cognito_region ?? gen2Auth?.aws_region; + + const uname = generateTestPhoneNumber(); + const pwd = generateTestPassword(); + + const cognitoClient = new CognitoIdentityProviderClient({ region }); + + await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: userPoolId, + Username: uname, + TemporaryPassword: pwd, + UserAttributes: [ + { Name: 'email', Value: generateTestEmail() }, + { Name: 'email_verified', Value: 'true' }, + { Name: 'phone_number', Value: uname }, + { Name: 'phone_number_verified', Value: 'true' }, + ], + MessageAction: 'SUPPRESS', + })); + + await cognitoClient.send(new AdminSetUserPasswordCommand({ + UserPoolId: userPoolId, + Username: uname, + Password: pwd, + Permanent: true, + })); + + return { username: uname, password: pwd }; +} + +export function generateTestPassword(): string { + return `Test${randomSuffix()}!Aa1`; +} + +export function generateTestEmail(): string { + return `testuser-${randomSuffix()}@test.example.com`; +} + +export function generateTestPhoneNumber(): string { + const local = Math.floor(1000000 + Math.random() * 9000000); + return `+1555${local}`; +} + +export function randomSuffix(): string { + return randomBytes(4).toString('hex'); +} diff --git a/amplify-migration-apps/discussions/tests/storage-activity.test.ts b/amplify-migration-apps/discussions/tests/storage-activity.test.ts new file mode 100644 index 00000000000..a2668d092a5 --- /dev/null +++ b/amplify-migration-apps/discussions/tests/storage-activity.test.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { generateClient } from 'aws-amplify/api'; +import { getCurrentUser, signIn, signOut } from 'aws-amplify/auth'; +import { signUp, config } from './signup'; +import { fetchUserActivity } from '../src/graphql/queries'; +import { createTopic } from '../src/graphql/mutations'; + +const client = () => generateClient({ authMode: 'apiKey' }); + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('auth', () => { + it('records activity after creating a topic', async () => { + const currentUser = await getCurrentUser(); + + // Create a topic to trigger the recorduseractivity Lambda via DynamoDB stream + await client().graphql({ + query: createTopic, + variables: { input: { content: `tech:Activity Test ${Date.now()}`, createdByUserId: currentUser.userId } }, + }); + + // Poll until the activity appears (stream trigger is async) + let activities: any[] = []; + for (let attempt = 0; attempt < 10; attempt++) { + const result = await client().graphql({ query: fetchUserActivity, variables: { userId: currentUser.userId } }); + activities = (result as any).data.fetchUserActivity || []; + if (activities.length > 0) break; + await new Promise((r) => setTimeout(r, 2000)); + } + + expect(activities.length).toBeGreaterThan(0); + expect(activities[0].userId).toBe(currentUser.userId); + expect(typeof activities[0].activityType).toBe('string'); + expect(typeof activities[0].timestamp).toBe('string'); + }, 30_000); +}); diff --git a/amplify-migration-apps/discussions/tests/storage-avatars.test.ts b/amplify-migration-apps/discussions/tests/storage-avatars.test.ts new file mode 100644 index 00000000000..3dce65ebb94 --- /dev/null +++ b/amplify-migration-apps/discussions/tests/storage-avatars.test.ts @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { signIn, signOut } from 'aws-amplify/auth'; +import { uploadData, getUrl, remove } from 'aws-amplify/storage'; +import { signUp, config } from './signup'; + +let username: string; +let password: string; + +beforeAll(async () => { + const creds = await signUp(config); + username = creds.username; + password = creds.password; + await signIn({ username, password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('auth', () => { + it('uploads a file and returns the key', async () => { + const testImageBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `test-avatar-${Date.now()}.png`; + + const result = await uploadData({ + key: fileName, + data: imageBuffer, + options: { contentType: 'image/png' }, + }).result; + + expect(typeof result.key).toBe('string'); + expect(result.key).toBe(fileName); + }); + + it('gets a signed URL for an uploaded file', async () => { + const imageBuffer = Buffer.from('test-content'); + const fileName = `test-url-${Date.now()}.txt`; + + await uploadData({ + key: fileName, + data: imageBuffer, + options: { contentType: 'text/plain' }, + }).result; + + const result = await getUrl({ + key: fileName, + options: { expiresIn: 3600 }, + }); + + expect(result.url).toBeDefined(); + const urlStr = result.url.toString(); + expect(urlStr).toContain('https://'); + expect(urlStr.length).toBeGreaterThan(0); + }); + + it('removes an uploaded file', async () => { + const imageBuffer = Buffer.from('delete-me'); + const fileName = `test-remove-${Date.now()}.txt`; + + await uploadData({ + key: fileName, + data: imageBuffer, + options: { contentType: 'text/plain' }, + }).result; + + await remove({ key: fileName }); + + // Verify removal completed without error + // (S3 may still return a signed URL for a deleted object briefly) + expect(true).toBe(true); + }); +}); + diff --git a/amplify-migration-apps/discussions/tests/storage-bookmarks.test.ts b/amplify-migration-apps/discussions/tests/storage-bookmarks.test.ts new file mode 100644 index 00000000000..4ea607697d4 --- /dev/null +++ b/amplify-migration-apps/discussions/tests/storage-bookmarks.test.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { getCurrentUser, signIn, signOut } from 'aws-amplify/auth'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, PutCommand, GetCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; +import { signUp, config } from './signup'; + +let username: string; +let password: string; + +beforeAll(async () => { + const creds = await signUp(config); + username = creds.username; + password = creds.password; + await signIn({ username, password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('auth', () => { + it('puts, gets, and deletes a bookmark', async () => { + const region = config.aws_project_region ?? config.aws_dynamodb_all_tables_region; + const schemas = config.aws_dynamodb_table_schemas ?? []; + const entry = schemas.find((s: any) => s.tableName?.startsWith('bookmarks-')); + if (!entry) { + console.warn('No bookmarks table found in config, skipping'); + return; + } + const tableName = entry.tableName; + + const currentUser = await getCurrentUser(); + const userId = currentUser.userId; + const postId = `test-post-${Date.now()}`; + const createdAt = new Date().toISOString(); + + const ddbClient = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + + await ddbClient.send(new PutCommand({ + TableName: tableName, + Item: { userId, postId, createdAt }, + })); + + const getResult = await ddbClient.send(new GetCommand({ + TableName: tableName, + Key: { userId, postId }, + })); + + expect(getResult.Item).toBeDefined(); + expect(getResult.Item!.userId).toBe(userId); + expect(getResult.Item!.postId).toBe(postId); + expect(getResult.Item!.createdAt).toBe(createdAt); + + await ddbClient.send(new DeleteCommand({ + TableName: tableName, + Key: { userId, postId }, + })); + + const afterDelete = await ddbClient.send(new GetCommand({ + TableName: tableName, + Key: { userId, postId }, + })); + + expect(afterDelete.Item).toBeUndefined(); + }); +}); diff --git a/amplify-migration-apps/fitness-tracker/README.md b/amplify-migration-apps/fitness-tracker/README.md index 569b1cfd9de..5921ad062db 100644 --- a/amplify-migration-apps/fitness-tracker/README.md +++ b/amplify-migration-apps/fitness-tracker/README.md @@ -293,165 +293,14 @@ git checkout -b gen2-main npx amplify gen2-migration generate ``` -**Edit in `./amplify/data/resource.ts`:** - -```diff -- branchName: "main" -+ branchName: "gen2-main" -``` - -**Edit in `./amplify/function/lognutrition/resource.ts`:** - -```diff -- entry: "./index.js", -+ entry: "./index.js", -+ resourceGroupName: 'data', -``` - -**Edit in `./amplify/function/lognutrition/index.js`:** - -```diff -- const awsServerlessExpress = require('aws-serverless-express'); -- const app = require('./app'); -+ import awsServerlessExpress from 'aws-serverless-express'; -+ import app from './app.js'; -``` - -```diff -- exports.handler = (event, context) => { -+ export async function handler(event, context) { -``` - -**Edit in `./amplify/function/lognutrition/app.js`:** - -```diff -- const express = require('express'); -- const bodyParser = require('body-parser'); -- const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware'); -- const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); -- const { DynamoDBDocumentClient, PutCommand } = require('@aws-sdk/lib-dynamodb'); -- const crypto = require('crypto'); - -+ import express from 'express'; -+ import bodyParser from 'body-parser'; -+ import awsServerlessExpressMiddleware from 'aws-serverless-express/middleware'; -+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -+ import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; -+ import crypto from 'crypto'; -``` - -```diff -- module.exports = app; -+ export default app; -``` - -**Edit in `./amplify/function/admin/resource.ts`:** - -```diff -- entry: "./index.js", -+ entry: "./index.js", -+ resourceGroupName: 'auth', -``` - -**Edit in `./amplify/function/admin/index.js`:** - -```diff -- const awsServerlessExpress = require('aws-serverless-express'); -- const app = require('./app'); -+ import awsServerlessExpress from 'aws-serverless-express'; -+ import app from './app.js'; -``` - -```diff -- exports.handler = (event, context) => { -+ export async function handler(event, context) { -``` - -**Edit in `./amplify/function/admin/app.js`:** - -```diff -- const express = require('express'); -- const bodyParser = require('body-parser'); -- const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware'); -- const { CognitoIdentityProviderClient, ListUsersCommand } = require('@aws-sdk/client-cognito-identity-provider'); -- module.exports = app; -+ import express from 'express'; -+ import bodyParser from 'body-parser'; -+ import awsServerlessExpressMiddleware from 'aws-serverless-express/middleware'; -+ import { CognitoIdentityProviderClient, ListUsersCommand } from '@aws-sdk/client-cognito-identity-provider'; -``` - -```diff -- module.exports = app; -+ export default app; -``` - -**Edit in `./amplify/auth/fitnesstrackerd21d4fcdd21d4fcdPreSignup/resource.ts`:** - -> Note: The hash value after `fitnesstracker` changes for each app; you will have a different one. - -```diff -- entry: "./index.js", -+ entry: "./index.js", -+ resourceGroupName: 'auth', -``` - -**Edit in `./amplify/auth/fitnesstrackerd21d4fcdd21d4fcdPreSignup/src/index.js`:** - -> Note: The hash value after `fitnesstracker` changes for each app; you will have a different one. - -```diff -- const modules = moduleNames.map((name) => require(`./${name}`)); -+ const modules = [require('./email-filter-allowlist')] -``` - -```diff -- exports.handler = (event, context) => { -+ export async function handler(event, context) { -``` - -**Edit in `./amplify/function/fitnesstrackerd21d4fcdd21d4fcdPreSignup/src/email-filter-allowlist.js`:** - -> Note: The hash value after `fitnesstracker` changes for each app; you will have a different one. - -```diff -- exports.handler = (event) => { -+ export async function handler(event) { -``` - -**Edit in `./src/App.tx`:** - -```diff -- apiName: 'nutritionapi', -+ apiName: 'nutritionapi-gen2-main', -``` - -```diff -- apiName: 'adminapi', -+ apiName: 'adminapi-gen2-main', -``` - -**Edit in `./src/main.tsx`:** - -```diff -- import amplifyconfig from './amplifyconfiguration.json'; -+ import outputs from '../amplify_outputs.json'; -+ import { parseAmplifyConfig } from "aws-amplify/utils"; +```console +npm run post-generate ``` -```diff -- Amplify.configure(amplifyconfig); -+ const amplifyConfig = parseAmplifyConfig(outputs); -+ -+ Amplify.configure( -+ { -+ ...amplifyConfig, -+ API: { -+ ...amplifyConfig.API, -+ REST: outputs.custom.API, -+ }, -+ }, -+ ); +```console +rm -rf node_modules package-lock.json +npm install +npm install --package-lock-only ``` ```console diff --git a/amplify-migration-apps/fitness-tracker/_snapshot.post.generate/package.json b/amplify-migration-apps/fitness-tracker/_snapshot.post.generate/package.json index 6149aadd62b..10c2c83ec2d 100644 --- a/amplify-migration-apps/fitness-tracker/_snapshot.post.generate/package.json +++ b/amplify-migration-apps/fitness-tracker/_snapshot.post.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/fitness-goal-tracker", + "name": "@amplify-migration-apps/fitness-goal-tracker-snapshot", "private": true, "version": "1.0.0", "type": "module", diff --git a/amplify-migration-apps/fitness-tracker/_snapshot.pre.generate/package.json b/amplify-migration-apps/fitness-tracker/_snapshot.pre.generate/package.json index 1ae62704fb1..c5d3e0d4aca 100644 --- a/amplify-migration-apps/fitness-tracker/_snapshot.pre.generate/package.json +++ b/amplify-migration-apps/fitness-tracker/_snapshot.pre.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/fitness-goal-tracker", + "name": "@amplify-migration-apps/fitness-goal-tracker-snapshot", "private": true, "version": "1.0.0", "type": "module", diff --git a/amplify-migration-apps/fitness-tracker/adminapi.js b/amplify-migration-apps/fitness-tracker/backend/adminapi.js similarity index 100% rename from amplify-migration-apps/fitness-tracker/adminapi.js rename to amplify-migration-apps/fitness-tracker/backend/adminapi.js diff --git a/amplify-migration-apps/fitness-tracker/adminapi.package.json b/amplify-migration-apps/fitness-tracker/backend/adminapi.package.json similarity index 100% rename from amplify-migration-apps/fitness-tracker/adminapi.package.json rename to amplify-migration-apps/fitness-tracker/backend/adminapi.package.json diff --git a/amplify-migration-apps/fitness-tracker/backend/configure-schema.sh b/amplify-migration-apps/fitness-tracker/backend/configure-schema.sh new file mode 100755 index 00000000000..2969e418df3 --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/backend/configure-schema.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -euxo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cp -f ${script_dir}/schema.graphql ${script_dir}/../amplify/backend/api/fitnesstracker/schema.graphql diff --git a/amplify-migration-apps/fitness-tracker/backend/configure.sh b/amplify-migration-apps/fitness-tracker/backend/configure.sh new file mode 100755 index 00000000000..ac8fb71cdb1 --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/backend/configure.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euxo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cp -f ${script_dir}/schema.graphql ${script_dir}/../amplify/backend/api/fitnesstracker/schema.graphql +cp -f ${script_dir}/restapi.js ${script_dir}/../amplify/backend/function/lognutrition/src/app.js +cp -f ${script_dir}/adminapi.js ${script_dir}/../amplify/backend/function/admin/src/app.js +cp -f ${script_dir}/adminapi.package.json ${script_dir}/../amplify/backend/function/admin/src/package.json diff --git a/amplify-migration-apps/fitness-tracker/restapi.js b/amplify-migration-apps/fitness-tracker/backend/restapi.js similarity index 100% rename from amplify-migration-apps/fitness-tracker/restapi.js rename to amplify-migration-apps/fitness-tracker/backend/restapi.js diff --git a/amplify-migration-apps/fitness-tracker/restapi.package.json b/amplify-migration-apps/fitness-tracker/backend/restapi.package.json similarity index 100% rename from amplify-migration-apps/fitness-tracker/restapi.package.json rename to amplify-migration-apps/fitness-tracker/backend/restapi.package.json diff --git a/amplify-migration-apps/fitness-tracker/schema.graphql b/amplify-migration-apps/fitness-tracker/backend/schema.graphql similarity index 100% rename from amplify-migration-apps/fitness-tracker/schema.graphql rename to amplify-migration-apps/fitness-tracker/backend/schema.graphql diff --git a/amplify-migration-apps/fitness-tracker/configure-functions.sh b/amplify-migration-apps/fitness-tracker/configure-functions.sh deleted file mode 100755 index 43d7d92db31..00000000000 --- a/amplify-migration-apps/fitness-tracker/configure-functions.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -cp -f restapi.js ./amplify/backend/function/lognutrition/src/app.js -cp -f adminapi.js ./amplify/backend/function/admin/src/app.js -cp -f adminapi.package.json ./amplify/backend/function/admin/src/package.json diff --git a/amplify-migration-apps/fitness-tracker/configure-schema.sh b/amplify-migration-apps/fitness-tracker/configure-schema.sh deleted file mode 100755 index d2bf310494a..00000000000 --- a/amplify-migration-apps/fitness-tracker/configure-schema.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -cp -f schema.graphql ./amplify/backend/api/fitnesstracker/schema.graphql diff --git a/amplify-migration-apps/fitness-tracker/configure.sh b/amplify-migration-apps/fitness-tracker/configure.sh deleted file mode 100755 index d48c8296478..00000000000 --- a/amplify-migration-apps/fitness-tracker/configure.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -./configure-schema.sh -./configure-functions.sh \ No newline at end of file diff --git a/amplify-migration-apps/fitness-tracker/gen1-test-script.ts b/amplify-migration-apps/fitness-tracker/gen1-test-script.ts deleted file mode 100644 index af327697df4..00000000000 --- a/amplify-migration-apps/fitness-tracker/gen1-test-script.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Gen1 Test Script for Fitness Tracker App - * - * This script tests all functionality: - * 1. Authenticated GraphQL Queries (requires auth) - * 2. Authenticated GraphQL Mutations (requires auth) - * 3. REST API Operations (nutrition logging) - * - * Credentials are provisioned automatically via Cognito SignUp + AdminConfirmSignUp. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import { post } from 'aws-amplify/api'; -import amplifyconfig from './src/amplifyconfiguration.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify -Amplify.configure(amplifyconfig); - -// ============================================================ -// REST API Test Functions (Gen1-specific) -// ============================================================ -async function testNutritionLogAPI(): Promise { - console.log('\n🍔 Testing REST API - POST /nutrition/log...'); - const user = await getCurrentUser(); - - const restOperation = post({ - apiName: 'nutritionapi', - path: '/nutrition/log', - options: { - body: { - userName: user.username, - content: `Test nutrition log via REST API - Pizza and salad - ${Date.now()}`, - }, - }, - }); - - const { body } = await restOperation.response; - const response = await body.json(); - - console.log('✅ REST API Response:', response); - console.log(' Message:', (response as any).message); -} - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Gen1 Test Script for Fitness Tracker\n'); - console.log('This script tests:'); - console.log(' 1. Authenticated GraphQL Queries'); - console.log(' 2. Authenticated GraphQL Mutations'); - console.log(' 3. REST API Operations (Nutrition Logging)'); - - // Provision user via SDK, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - // Sign in from this module so the auth tokens are available to api/storage - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn has failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { runQueryTests, runMutationTests } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Queries - await runQueryTests(); - - // Part 2: Mutations - await runMutationTests(); - - // Part 3: REST API - console.log('\n' + '='.repeat(50)); - console.log('🌐 PART 3: REST API Operations'); - console.log('='.repeat(50)); - - await runner.runTest('nutritionLogAPI', testNutritionLogAPI); - - console.log('\n💡 Note: The REST API creates meals directly in DynamoDB.'); - console.log(' Check your app to see the logged nutrition data!'); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/fitness-tracker/gen2-test-script.ts b/amplify-migration-apps/fitness-tracker/gen2-test-script.ts deleted file mode 100644 index 400c5b93b42..00000000000 --- a/amplify-migration-apps/fitness-tracker/gen2-test-script.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Gen2 Test Script for Fitness Tracker App - * - * This script tests all functionality for Amplify Gen2: - * 1. Authenticated GraphQL Queries (requires auth) - * 2. Authenticated GraphQL Mutations (requires auth) - * 3. REST API Operations (nutrition logging) - * - * Credentials are provisioned automatically via Cognito AdminCreateUser. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser, fetchAuthSession } from 'aws-amplify/auth'; -import { parseAmplifyConfig } from 'aws-amplify/utils'; -import amplifyconfig from './src/amplify_outputs.json'; -import { SignatureV4 } from '@aws-sdk/signature-v4'; -import { HttpRequest } from '@aws-sdk/protocol-http'; -import { Sha256 } from '@aws-crypto/sha256-js'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify with Gen2 outputs, merging REST API config -const parsedConfig = parseAmplifyConfig(amplifyconfig); -Amplify.configure({ - ...parsedConfig, - API: { - ...parsedConfig.API, - REST: { - ...(amplifyconfig as any).custom?.API, - }, - }, -}); - -// ============================================================ -// REST API Test Functions (Gen2-specific, uses SigV4 signing) -// ============================================================ - -/** - * Make a signed REST API request using AWS SigV4. - * Gen2 REST APIs require manual signing since Amplify's post() helper - * has signing issues in Node.js environments. - */ -async function makeSignedRequest( - method: 'GET' | 'POST' | 'PUT' | 'DELETE', - path: string, - body?: any, -): Promise { - const session = await fetchAuthSession(); - const credentials = session.credentials; - - if (!credentials) { - throw new Error('No credentials available'); - } - - const apiConfigs = (amplifyconfig as any).custom.API; - const apiName = Object.keys(apiConfigs)[0]; - const apiConfig = apiConfigs[apiName]; - let endpoint = apiConfig.endpoint; - const region = apiConfig.region; - - if (endpoint.endsWith('/')) { - endpoint = endpoint.slice(0, -1); - } - - const normalizedPath = path.startsWith('/') ? path : '/' + path; - const url = new URL(endpoint + normalizedPath); - - console.log(' 🔗 Request URL:', url.toString()); - - const request = new HttpRequest({ - method, - protocol: url.protocol, - hostname: url.hostname, - path: url.pathname + url.search, - headers: { - 'Content-Type': 'application/json', - host: url.hostname, - }, - body: body ? JSON.stringify(body) : undefined, - }); - - const signer = new SignatureV4({ - credentials, - region, - service: 'execute-api', - sha256: Sha256, - }); - - const signedRequest = await signer.sign(request); - - const response = await fetch(url.toString(), { - method: signedRequest.method, - headers: signedRequest.headers, - body: signedRequest.body, - }); - - const responseText = await response.text(); - console.log(' 📥 Response status:', response.status); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${responseText}`); - } - - return JSON.parse(responseText); -} - -async function testNutritionLogAPI(): Promise { - console.log('\n🍔 Testing Gen2 REST API - POST /nutrition/log...'); - const user = await getCurrentUser(); - - const requestBody = { - userName: user.username, - content: `Test nutrition log via Gen2 REST API - Pizza and salad - ${Date.now()}`, - }; - - const response = await makeSignedRequest('POST', 'nutrition/log', requestBody); - console.log('✅ Gen2 REST API Response:', response); - console.log(' Message:', response.message); -} - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Gen2 Test Script for Fitness Tracker\n'); - console.log('This script tests:'); - console.log(' 1. Authenticated GraphQL Queries'); - console.log(' 2. Authenticated GraphQL Mutations'); - console.log(' 3. REST API Operations (Nutrition Logging)'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { runQueryTests, runMutationTests } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Queries - await runQueryTests(); - - // Part 2: Mutations - await runMutationTests(); - - // Part 3: REST API (gen2-specific, uses SigV4 signing) - console.log('\n' + '='.repeat(50)); - console.log('🌐 PART 3: REST API Operations'); - console.log('='.repeat(50)); - - await runner.runTest('nutritionLogAPI', testNutritionLogAPI); - - console.log('\n💡 Note: The REST API creates meals directly in DynamoDB.'); - console.log(' Check your app to see the logged nutrition data!'); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/fitness-tracker/jest.config.js b/amplify-migration-apps/fitness-tracker/jest.config.js new file mode 100644 index 00000000000..fb5a9b20fe0 --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/jest.config.js @@ -0,0 +1,23 @@ +/** @type {import('jest').Config} */ +export default { + testMatch: ['/tests/**/*.test.ts'], + modulePathIgnorePatterns: ['/_snapshot', '/amplify'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: { + target: 'ES2022', + module: 'CommonJS', + moduleResolution: 'node', + esModuleInterop: true, + allowJs: true, + noEmit: true, + strict: true, + skipLibCheck: true, + types: ['node', 'jest'], + }, + }], + }, + testTimeout: 30_000, + setupFilesAfterEnv: ['/tests/jest.setup.ts'], +}; diff --git a/amplify-migration-apps/fitness-tracker/migration-config.json b/amplify-migration-apps/fitness-tracker/migration-config.json deleted file mode 100644 index 336dc35e112..00000000000 --- a/amplify-migration-apps/fitness-tracker/migration-config.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "app": { - "name": "fitness-tracker", - "description": "Fitness tracking application with GraphQL and REST APIs, authentication with email allowlist trigger", - "framework": "react" - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["COGNITO_USER_POOLS", "API_KEY"] - }, - "auth": { - "signInMethods": ["username"], - "socialProviders": [], - "triggers": { - "preSignUp": { - "type": "email-filter-allowlist" - } - } - }, - "restApi": { - "name": "nutritionapi", - "paths": ["/nutrition/log"], - "lambdaSource": "lognutrition" - }, - "function": { - "functions": [ - { - "name": "lognutrition", - "runtime": "nodejs", - "template": "serverless-expressjs" - } - ] - }, - "hosting": { - "type": "amplify-console" - } - } -} diff --git a/amplify-migration-apps/fitness-tracker/migration/config.json b/amplify-migration-apps/fitness-tracker/migration/config.json new file mode 100644 index 00000000000..6f1f19579b1 --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/migration/config.json @@ -0,0 +1,5 @@ +{ + "lock": { + "skipValidations": true + } +} diff --git a/amplify-migration-apps/fitness-tracker/migration/post-generate.ts b/amplify-migration-apps/fitness-tracker/migration/post-generate.ts new file mode 100644 index 00000000000..d555e61cbb5 --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/migration/post-generate.ts @@ -0,0 +1,205 @@ +#!/usr/bin/env npx ts-node +/** + * Post-generate script for fitness-tracker app. + * + * Applies manual edits required after `amplify gen2-migration generate`: + * 1. Update branchName in amplify/data/resource.ts to the value of AWS_BRANCH + * env var, or the current git branch if AWS_BRANCH is not set + * 2. Convert lognutrition function from CommonJS to ESM + * 3. Convert admin function from CommonJS to ESM + * 4. Convert PreSignup trigger function from CommonJS to ESM + * 5. Update frontend import from amplifyconfiguration.json to amplify_outputs.json + * 6. Add resourceGroupName to function resource.ts files to break circular dependencies + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { execSync } from 'child_process'; + +function resolveTargetBranch(): string { + if (process.env.AWS_BRANCH) { + return process.env.AWS_BRANCH; + } + return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); +} + +async function findPreSignupDir(appPath: string): Promise { + const authDir = path.join(appPath, 'amplify', 'auth'); + const entries = await fs.readdir(authDir); + const match = entries.find((e) => e.startsWith('fitnesstracker') && e.includes('PreSignup')); + if (!match) throw new Error('PreSignup directory not found under amplify/auth/'); + return path.join(authDir, match); +} + +async function updateBranchName(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); + + const content = await fs.readFile(resourcePath, 'utf-8'); + + const targetBranch = resolveTargetBranch(); + + const updated = content.replace( + /branchName:\s*['"]([^'"]+)['"]/, + `branchName: '${targetBranch}'`, + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function convertExpressFunctionToESM(appPath: string, functionName: string): Promise { + const functionDir = path.join(appPath, 'amplify', 'function', functionName); + + // Convert index.js + const indexPath = path.join(functionDir, 'index.js'); + let indexContent = await fs.readFile(indexPath, 'utf-8'); + + // const X = require('Y') → import X from 'Y' + // For relative paths without extension, append .js for ESM resolution + indexContent = indexContent.replace( + /const\s+(\w+)\s*=\s*require\(['"]([^'"]+)['"]\);?/g, + (_match, name, mod) => { + const specifier = mod.startsWith('./') && !path.extname(mod) ? `${mod}.js` : mod; + return `import ${name} from '${specifier}';`; + }, + ); + + // const { A, B } = require('Y') → import { A, B } from 'Y' + indexContent = indexContent.replace( + /const\s+(\{[^}]+\})\s*=\s*require\(['"]([^'"]+)['"]\);?/g, + "import $1 from '$2';", + ); + + // exports.handler = (event, context) => { → export async function handler(event, context) { + indexContent = indexContent.replace( + /exports\.handler\s*=\s*(?:async\s*)?\((\w+(?:,\s*\w+)*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + await fs.writeFile(indexPath, indexContent, 'utf-8'); + + // Convert app.js + const appJsPath = path.join(functionDir, 'app.js'); + let appContent = await fs.readFile(appJsPath, 'utf-8'); + + // const { A, B } = require('Y') → import { A, B } from 'Y' + appContent = appContent.replace( + /const\s+(\{[^}]+\})\s*=\s*require\(['"]([^'"]+)['"]\);?/g, + "import $1 from '$2';", + ); + + // const X = require('Y') → import X from 'Y' + appContent = appContent.replace( + /const\s+(\w+)\s*=\s*require\(['"]([^'"]+)['"]\);?/g, + "import $1 from '$2';", + ); + + // module.exports = X → export default X + appContent = appContent.replace( + /module\.exports\s*=\s*(\w+);?/g, + 'export default $1;', + ); + + await fs.writeFile(appJsPath, appContent, 'utf-8'); +} + +async function convertPreSignupToESM(appPath: string): Promise { + // The PreSignup function name contains a hash that varies per deployment. + const preSignupDir = await findPreSignupDir(appPath); + + // Convert index.js + const indexPath = path.join(preSignupDir, 'index.js'); + let indexContent = await fs.readFile(indexPath, 'utf-8'); + + // Replace dynamic require with static import + indexContent = indexContent.replace( + /const modules = moduleNames\.map\(\(name\) => require\(`\.\/\$\{name\}`\)\);/, + "const modules = [await import('./email-filter-allowlist.js')];", + ); + + // exports.handler = async (event, context) => { → export async function handler(event, context) { + indexContent = indexContent.replace( + /exports\.handler\s*=\s*async\s*\((\w+(?:,\s*\w+)*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + await fs.writeFile(indexPath, indexContent, 'utf-8'); + + // Convert email-filter-allowlist.js + const allowlistPath = path.join(preSignupDir, 'email-filter-allowlist.js'); + let allowlistContent = await fs.readFile(allowlistPath, 'utf-8'); + + // exports.handler = async (event) => { → export async function handler(event) { + allowlistContent = allowlistContent.replace( + /exports\.handler\s*=\s*async\s*\((\w+)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + await fs.writeFile(allowlistPath, allowlistContent, 'utf-8'); +} + +async function updateFrontendConfig(appPath: string, branchName: string): Promise { + // Rewrite api-config.ts with Gen2 API names and parseAmplifyConfig-based configure function + const apiConfigPath = path.join(appPath, 'src', 'api-config.ts'); + await fs.writeFile(apiConfigPath, `import { Amplify } from 'aws-amplify'; +import { parseAmplifyConfig } from 'aws-amplify/utils'; + +export const NUTRITION_API_NAME = 'nutritionapi-${branchName}'; +export const ADMIN_API_NAME = 'adminapi-${branchName}'; + +export function configureAmplify(config: any): void { + const amplifyConfig = parseAmplifyConfig(config); + Amplify.configure({ + ...amplifyConfig, + API: { ...amplifyConfig.API, REST: config.custom?.API }, + }); +} +`, 'utf-8'); + + // Update main.tsx: switch config import from amplifyconfiguration.json to amplify_outputs.json + const mainPath = path.join(appPath, 'src', 'main.tsx'); + let mainContent = await fs.readFile(mainPath, 'utf-8'); + mainContent = mainContent.replace( + /from\s*["']\.\/amplifyconfiguration\.json["']/g, + "from '../amplify_outputs.json'", + ); + await fs.writeFile(mainPath, mainContent, 'utf-8'); +} + +async function setResourceGroupName(resourceTsPath: string, groupName: string): Promise { + const content = await fs.readFile(resourceTsPath, 'utf-8'); + + // Add resourceGroupName after the entry property in defineFunction({ entry: "...", }) + const updated = content.replace( + /(entry:\s*["'][^"']+["'],?)/, + `$1\n resourceGroupName: '${groupName}',`, + ); + + await fs.writeFile(resourceTsPath, updated, 'utf-8'); +} + +export async function postGenerate(appPath: string): Promise { + const branchName = execSync('git branch --show-current', { cwd: appPath, encoding: 'utf-8' }).trim(); + + await updateBranchName(appPath); + await convertExpressFunctionToESM(appPath, 'lognutrition'); + await convertExpressFunctionToESM(appPath, 'admin'); + await convertPreSignupToESM(appPath); + await updateFrontendConfig(appPath, branchName); + + // Break circular dependencies by assigning functions to the stack of the resource they access + await setResourceGroupName(path.join(appPath, 'amplify', 'function', 'lognutrition', 'resource.ts'), 'data'); + await setResourceGroupName(path.join(appPath, 'amplify', 'function', 'admin', 'resource.ts'), 'auth'); + + const preSignupDir = await findPreSignupDir(appPath); + await setResourceGroupName(path.join(preSignupDir, 'resource.ts'), 'auth'); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postGenerate(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/fitness-tracker/migration/post-push.ts b/amplify-migration-apps/fitness-tracker/migration/post-push.ts new file mode 100644 index 00000000000..548a5ca346d --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/migration/post-push.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env npx ts-node +/** + * Post-push script for fitness-tracker app. + * + * The PreSignUp trigger has a DOMAINALLOWLIST env var that defaults to "" + * in the CloudFormation template. After push, we update the Lambda's + * environment to include "amazon.com" so test user provisioning works. + */ + +import fs from 'fs'; +import path from 'path'; +import { + LambdaClient, + UpdateFunctionConfigurationCommand, + GetFunctionConfigurationCommand, +} from '@aws-sdk/client-lambda'; + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + + const metaPath = path.join(appPath, 'amplify', 'backend', 'amplify-meta.json'); + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); + + const preSignupEntry = Object.entries(meta.function ?? {}) + .find(([name]) => name.includes('PreSignup')); + + if (!preSignupEntry) { + throw new Error('No PreSignup function found in amplify-meta.json'); + } + + const functionName = (preSignupEntry[1] as any).output?.Name as string; + if (!functionName) { + throw new Error(`PreSignup entry '${preSignupEntry[0]}' has no output.Name`); + } + + const lambda = new LambdaClient({}); + + const config = await lambda.send(new GetFunctionConfigurationCommand({ FunctionName: functionName })); + const env = config.Environment?.Variables ?? {}; + + await lambda.send(new UpdateFunctionConfigurationCommand({ + FunctionName: functionName, + Environment: { Variables: { ...env, DOMAINALLOWLIST: 'amazon.com' } }, + })); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/fitness-tracker/package.json b/amplify-migration-apps/fitness-tracker/package.json index 1ae62704fb1..b0a410907dd 100644 --- a/amplify-migration-apps/fitness-tracker/package.json +++ b/amplify-migration-apps/fitness-tracker/package.json @@ -12,10 +12,19 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "configure": "./configure.sh", - "configure-schema": "./configure-schema.sh", + "configure": "./backend/configure.sh", + "configure-schema": "./backend/configure-schema.sh", "sanitize": "tsx ../sanitize.ts", - "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit" + "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit", + "test:gen1": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-src/amplifyconfiguration.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:gen2": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-amplify_outputs.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:e2e": "cd ../../packages/amplify-gen2-migration-e2e-system && npx tsx src/cli.ts --app fitness-tracker --profile ${AWS_PROFILE:-default}", + "pre-push": "true", + "post-generate": "npx tsx migration/post-generate.ts", + "post-refactor": "true", + "post-sandbox": "true", + "pre-sandbox": "true", + "post-push": "npx tsx migration/post-push.ts" }, "dependencies": { "@aws-amplify/ui-react": "^6.13.1", @@ -29,7 +38,10 @@ "react-dom": "^19.2.0" }, "devDependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.936.0", + "@aws-sdk/client-lambda": "^3.936.0", "@eslint/js": "^9.39.1", + "@types/jest": "^29.5.14", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -38,6 +50,8 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jest": "^29.7.0", + "ts-jest": "^29.3.4", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" diff --git a/amplify-migration-apps/fitness-tracker/src/App.tsx b/amplify-migration-apps/fitness-tracker/src/App.tsx index 7053d7c41a9..f796caa9c90 100644 --- a/amplify-migration-apps/fitness-tracker/src/App.tsx +++ b/amplify-migration-apps/fitness-tracker/src/App.tsx @@ -5,6 +5,7 @@ import { useEffect, useState, useRef } from 'react'; import { generateClient } from 'aws-amplify/api'; import { post, get } from 'aws-amplify/api'; +import { NUTRITION_API_NAME, ADMIN_API_NAME } from './api-config'; import { Button, Heading, withAuthenticator } from '@aws-amplify/ui-react'; import '@aws-amplify/ui-react/styles.css'; @@ -353,7 +354,7 @@ const MealLogger: React.FC<{ user?: AuthUser; onMealLogged: () => void }> = ({ u try { setLogging(true); await post({ - apiName: 'nutritionapi', + apiName: NUTRITION_API_NAME, path: '/nutrition/log', options: { body: { @@ -959,7 +960,7 @@ const App: React.FC = ({ signOut, user }) => { try { setLoadingUsers(true); const response = await get({ - apiName: 'adminapi', + apiName: ADMIN_API_NAME, path: '/admin/users', }).response; const data = (await response.body.json()) as any; diff --git a/amplify-migration-apps/fitness-tracker/src/api-config.ts b/amplify-migration-apps/fitness-tracker/src/api-config.ts new file mode 100644 index 00000000000..c7361cedc6f --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/src/api-config.ts @@ -0,0 +1,8 @@ +import { Amplify } from 'aws-amplify'; + +export const NUTRITION_API_NAME = 'nutritionapi'; +export const ADMIN_API_NAME = 'adminapi'; + +export function configureAmplify(config: any): void { + Amplify.configure(config); +} diff --git a/amplify-migration-apps/fitness-tracker/src/main.tsx b/amplify-migration-apps/fitness-tracker/src/main.tsx index 0851058047c..dc168a39b13 100644 --- a/amplify-migration-apps/fitness-tracker/src/main.tsx +++ b/amplify-migration-apps/fitness-tracker/src/main.tsx @@ -3,9 +3,9 @@ import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; -import { Amplify } from 'aws-amplify'; import amplifyconfig from './amplifyconfiguration.json'; -Amplify.configure(amplifyconfig); +import { configureAmplify } from './api-config'; +configureAmplify(amplifyconfig); createRoot(document.getElementById('root')!).render( diff --git a/amplify-migration-apps/fitness-tracker/test-utils.ts b/amplify-migration-apps/fitness-tracker/test-utils.ts deleted file mode 100644 index ed7fa89d08c..00000000000 --- a/amplify-migration-apps/fitness-tracker/test-utils.ts +++ /dev/null @@ -1,360 +0,0 @@ -// test-utils.ts -/** - * Shared test utilities for Fitness Tracker Gen1 and Gen2 test scripts - */ - -import { Amplify } from 'aws-amplify'; -import { generateClient } from 'aws-amplify/api'; -import { getCurrentUser } from 'aws-amplify/auth'; -import { getWorkoutProgram, getExercise, getMeal, listWorkoutPrograms, listExercises, listMeals } from './src/graphql/queries'; -import { - createWorkoutProgram, - updateWorkoutProgram, - deleteWorkoutProgram, - createExercise, - updateExercise, - deleteExercise, - createMeal, - updateMeal, - deleteMeal, -} from './src/graphql/mutations'; -import { WorkoutProgramStatus } from './src/API'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import amplifyconfig from './src/amplifyconfiguration.json'; - -// Configure Amplify in this module to ensure api singletons see the config -Amplify.configure(amplifyconfig); - -// ============================================================ -// Shared Test Functions Factory -// ============================================================ - -export function createTestFunctions() { - function getAuthClient() { - return generateClient({ authMode: 'userPool' }); - } - - // ============================================================ - // Query Test Functions - // ============================================================ - - async function testListWorkoutPrograms(): Promise { - console.log('\n📋 Testing listWorkoutPrograms...'); - const authClient = getAuthClient(); - const result = await authClient.graphql({ query: listWorkoutPrograms }); - const programs = (result as any).data.listWorkoutPrograms.items; - console.log(`✅ Found ${programs.length} workout programs:`); - programs.forEach((p: any) => console.log(` - [${p.id}] ${p.title} (${p.status})${p.deadline ? ` - Due: ${p.deadline}` : ''}`)); - return programs.length > 0 ? programs[0].id : null; - } - - async function testListExercises(): Promise { - console.log('\n💪 Testing listExercises...'); - const authClient = getAuthClient(); - const result = await authClient.graphql({ query: listExercises }); - const exercises = (result as any).data.listExercises.items; - console.log(`✅ Found ${exercises.length} exercises:`); - exercises.forEach((e: any) => - console.log(` - [${e.id}] ${e.name}${e.workoutProgramId ? ` (Program: ${e.workoutProgramId})` : ' (Unassigned)'}`), - ); - return exercises.length > 0 ? exercises[0].id : null; - } - - async function testListMeals(): Promise { - console.log('\n🍽️ Testing listMeals...'); - const apiKeyClient = generateClient({ authMode: 'apiKey' }); - const result = await apiKeyClient.graphql({ query: listMeals }); - const meals = (result as any).data.listMeals.items; - console.log(`✅ Found ${meals.length} meals:`); - meals.forEach((m: any) => console.log(` - [${m.id}] ${m.userName}: ${m.content} (${m.timestamp})`)); - return meals.length > 0 ? meals[0].id : null; - } - - async function testGetWorkoutProgram(id: string): Promise { - console.log(`\n🔍 Testing getWorkoutProgram (id: ${id})...`); - const authClient = getAuthClient(); - const result = await authClient.graphql({ - query: getWorkoutProgram, - variables: { id }, - }); - const program = (result as any).data.getWorkoutProgram; - console.log('✅ Workout Program:', { - id: program.id, - title: program.title, - status: program.status, - deadline: program.deadline, - owner: program.owner, - }); - } - - async function testGetExercise(id: string): Promise { - console.log(`\n🔍 Testing getExercise (id: ${id})...`); - const authClient = getAuthClient(); - const result = await authClient.graphql({ - query: getExercise, - variables: { id }, - }); - console.log('✅ Exercise:', (result as any).data.getExercise); - } - - async function testGetMeal(id: string): Promise { - console.log(`\n🔍 Testing getMeal (id: ${id})...`); - const apiKeyClient = generateClient({ authMode: 'apiKey' }); - const result = await apiKeyClient.graphql({ - query: getMeal, - variables: { id }, - }); - console.log('✅ Meal:', (result as any).data.getMeal); - } - - // ============================================================ - // Mutation Test Functions - Workout Programs - // ============================================================ - - async function testCreateWorkoutProgram(): Promise { - console.log('\n🆕 Testing createWorkoutProgram...'); - const authClient = getAuthClient(); - const result = await authClient.graphql({ - query: createWorkoutProgram, - variables: { - input: { - title: `Test Workout Program ${Date.now()}`, - status: WorkoutProgramStatus.ACTIVE, - description: 'This is a test workout program created by the test script', - deadline: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now - color: '#007bff', - }, - }, - }); - const program = (result as any).data.createWorkoutProgram; - console.log('✅ Created workout program:', { - id: program.id, - title: program.title, - status: program.status, - deadline: program.deadline, - owner: program.owner, - }); - return program.id; - } - - async function testUpdateWorkoutProgram(programId: string): Promise { - console.log(`\n✏️ Testing updateWorkoutProgram (id: ${programId})...`); - const authClient = getAuthClient(); - const result = await authClient.graphql({ - query: updateWorkoutProgram, - variables: { - input: { - id: programId, - title: 'Updated Test Workout Program', - description: 'This workout program was updated by the test script', - status: WorkoutProgramStatus.ON_HOLD, - color: '#28a745', - }, - }, - }); - const program = (result as any).data.updateWorkoutProgram; - console.log('✅ Updated workout program:', { - id: program.id, - title: program.title, - status: program.status, - color: program.color, - }); - } - - async function testDeleteWorkoutProgram(programId: string): Promise { - console.log(`\n🗑️ Testing deleteWorkoutProgram (id: ${programId})...`); - const authClient = getAuthClient(); - const result = await authClient.graphql({ - query: deleteWorkoutProgram, - variables: { input: { id: programId } }, - }); - const deleted = (result as any).data.deleteWorkoutProgram; - console.log('✅ Deleted workout program:', deleted.title); - } - - // ============================================================ - // Mutation Test Functions - Exercises - // ============================================================ - - async function testCreateExercise(programId?: string): Promise { - console.log('\n🆕 Testing createExercise...'); - const authClient = getAuthClient(); - const result = await authClient.graphql({ - query: createExercise, - variables: { - input: { - name: `Test Exercise ${Date.now()}`, - description: 'This is a test exercise created by the test script - 3 sets of 10 reps', - workoutProgramId: programId || null, - }, - }, - }); - const exercise = (result as any).data.createExercise; - console.log('✅ Created exercise:', { - id: exercise.id, - name: exercise.name, - workoutProgramId: exercise.workoutProgramId || 'unassigned', - owner: exercise.owner, - }); - return exercise.id; - } - - async function testUpdateExercise(exerciseId: string, newProgramId?: string): Promise { - console.log(`\n✏️ Testing updateExercise (id: ${exerciseId})...`); - const authClient = getAuthClient(); - const result = await authClient.graphql({ - query: updateExercise, - variables: { - input: { - id: exerciseId, - name: 'Updated Test Exercise', - description: 'This exercise was updated by the test script - 4 sets of 12 reps', - workoutProgramId: newProgramId || null, - }, - }, - }); - const exercise = (result as any).data.updateExercise; - console.log('✅ Updated exercise:', { - id: exercise.id, - name: exercise.name, - workoutProgramId: exercise.workoutProgramId || 'unassigned', - }); - } - - async function testDeleteExercise(exerciseId: string): Promise { - console.log(`\n🗑️ Testing deleteExercise (id: ${exerciseId})...`); - const authClient = getAuthClient(); - const result = await authClient.graphql({ - query: deleteExercise, - variables: { input: { id: exerciseId } }, - }); - const deleted = (result as any).data.deleteExercise; - console.log('✅ Deleted exercise:', deleted.name); - } - - // ============================================================ - // Mutation Test Functions - Meals - // ============================================================ - - async function testCreateMeal(): Promise { - console.log('\n🆕 Testing createMeal...'); - const apiKeyClient = generateClient({ authMode: 'apiKey' }); - const user = await getCurrentUser(); - const result = await apiKeyClient.graphql({ - query: createMeal, - variables: { - input: { - userName: user.username, - content: `Test meal: Chicken breast, rice, and vegetables - ${Date.now()}`, - timestamp: new Date().toISOString(), - }, - }, - }); - const meal = (result as any).data.createMeal; - console.log('✅ Created meal:', { - id: meal.id, - userName: meal.userName, - content: meal.content, - timestamp: meal.timestamp, - }); - return meal.id; - } - - async function testUpdateMeal(mealId: string): Promise { - console.log(`\n✏️ Testing updateMeal (id: ${mealId})...`); - const apiKeyClient = generateClient({ authMode: 'apiKey' }); - const result = await apiKeyClient.graphql({ - query: updateMeal, - variables: { - input: { - id: mealId, - content: 'Updated meal: Grilled salmon, quinoa, and steamed broccoli', - }, - }, - }); - const meal = (result as any).data.updateMeal; - console.log('✅ Updated meal:', { - id: meal.id, - content: meal.content, - }); - } - - async function testDeleteMeal(mealId: string): Promise { - console.log(`\n🗑️ Testing deleteMeal (id: ${mealId})...`); - const apiKeyClient = generateClient({ authMode: 'apiKey' }); - const result = await apiKeyClient.graphql({ - query: deleteMeal, - variables: { input: { id: mealId } }, - }); - const deleted = (result as any).data.deleteMeal; - console.log('✅ Deleted meal:', deleted.content); - } - - return { - testListWorkoutPrograms, - testListExercises, - testListMeals, - testGetWorkoutProgram, - testGetExercise, - testGetMeal, - testCreateWorkoutProgram, - testUpdateWorkoutProgram, - testDeleteWorkoutProgram, - testCreateExercise, - testUpdateExercise, - testDeleteExercise, - testCreateMeal, - testUpdateMeal, - testDeleteMeal, - }; -} - -// ============================================================ -// Shared Test Orchestration Functions -// ============================================================ - -export function createTestOrchestrator(testFunctions: ReturnType, runner: TestRunner) { - async function runQueryTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('📖 PART 1: Authenticated GraphQL Queries'); - console.log('='.repeat(50)); - - const programId = await runner.runTest('listWorkoutPrograms', testFunctions.testListWorkoutPrograms); - const exerciseId = await runner.runTest('listExercises', testFunctions.testListExercises); - const mealId = await runner.runTest('listMeals', testFunctions.testListMeals); - - if (programId) await runner.runTest('getWorkoutProgram', () => testFunctions.testGetWorkoutProgram(programId)); - if (exerciseId) await runner.runTest('getExercise', () => testFunctions.testGetExercise(exerciseId)); - if (mealId) await runner.runTest('getMeal', () => testFunctions.testGetMeal(mealId)); - } - - async function runMutationTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('✏️ PART 2: Authenticated GraphQL Mutations'); - console.log('='.repeat(50)); - - // Create workout program and exercise - const programId = await runner.runTest('createWorkoutProgram', testFunctions.testCreateWorkoutProgram); - const exerciseId = await runner.runTest('createExercise', () => testFunctions.testCreateExercise(programId || undefined)); - - // Update workout program and exercise - if (programId) await runner.runTest('updateWorkoutProgram', () => testFunctions.testUpdateWorkoutProgram(programId)); - if (exerciseId) await runner.runTest('updateExercise', () => testFunctions.testUpdateExercise(exerciseId, programId || undefined)); - - // Create, update, and delete meal - const mealId = await runner.runTest('createMeal', testFunctions.testCreateMeal); - if (mealId) { - await runner.runTest('updateMeal', () => testFunctions.testUpdateMeal(mealId)); - await runner.runTest('deleteMeal', () => testFunctions.testDeleteMeal(mealId)); - } - - // Cleanup: delete exercise and workout program - if (exerciseId) await runner.runTest('deleteExercise', () => testFunctions.testDeleteExercise(exerciseId)); - if (programId) await runner.runTest('deleteWorkoutProgram', () => testFunctions.testDeleteWorkoutProgram(programId)); - } - - return { - runQueryTests, - runMutationTests, - }; -} diff --git a/amplify-migration-apps/fitness-tracker/tests/api-admin.test.ts b/amplify-migration-apps/fitness-tracker/tests/api-admin.test.ts new file mode 100644 index 00000000000..3af2b3dd33e --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/tests/api-admin.test.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { signIn, signOut } from 'aws-amplify/auth'; +import { get } from 'aws-amplify/api'; +import { ADMIN_API_NAME } from '../src/api-config'; +import { signUp, config } from './signup'; + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('auth', () => { + it('GET /admin/users returns a user list with count', async () => { + const restOperation = get({ + apiName: ADMIN_API_NAME, + path: '/admin/users', + }); + + const { body } = await restOperation.response; + const response = await body.json() as any; + + expect(response).toBeDefined(); + expect(typeof response.count).toBe('number'); + expect(response.count).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/amplify-migration-apps/fitness-tracker/tests/api-graphql.test.ts b/amplify-migration-apps/fitness-tracker/tests/api-graphql.test.ts new file mode 100644 index 00000000000..86d580b7f7b --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/tests/api-graphql.test.ts @@ -0,0 +1,325 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { generateClient } from 'aws-amplify/api'; +import { getCurrentUser, signIn, signOut } from 'aws-amplify/auth'; +import { signUp, config } from './signup'; +import { + getWorkoutProgram, getExercise, getMeal, + listWorkoutPrograms, listExercises, listMeals, +} from '../src/graphql/queries'; +import { + createWorkoutProgram, updateWorkoutProgram, deleteWorkoutProgram, + createExercise, updateExercise, deleteExercise, + createMeal, updateMeal, deleteMeal, +} from '../src/graphql/mutations'; +import { WorkoutProgramStatus } from '../src/API'; + +const guest = () => generateClient({ authMode: 'apiKey' }); +const auth = () => generateClient({ authMode: 'userPool' }); + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('guest', () => { + it('creates a meal with correct fields', async () => { + const currentUser = await getCurrentUser(); + const content = `Chicken and rice - ${Date.now()}`; + const timestamp = new Date().toISOString(); + + const result = await guest().graphql({ + query: createMeal, + variables: { input: { userName: currentUser.username, content, timestamp } }, + }); + const meal = (result as any).data.createMeal; + + expect(typeof meal.id).toBe('string'); + expect(meal.id.length).toBeGreaterThan(0); + expect(meal.userName).toBe(currentUser.username); + expect(meal.content).toBe(content); + expect(meal.timestamp).toBe(timestamp); + expect(meal.createdAt).toBeDefined(); + expect(meal.updatedAt).toBeDefined(); + }); + + it('reads a meal by id', async () => { + const currentUser = await getCurrentUser(); + const content = `Read meal test - ${Date.now()}`; + const timestamp = new Date().toISOString(); + + const createResult = await guest().graphql({ + query: createMeal, + variables: { input: { userName: currentUser.username, content, timestamp } }, + }); + const created = (createResult as any).data.createMeal; + + const getResult = await guest().graphql({ query: getMeal, variables: { id: created.id } }); + const fetched = (getResult as any).data.getMeal; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.userName).toBe(currentUser.username); + expect(fetched.content).toBe(content); + expect(fetched.timestamp).toBe(timestamp); + }); + + it('updates a meal and persists changes', async () => { + const currentUser = await getCurrentUser(); + const createResult = await guest().graphql({ + query: createMeal, + variables: { input: { userName: currentUser.username, content: 'Original meal', timestamp: new Date().toISOString() } }, + }); + const created = (createResult as any).data.createMeal; + + const updatedContent = 'Grilled salmon and quinoa'; + await guest().graphql({ query: updateMeal, variables: { input: { id: created.id, content: updatedContent } } }); + + const getResult = await guest().graphql({ query: getMeal, variables: { id: created.id } }); + const fetched = (getResult as any).data.getMeal; + + expect(fetched.content).toBe(updatedContent); + expect(fetched.userName).toBe(currentUser.username); + expect(fetched.timestamp).toBe(created.timestamp); + }); + + it('deletes a meal', async () => { + const currentUser = await getCurrentUser(); + const createResult = await guest().graphql({ + query: createMeal, + variables: { input: { userName: currentUser.username, content: 'Delete me', timestamp: new Date().toISOString() } }, + }); + const created = (createResult as any).data.createMeal; + + await guest().graphql({ query: deleteMeal, variables: { input: { id: created.id } } }); + + const getResult = await guest().graphql({ query: getMeal, variables: { id: created.id } }); + expect((getResult as any).data.getMeal).toBeNull(); + }); + + it('lists meals including a newly created one', async () => { + const currentUser = await getCurrentUser(); + const content = `List meal test - ${Date.now()}`; + const createResult = await guest().graphql({ + query: createMeal, + variables: { input: { userName: currentUser.username, content, timestamp: new Date().toISOString() } }, + }); + const created = (createResult as any).data.createMeal; + + const listResult = await guest().graphql({ query: listMeals }); + const items = (listResult as any).data.listMeals.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((m: any) => m.id === created.id); + expect(found).toBeDefined(); + expect(found.content).toBe(content); + }); + + it('cannot create a WorkoutProgram', async () => { + await expect( + guest().graphql({ + query: createWorkoutProgram, + variables: { input: { title: 'Should fail', status: WorkoutProgramStatus.ACTIVE } }, + }), + ).rejects.toBeDefined(); + }); +}); + +describe('auth', () => { + describe('WorkoutProgram', () => { + it('creates a workout program with correct fields', async () => { + const deadline = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + const input = { + title: `Test Program ${Date.now()}`, + status: WorkoutProgramStatus.ACTIVE, + description: 'Created by jest', + deadline, + color: '#007bff', + }; + + const result = await auth().graphql({ query: createWorkoutProgram, variables: { input } }); + const program = (result as any).data.createWorkoutProgram; + + expect(typeof program.id).toBe('string'); + expect(program.id.length).toBeGreaterThan(0); + expect(program.title).toBe(input.title); + expect(program.status).toBe(WorkoutProgramStatus.ACTIVE); + expect(program.description).toBe('Created by jest'); + expect(program.deadline).toBe(deadline); + expect(program.color).toBe('#007bff'); + expect(program.createdAt).toBeDefined(); + expect(program.updatedAt).toBeDefined(); + expect(program.owner).toBeDefined(); + }); + + it('reads a workout program by id', async () => { + const createResult = await auth().graphql({ + query: createWorkoutProgram, + variables: { input: { title: `Read Test ${Date.now()}`, status: WorkoutProgramStatus.COMPLETED, description: 'For read test' } }, + }); + const created = (createResult as any).data.createWorkoutProgram; + + const getResult = await auth().graphql({ query: getWorkoutProgram, variables: { id: created.id } }); + const fetched = (getResult as any).data.getWorkoutProgram; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.title).toBe(created.title); + expect(fetched.status).toBe(WorkoutProgramStatus.COMPLETED); + expect(fetched.description).toBe('For read test'); + }); + + it('updates a workout program and persists changes', async () => { + const createResult = await auth().graphql({ + query: createWorkoutProgram, + variables: { input: { title: `Update Test ${Date.now()}`, status: WorkoutProgramStatus.ACTIVE, color: '#000000' } }, + }); + const created = (createResult as any).data.createWorkoutProgram; + + await auth().graphql({ + query: updateWorkoutProgram, + variables: { input: { id: created.id, title: 'Updated Title', status: WorkoutProgramStatus.ON_HOLD, color: '#28a745', description: 'Now updated' } }, + }); + + const getResult = await auth().graphql({ query: getWorkoutProgram, variables: { id: created.id } }); + const fetched = (getResult as any).data.getWorkoutProgram; + + expect(fetched.title).toBe('Updated Title'); + expect(fetched.status).toBe(WorkoutProgramStatus.ON_HOLD); + expect(fetched.color).toBe('#28a745'); + expect(fetched.description).toBe('Now updated'); + }); + + it('deletes a workout program', async () => { + const createResult = await auth().graphql({ + query: createWorkoutProgram, + variables: { input: { title: `Delete Test ${Date.now()}`, status: WorkoutProgramStatus.ARCHIVED } }, + }); + const created = (createResult as any).data.createWorkoutProgram; + + await auth().graphql({ query: deleteWorkoutProgram, variables: { input: { id: created.id } } }); + + const getResult = await auth().graphql({ query: getWorkoutProgram, variables: { id: created.id } }); + expect((getResult as any).data.getWorkoutProgram).toBeNull(); + }); + + it('lists workout programs including a newly created one', async () => { + const title = `List Test ${Date.now()}`; + const createResult = await auth().graphql({ + query: createWorkoutProgram, + variables: { input: { title, status: WorkoutProgramStatus.ACTIVE } }, + }); + const created = (createResult as any).data.createWorkoutProgram; + + const listResult = await auth().graphql({ query: listWorkoutPrograms }); + const items = (listResult as any).data.listWorkoutPrograms.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((p: any) => p.id === created.id); + expect(found).toBeDefined(); + expect(found.title).toBe(title); + }); + }); + + describe('Exercise', () => { + async function createParentProgram(): Promise { + const result = await auth().graphql({ + query: createWorkoutProgram, + variables: { input: { title: `Exercise Parent ${Date.now()}`, status: WorkoutProgramStatus.ACTIVE } }, + }); + return (result as any).data.createWorkoutProgram.id; + } + + it('creates an exercise linked to a program', async () => { + const programId = await createParentProgram(); + const input = { name: `Bench Press ${Date.now()}`, description: '3 sets of 10 reps', workoutProgramId: programId }; + + const result = await auth().graphql({ query: createExercise, variables: { input } }); + const exercise = (result as any).data.createExercise; + + expect(typeof exercise.id).toBe('string'); + expect(exercise.id.length).toBeGreaterThan(0); + expect(exercise.name).toBe(input.name); + expect(exercise.description).toBe('3 sets of 10 reps'); + expect(exercise.workoutProgramId).toBe(programId); + expect(exercise.createdAt).toBeDefined(); + expect(exercise.owner).toBeDefined(); + }); + + it('reads an exercise by id', async () => { + const programId = await createParentProgram(); + const createResult = await auth().graphql({ + query: createExercise, + variables: { input: { name: `Read Exercise ${Date.now()}`, description: 'For read test', workoutProgramId: programId } }, + }); + const created = (createResult as any).data.createExercise; + + const getResult = await auth().graphql({ query: getExercise, variables: { id: created.id } }); + const fetched = (getResult as any).data.getExercise; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.name).toBe(created.name); + expect(fetched.description).toBe('For read test'); + expect(fetched.workoutProgramId).toBe(programId); + }); + + it('updates an exercise and persists changes', async () => { + const programId = await createParentProgram(); + const createResult = await auth().graphql({ + query: createExercise, + variables: { input: { name: `Update Exercise ${Date.now()}`, description: 'Original', workoutProgramId: programId } }, + }); + const created = (createResult as any).data.createExercise; + + await auth().graphql({ + query: updateExercise, + variables: { input: { id: created.id, name: 'Updated Deadlift', description: '4 sets of 12 reps', workoutProgramId: programId } }, + }); + + const getResult = await auth().graphql({ query: getExercise, variables: { id: created.id } }); + const fetched = (getResult as any).data.getExercise; + + expect(fetched.name).toBe('Updated Deadlift'); + expect(fetched.description).toBe('4 sets of 12 reps'); + }); + + it('deletes an exercise', async () => { + const programId = await createParentProgram(); + const createResult = await auth().graphql({ + query: createExercise, + variables: { input: { name: `Delete Exercise ${Date.now()}`, workoutProgramId: programId } }, + }); + const created = (createResult as any).data.createExercise; + + await auth().graphql({ query: deleteExercise, variables: { input: { id: created.id } } }); + + const getResult = await auth().graphql({ query: getExercise, variables: { id: created.id } }); + expect((getResult as any).data.getExercise).toBeNull(); + }); + + it('lists exercises including a newly created one', async () => { + const programId = await createParentProgram(); + const name = `List Exercise ${Date.now()}`; + const createResult = await auth().graphql({ + query: createExercise, + variables: { input: { name, workoutProgramId: programId } }, + }); + const created = (createResult as any).data.createExercise; + + const listResult = await auth().graphql({ query: listExercises }); + const items = (listResult as any).data.listExercises.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((e: any) => e.id === created.id); + expect(found).toBeDefined(); + expect(found.name).toBe(name); + }); + }); +}); diff --git a/amplify-migration-apps/fitness-tracker/tests/api-nutrition.test.ts b/amplify-migration-apps/fitness-tracker/tests/api-nutrition.test.ts new file mode 100644 index 00000000000..3a88f1f7ec2 --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/tests/api-nutrition.test.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { getCurrentUser, signIn, signOut } from 'aws-amplify/auth'; +import { post } from 'aws-amplify/api'; +import { NUTRITION_API_NAME } from '../src/api-config'; +import { signUp, config } from './signup'; + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('auth', () => { + it('POST /nutrition/log returns a success message', async () => { + const currentUser = await getCurrentUser(); + + const restOperation = post({ + apiName: NUTRITION_API_NAME, + path: '/nutrition/log', + options: { + body: { userName: currentUser.username, content: `Jest nutrition log - ${Date.now()}` }, + }, + }); + + const { body } = await restOperation.response; + const response = await body.json() as any; + + expect(response).toBeDefined(); + expect(typeof response.message).toBe('string'); + expect(response.message.length).toBeGreaterThan(0); + }); +}); diff --git a/amplify-migration-apps/fitness-tracker/tests/auth.test.ts b/amplify-migration-apps/fitness-tracker/tests/auth.test.ts new file mode 100644 index 00000000000..feb2c316f81 --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/tests/auth.test.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import { randomBytes } from 'crypto'; +import { config } from './signup'; + +describe('PreSignUp trigger', () => { + it('allows user creation with an amazon.com email', async () => { + const gen2Auth = (config as any)?.auth; + const userPoolId = config.aws_user_pools_id ?? gen2Auth?.user_pool_id; + const region = config.aws_cognito_region ?? gen2Auth?.aws_region; + + const cognitoClient = new CognitoIdentityProviderClient({ region }); + + await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: userPoolId, + Username: `testuser-${randomBytes(4).toString('hex')}`, + TemporaryPassword: `Test${randomBytes(4).toString('hex')}!Aa1`, + UserAttributes: [ + { Name: 'email', Value: `allowed-${randomBytes(4).toString('hex')}@amazon.com` }, + { Name: 'email_verified', Value: 'true' }, + ], + MessageAction: 'SUPPRESS', + })); + }); + + it('rejects user creation with a non-amazon.com email', async () => { + const gen2Auth = (config as any)?.auth; + const userPoolId = config.aws_user_pools_id ?? gen2Auth?.user_pool_id; + const region = config.aws_cognito_region ?? gen2Auth?.aws_region; + + const cognitoClient = new CognitoIdentityProviderClient({ region }); + + await expect( + cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: userPoolId, + Username: `testuser-${randomBytes(4).toString('hex')}`, + TemporaryPassword: `Test${randomBytes(4).toString('hex')}!Aa1`, + UserAttributes: [ + { Name: 'email', Value: `rejected-${randomBytes(4).toString('hex')}@notallowed.com` }, + { Name: 'email_verified', Value: 'true' }, + ], + MessageAction: 'SUPPRESS', + })), + ).rejects.toBeDefined(); + }); +}); diff --git a/amplify-migration-apps/fitness-tracker/tests/jest.setup.ts b/amplify-migration-apps/fitness-tracker/tests/jest.setup.ts new file mode 100644 index 00000000000..bb0b4613b66 --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/tests/jest.setup.ts @@ -0,0 +1 @@ +jest.retryTimes(3); diff --git a/amplify-migration-apps/fitness-tracker/tests/signup.ts b/amplify-migration-apps/fitness-tracker/tests/signup.ts new file mode 100644 index 00000000000..ed8c5eb7210 --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/tests/signup.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, + AdminSetUserPasswordCommand, + AdminAddUserToGroupCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import * as fs from 'fs'; +import { randomBytes } from 'crypto'; +import { configureAmplify } from '../src/api-config'; + +import { webcrypto } from 'crypto'; +if (typeof globalThis.crypto === 'undefined') { + (globalThis as any).crypto = webcrypto; +} + +const CONFIG_PATH = process.env.APP_CONFIG_PATH; +if (!CONFIG_PATH) { + throw new Error('APP_CONFIG_PATH environment variable is required'); +} + +export const config = JSON.parse(fs.readFileSync(CONFIG_PATH, { encoding: 'utf-8' })); +configureAmplify(config); + +export async function signUp(cfg: any): Promise<{ username: string; password: string }> { + const gen2Auth = (cfg as any)?.auth; + const userPoolId = cfg.aws_user_pools_id ?? gen2Auth?.user_pool_id; + const region = cfg.aws_cognito_region ?? gen2Auth?.aws_region; + + const uname = generateTestUsername(); + const pwd = generateTestPassword(); + + const cognitoClient = new CognitoIdentityProviderClient({ region }); + + await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: userPoolId, + Username: uname, + TemporaryPassword: pwd, + UserAttributes: [ + { Name: 'email', Value: generateTestEmail() }, + { Name: 'email_verified', Value: 'true' }, + ], + MessageAction: 'SUPPRESS', + })); + + await cognitoClient.send(new AdminSetUserPasswordCommand({ + UserPoolId: userPoolId, + Username: uname, + Password: pwd, + Permanent: true, + })); + + await cognitoClient.send(new AdminAddUserToGroupCommand({ + UserPoolId: userPoolId, + Username: uname, + GroupName: 'Admin', + })); + + return { username: uname, password: pwd }; +} + +function generateTestPassword(): string { + return `Test${randomSuffix()}!Aa1`; +} + +function generateTestEmail(): string { + return `testuser-${randomSuffix()}@amazon.com`; +} + +function generateTestUsername(): string { + return `testuser-${randomSuffix()}`; +} + +function randomSuffix(): string { + return randomBytes(4).toString('hex'); +} diff --git a/amplify-migration-apps/imported-resources/README.md b/amplify-migration-apps/imported-resources/README.md index 86c60e10041..6ea9a35f2af 100644 --- a/amplify-migration-apps/imported-resources/README.md +++ b/amplify-migration-apps/imported-resources/README.md @@ -222,25 +222,14 @@ git checkout -b gen2-main npx amplify gen2-migration generate ``` -**Edit in `./amplify/data/resource.ts`:** - -```diff -- branchName: "main" -+ branchName: "gen2-main" -``` - -**Edit in `./amplify/function/quotegenerator/index.js`:** - -```diff -- exports.handler = async (event) => { -+ export async function handler(event) { +```console +npm run post-generate ``` -**Edit in `./src/main.tsx`:** - -```diff -- import amplifyconfig from './amplifyconfiguration.json'; -+ import amplifyconfig from '../amplify_outputs.json'; +```console +rm -rf node_modules package-lock.json +npm install +npm install --package-lock-only ``` ```console diff --git a/amplify-migration-apps/imported-resources/_snapshot.post.generate/package.json b/amplify-migration-apps/imported-resources/_snapshot.post.generate/package.json index 129f325d698..39383bbfbf5 100644 --- a/amplify-migration-apps/imported-resources/_snapshot.post.generate/package.json +++ b/amplify-migration-apps/imported-resources/_snapshot.post.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/importedresources", + "name": "@amplify-migration-apps/importedresources-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/imported-resources/_snapshot.pre.generate/package.json b/amplify-migration-apps/imported-resources/_snapshot.pre.generate/package.json index 63e81be28bf..9c8dcdafeb3 100644 --- a/amplify-migration-apps/imported-resources/_snapshot.pre.generate/package.json +++ b/amplify-migration-apps/imported-resources/_snapshot.pre.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/importedresources", + "name": "@amplify-migration-apps/importedresources-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/imported-resources/backend/configure.sh b/amplify-migration-apps/imported-resources/backend/configure.sh new file mode 100755 index 00000000000..643e6a9079c --- /dev/null +++ b/amplify-migration-apps/imported-resources/backend/configure.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -euxo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cp -f ${script_dir}/schema.graphql ${script_dir}/../amplify/backend/api/importedresources/schema.graphql +cp -f ${script_dir}/quotegenerator.js ${script_dir}/../amplify/backend/function/importedresourcequotegenerator/src/index.js diff --git a/amplify-migration-apps/imported-resources/quotegenerator.js b/amplify-migration-apps/imported-resources/backend/quotegenerator.js similarity index 100% rename from amplify-migration-apps/imported-resources/quotegenerator.js rename to amplify-migration-apps/imported-resources/backend/quotegenerator.js diff --git a/amplify-migration-apps/imported-resources/schema.graphql b/amplify-migration-apps/imported-resources/backend/schema.graphql similarity index 100% rename from amplify-migration-apps/imported-resources/schema.graphql rename to amplify-migration-apps/imported-resources/backend/schema.graphql diff --git a/amplify-migration-apps/imported-resources/configure.sh b/amplify-migration-apps/imported-resources/configure.sh deleted file mode 100755 index 56afdb93bd1..00000000000 --- a/amplify-migration-apps/imported-resources/configure.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -cp -f schema.graphql ./amplify/backend/api/importedresources/schema.graphql -cp -f quotegenerator.js ./amplify/backend/function/importedresourcequotegenerator/src/index.js diff --git a/amplify-migration-apps/imported-resources/gen-1-cleanup.sh b/amplify-migration-apps/imported-resources/gen-1-cleanup.sh deleted file mode 100755 index 4dbe65cf025..00000000000 --- a/amplify-migration-apps/imported-resources/gen-1-cleanup.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -rm -rf amplify/ -rm src/amplifyconfiguration.json -rm src/aws-exports.js - -exit 0 diff --git a/amplify-migration-apps/imported-resources/gen1-test-script.ts b/amplify-migration-apps/imported-resources/gen1-test-script.ts deleted file mode 100644 index 4144071dd1a..00000000000 --- a/amplify-migration-apps/imported-resources/gen1-test-script.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Gen1 Test Script for Project Boards App - * - * This script tests all functionality for Amplify Gen1: - * 1. Public GraphQL Queries (no auth required) - * 2. Authenticated GraphQL Mutations (requires auth) - * 3. S3 Storage Operations (requires auth) - * - * Credentials are provisioned automatically via Cognito SignUp + AdminConfirmSignUp. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import amplifyconfig from './src/amplifyconfiguration.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Gen1 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. Public GraphQL Queries'); - console.log(' 2. Authenticated GraphQL Mutations'); - console.log(' 3. S3 Storage Operations'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - // Sign in from this module so the auth tokens are available to api/storage - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { runPublicQueryTests, runMutationTests, runStorageTests } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Public queries (no auth needed) - await runPublicQueryTests(); - - // Part 2: Mutations (already authenticated) - await runMutationTests(); - - // Part 3: Storage - await runStorageTests(); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/imported-resources/jest.config.js b/amplify-migration-apps/imported-resources/jest.config.js new file mode 100644 index 00000000000..ab207313f84 --- /dev/null +++ b/amplify-migration-apps/imported-resources/jest.config.js @@ -0,0 +1,23 @@ +/** @type {import('jest').Config} */ +export default { + testMatch: ['/tests/**/*.test.ts'], + modulePathIgnorePatterns: ['/_snapshot', '/amplify'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + transform: { + '^.+\\.tsx?$': ['ts-jest', { + tsconfig: { + target: 'ES2022', + module: 'CommonJS', + moduleResolution: 'node', + esModuleInterop: true, + allowJs: true, + noEmit: true, + strict: true, + skipLibCheck: true, + types: ['node', 'jest'], + }, + }], + }, + testTimeout: 30_000, + setupFilesAfterEnv: ['/tests/jest.setup.ts'], +}; diff --git a/amplify-migration-apps/imported-resources/migration-config.json b/amplify-migration-apps/imported-resources/migration-config.json deleted file mode 100644 index 209356f5d25..00000000000 --- a/amplify-migration-apps/imported-resources/migration-config.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "app": { - "name": "importedresources", - "description": "Project board app with authentication and file storage", - "framework": "react" - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["API_KEY"] - }, - "auth": { - "signInMethods": ["email"], - "socialProviders": [] - }, - "storage": { - "buckets": [ - { - "name": "images", - "access": ["auth", "guest"] - } - ] - }, - "function": { - "functions": [ - { - "name": "quotegenerator", - "runtime": "nodejs", - "template": "hello-world" - } - ] - }, - "hosting": { - "type": "amplify-console" - } - } -} diff --git a/amplify-migration-apps/imported-resources/migration/post-generate.ts b/amplify-migration-apps/imported-resources/migration/post-generate.ts new file mode 100644 index 00000000000..27a474d69d2 --- /dev/null +++ b/amplify-migration-apps/imported-resources/migration/post-generate.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env npx ts-node +/** + * Post-generate script for imported-resources app. + * + * Applies manual edits required after `amplify gen2-migration generate`: + * 1. Update branchName in amplify/data/resource.ts to the value of AWS_BRANCH + * env var, or the current git branch if AWS_BRANCH is not set + * 2. Convert quotegenerator function from CommonJS to ESM + * 3. Update frontend import from amplifyconfiguration.json to amplify_outputs.json + */ + +import { execSync } from 'child_process'; +import fs from 'fs/promises'; +import path from 'path'; + +function resolveTargetBranch(): string { + if (process.env.AWS_BRANCH) { + return process.env.AWS_BRANCH; + } + return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); +} + +async function updateBranchName(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); + + const content = await fs.readFile(resourcePath, 'utf-8'); + + const targetBranch = resolveTargetBranch(); + + const updated = content.replace( + /branchName:\s*['"]([^'"]+)['"]/, + `branchName: '${targetBranch}'`, + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function convertQuotegeneratorToESM(appPath: string): Promise { + const handlerPath = path.join(appPath, 'amplify', 'function', 'quotegenerator', 'index.js'); + + const content = await fs.readFile(handlerPath, 'utf-8'); + + let updated = content.replace( + /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + updated = updated.replace( + /module\.exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + await fs.writeFile(handlerPath, updated, 'utf-8'); +} + +async function updateFrontendConfig(appPath: string): Promise { + const mainPath = path.join(appPath, 'src', 'main.tsx'); + + const content = await fs.readFile(mainPath, 'utf-8'); + + const updated = content.replace( + /from\s*["']\.\/amplifyconfiguration\.json["']/g, + "from '../amplify_outputs.json'", + ); + + await fs.writeFile(mainPath, updated, 'utf-8'); +} + +export async function postGenerate(appPath: string): Promise { + await updateBranchName(appPath); + await convertQuotegeneratorToESM(appPath); + await updateFrontendConfig(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postGenerate(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/imported-resources/migration/post-refactor.ts b/amplify-migration-apps/imported-resources/migration/post-refactor.ts new file mode 100644 index 00000000000..e0411ddad75 --- /dev/null +++ b/amplify-migration-apps/imported-resources/migration/post-refactor.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env npx ts-node +/** + * Post-refactor script for imported-resources app. + * + * Applies manual edits required after `amplify gen2-migration refactor`: + * 1. Uncomment s3Bucket.bucketName in amplify/backend.ts to sync with deployed template + */ + +import fs from 'fs/promises'; +import path from 'path'; + +/** + * Uncomment the s3Bucket.bucketName line in backend.ts. + * + * The generate step produces a commented line like: + * // s3Bucket.bucketName = 'bucket-name-here'; + * + * After refactor, we need to uncomment it to sync with the deployed template. + */ +async function uncommentS3BucketName(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + const content = await fs.readFile(backendPath, 'utf-8'); + + // Match commented bucket name line: // s3Bucket.bucketName = '...'; + const updated = content.replace( + /\/\/\s*(s3Bucket\.bucketName\s*=\s*['"][^'"]+['"];?)/g, + '$1', + ); + + await fs.writeFile(backendPath, updated, 'utf-8'); +} + +export async function postRefactor(appPath: string): Promise { + await uncommentS3BucketName(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRefactor(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/imported-resources/package.json b/amplify-migration-apps/imported-resources/package.json index 854b47981d7..43ec22a002c 100644 --- a/amplify-migration-apps/imported-resources/package.json +++ b/amplify-migration-apps/imported-resources/package.json @@ -11,10 +11,19 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "configure": "./configure.sh", + "configure": "./backend/configure.sh", "create-auth": "tsx create-auth-resources.ts", "sanitize": "tsx ../sanitize.ts", - "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit" + "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit", + "test:gen1": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-src/amplifyconfiguration.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:gen2": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-amplify_outputs.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:e2e": "cd ../../packages/amplify-gen2-migration-e2e-system && npx tsx src/cli.ts --app imported-resources --profile ${AWS_PROFILE:-default}", + "pre-push": "true", + "post-generate": "npx tsx migration/post-generate.ts", + "post-refactor": "npx tsx migration/post-refactor.ts", + "post-sandbox": "true", + "pre-sandbox": "true", + "post-push": "true" }, "dependencies": { "@aws-amplify/ui-react": "^6.13.0", @@ -28,6 +37,7 @@ "@aws-sdk/client-cognito-identity-provider": "^3.0.0", "@aws-sdk/client-iam": "^3.0.0", "@eslint/js": "^9.36.0", + "@types/jest": "^29.5.0", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", @@ -36,8 +46,10 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", - "typescript": "~5.9.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.0", "tsx": "^4.19.0", + "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" } diff --git a/amplify-migration-apps/imported-resources/test-utils.ts b/amplify-migration-apps/imported-resources/test-utils.ts deleted file mode 100644 index 70a38f3786c..00000000000 --- a/amplify-migration-apps/imported-resources/test-utils.ts +++ /dev/null @@ -1,383 +0,0 @@ -// test-utils.ts - -import { Amplify } from 'aws-amplify'; -import { generateClient } from 'aws-amplify/api'; -import { uploadData, getUrl, downloadData, getProperties } from 'aws-amplify/storage'; -import * as fs from 'fs'; -import { getProject, getTodo, listProjects, listTodos } from './src/graphql/queries'; -import { createProject, updateProject, deleteProject, createTodo, updateTodo, deleteTodo } from './src/graphql/mutations'; -import { ProjectStatus } from './src/API'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import amplifyconfig from './src/amplifyconfiguration.json'; - -// Configure Amplify in this module to ensure api/storage singletons see the config -Amplify.configure(amplifyconfig); - -// Custom query for getRandomQuote (not in generated files) -const getRandomQuote = /* GraphQL */ ` - query GetRandomQuote { - getRandomQuote { - message - quote - author - timestamp - totalQuotes - } - } -`; - -// ============================================================ -// Shared Test Functions Factory -// ============================================================ - -export function createTestFunctions() { - const publicClient = generateClient({ authMode: 'apiKey' }); - - // ============================================================ - // Public Query Test Functions - // ============================================================ - - async function testGetRandomQuote(): Promise { - console.log('\n📝 Testing getRandomQuote...'); - const result = await publicClient.graphql({ query: getRandomQuote }); - console.log('✅ Success:', (result as any).data.getRandomQuote); - } - - async function testListProjects(): Promise { - console.log('\n📋 Testing listProjects...'); - const result = await publicClient.graphql({ query: listProjects }); - const projects = (result as any).data.listProjects.items; - console.log(`✅ Found ${projects.length} projects:`); - projects.forEach((p: any) => console.log(` - [${p.id}] ${p.title} (${p.status})`)); - return projects.length > 0 ? projects[0].id : null; - } - - async function testListTodos(): Promise { - console.log('\n✅ Testing listTodos...'); - const result = await publicClient.graphql({ query: listTodos }); - const todos = (result as any).data.listTodos.items; - console.log(`✅ Found ${todos.length} todos:`); - todos.forEach((t: any) => console.log(` - [${t.id}] ${t.name}: ${t.description || '(no description)'}`)); - return todos.length > 0 ? todos[0].id : null; - } - - async function testGetProject(id: string): Promise { - console.log(`\n🔍 Testing getProject (id: ${id})...`); - const result = await publicClient.graphql({ - query: getProject, - variables: { id }, - }); - console.log('✅ Project:', (result as any).data.getProject); - } - - async function testGetTodo(id: string): Promise { - console.log(`\n🔍 Testing getTodo (id: ${id})...`); - const result = await publicClient.graphql({ - query: getTodo, - variables: { id }, - }); - console.log('✅ Todo:', (result as any).data.getTodo); - } - - // ============================================================ - // Mutation Test Functions - // ============================================================ - - async function testCreateProject(): Promise { - console.log('\n🆕 Testing createProject...'); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: createProject, - variables: { - input: { - title: `Test Project ${Date.now()}`, - status: ProjectStatus.ACTIVE, - description: 'This is a test project created by the test script', - color: '#007bff', - }, - }, - }); - - const project = (result as any).data.createProject; - console.log('✅ Created project:', { - id: project.id, - title: project.title, - status: project.status, - owner: project.owner, - }); - return project.id; - } - - async function testUpdateProject(projectId: string): Promise { - console.log(`\n✏️ Testing updateProject (id: ${projectId})...`); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: updateProject, - variables: { - input: { - id: projectId, - title: 'Updated Test Project', - description: 'This project was updated by the test script', - status: ProjectStatus.ON_HOLD, - color: '#28a745', - }, - }, - }); - - const project = (result as any).data.updateProject; - console.log('✅ Updated project:', { - id: project.id, - title: project.title, - status: project.status, - color: project.color, - }); - } - - async function testDeleteProject(projectId: string): Promise { - console.log(`\n🗑️ Testing deleteProject (id: ${projectId})...`); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: deleteProject, - variables: { input: { id: projectId } }, - }); - const deleted = (result as any).data.deleteProject; - console.log('✅ Deleted project:', deleted.title); - } - - async function testCreateTodo(projectId?: string, images?: string[]): Promise { - console.log('\n🆕 Testing createTodo...'); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: createTodo, - variables: { - input: { - name: `Test Todo ${Date.now()}`, - description: 'This is a test todo created by the test script', - projectID: projectId || null, - images: images || [], - }, - }, - }); - - const todo = (result as any).data.createTodo; - console.log('✅ Created todo:', { - id: todo.id, - name: todo.name, - projectID: todo.projectID || 'unassigned', - images: todo.images?.length || 0, - owner: todo.owner, - }); - return todo.id; - } - - async function testUpdateTodo(todoId: string, newProjectId?: string): Promise { - console.log(`\n✏️ Testing updateTodo (id: ${todoId})...`); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: updateTodo, - variables: { - input: { - id: todoId, - name: 'Updated Test Todo', - description: 'This todo was updated by the test script', - projectID: newProjectId || null, - }, - }, - }); - - const todo = (result as any).data.updateTodo; - console.log('✅ Updated todo:', { - id: todo.id, - name: todo.name, - projectID: todo.projectID || 'unassigned', - }); - } - - async function testDeleteTodo(todoId: string): Promise { - console.log(`\n🗑️ Testing deleteTodo (id: ${todoId})...`); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: deleteTodo, - variables: { input: { id: todoId } }, - }); - const deleted = (result as any).data.deleteTodo; - console.log('✅ Deleted todo:', deleted.name); - } - - // ============================================================ - // Storage Test Functions - // ============================================================ - - async function testUploadImage(): Promise { - console.log('\n📤 Testing uploadData (S3)...'); - - // Try to use local image file, fallback to generated image - const localImagePath = 'ADD_TEST_IMAGE_HERE'; - let imageBuffer: Buffer; - let contentType: string; - let fileExt: string; - - if (fs.existsSync(localImagePath)) { - imageBuffer = fs.readFileSync(localImagePath); - contentType = 'image/jpeg'; - fileExt = 'jpg'; - console.log(' Using local image file'); - } else { - // Fallback: create a simple test image (100x100 gray square) - const testImageBase64 = - 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAA3klEQVR42u3QMQEAAAgDILV/51nBzwci0JlYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqz8WgGPGAGBPQqrHAAAAABJRU5ErkJggg=='; - imageBuffer = Buffer.from(testImageBase64, 'base64'); - contentType = 'image/png'; - fileExt = 'png'; - console.log(' Using generated test image'); - } - - const fileName = `test-image-${Date.now()}.${fileExt}`; - const s3Path = `public/images/${fileName}`; - - console.log(` Uploading to: ${s3Path}`); - console.log(` File size: ${imageBuffer.length} bytes`); - - const result = await uploadData({ - path: s3Path, - data: imageBuffer, - options: { contentType }, - }).result; - - console.log('✅ Upload successful!'); - console.log(' Path:', result.path); - return result.path; - } - - async function testGetUrl(filePath: string): Promise { - console.log('\n🔗 Testing getUrl (S3)...'); - console.log(` File path: ${filePath}`); - - const result = await getUrl({ - path: filePath, - options: { expiresIn: 3600 }, - }); - - console.log('✅ Got signed URL!'); - console.log(' URL:', result.url.toString().substring(0, 100) + '...'); - console.log(' Expires at:', result.expiresAt); - return result.url.toString(); - } - - async function testGetProperties(filePath: string): Promise { - console.log('\n📋 Testing getProperties (S3)...'); - console.log(` File path: ${filePath}`); - - const properties = await getProperties({ path: filePath }); - - console.log('✅ Got file properties!'); - if ('contentType' in properties) console.log(' Content Type:', (properties as any).contentType); - if ('size' in properties) console.log(' Size:', (properties as any).size, 'bytes'); - if ('eTag' in properties) console.log(' ETag:', (properties as any).eTag); - if ('lastModified' in properties) console.log(' Last Modified:', (properties as any).lastModified); - } - - async function testDownloadData(filePath: string): Promise { - console.log('\n📥 Testing downloadData (S3)...'); - console.log(` File path: ${filePath}`); - - const downloadResult = await downloadData({ path: filePath }).result; - const blob = await downloadResult.body.blob(); - const arrayBuffer = await blob.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - console.log('✅ Download successful!'); - console.log(' Downloaded size:', buffer.length, 'bytes'); - console.log(' Content type:', blob.type); - - const localPath = `./downloaded-test-image-${Date.now()}.png`; - fs.writeFileSync(localPath, buffer); - console.log(' Saved to:', localPath); - } - - return { - testGetRandomQuote, - testListProjects, - testListTodos, - testGetProject, - testGetTodo, - testCreateProject, - testUpdateProject, - testDeleteProject, - testCreateTodo, - testUpdateTodo, - testDeleteTodo, - testUploadImage, - testGetUrl, - testGetProperties, - testDownloadData, - }; -} - -// ============================================================ -// Shared Test Orchestration Functions -// ============================================================ - -export function createTestOrchestrator(testFunctions: ReturnType, runner: TestRunner) { - async function runPublicQueryTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('📖 PART 1: Public GraphQL Queries (No Auth)'); - console.log('='.repeat(50)); - - await runner.runTest('getRandomQuote', testFunctions.testGetRandomQuote); - const projectId = await runner.runTest('listProjects', testFunctions.testListProjects); - const todoId = await runner.runTest('listTodos', testFunctions.testListTodos); - - if (projectId) await runner.runTest('getProject', () => testFunctions.testGetProject(projectId)); - if (todoId) await runner.runTest('getTodo', () => testFunctions.testGetTodo(todoId)); - } - - async function runMutationTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('✏️ PART 2: Authenticated GraphQL Mutations'); - console.log('='.repeat(50)); - - // Create project and todo - const projectId = await runner.runTest('createProject', testFunctions.testCreateProject); - const todoId = await runner.runTest('createTodo', () => testFunctions.testCreateTodo(projectId || undefined)); - - // Update project and todo - if (projectId) await runner.runTest('updateProject', () => testFunctions.testUpdateProject(projectId)); - if (todoId) await runner.runTest('updateTodo', () => testFunctions.testUpdateTodo(todoId, projectId || undefined)); - - // Cleanup: delete todo and project - if (todoId) await runner.runTest('deleteTodo', () => testFunctions.testDeleteTodo(todoId)); - if (projectId) await runner.runTest('deleteProject', () => testFunctions.testDeleteProject(projectId)); - } - - async function runStorageTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('📦 PART 3: S3 Storage Operations'); - console.log('='.repeat(50)); - - const uploadedPath = await runner.runTest('uploadImage', testFunctions.testUploadImage); - - if (uploadedPath) { - await runner.runTest('getUrl', () => testFunctions.testGetUrl(uploadedPath)); - await runner.runTest('getProperties', () => testFunctions.testGetProperties(uploadedPath)); - await runner.runTest('downloadData', () => testFunctions.testDownloadData(uploadedPath)); - - // Create a todo with the uploaded image - console.log('\n📝 Creating todo with uploaded image...'); - await runner.runTest('createTodoWithImage', () => testFunctions.testCreateTodo(undefined, [uploadedPath])); - console.log('🎉 Check your browser - the todo should appear with the image!'); - } - } - - return { - runPublicQueryTests, - runMutationTests, - runStorageTests, - }; -} diff --git a/amplify-migration-apps/imported-resources/tests/api.test.ts b/amplify-migration-apps/imported-resources/tests/api.test.ts new file mode 100644 index 00000000000..46b46f6b869 --- /dev/null +++ b/amplify-migration-apps/imported-resources/tests/api.test.ts @@ -0,0 +1,332 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { generateClient } from 'aws-amplify/api'; +import { signIn, signOut } from 'aws-amplify/auth'; +import { uploadData } from 'aws-amplify/storage'; +import { getProject, getTodo, listProjects, listTodos } from '../src/graphql/queries'; +import { + createProject, updateProject, deleteProject, + createTodo, updateTodo, deleteTodo, +} from '../src/graphql/mutations'; +import { ProjectStatus } from '../src/API'; +import { signUp, config } from './signup'; + +const getRandomQuote = /* GraphQL */ ` + query GetRandomQuote { + getRandomQuote { + message + quote + author + timestamp + totalQuotes + } + } +`; + +const guest = () => generateClient({ authMode: 'apiKey' }); +const auth = () => generateClient({ authMode: 'userPool' }); + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('guest', () => { + it('getRandomQuote returns a quote with all expected fields', async () => { + const result = await guest().graphql({ query: getRandomQuote }); + const quote = (result as any).data.getRandomQuote; + + expect(quote).toBeDefined(); + expect(typeof quote.message).toBe('string'); + expect(quote.message.length).toBeGreaterThan(0); + expect(typeof quote.quote).toBe('string'); + expect(quote.quote.length).toBeGreaterThan(0); + expect(typeof quote.author).toBe('string'); + expect(typeof quote.timestamp).toBe('string'); + expect(typeof quote.totalQuotes).toBe('number'); + expect(quote.totalQuotes).toBeGreaterThan(0); + }); + + it('lists projects', async () => { + const result = await guest().graphql({ query: listProjects }); + const items = (result as any).data.listProjects.items; + + expect(Array.isArray(items)).toBe(true); + }); + + it('reads a project by id', async () => { + const listResult = await guest().graphql({ query: listProjects }); + const items = (listResult as any).data.listProjects.items; + if (items.length === 0) return; + + const result = await guest().graphql({ query: getProject, variables: { id: items[0].id } }); + const project = (result as any).data.getProject; + + expect(project).not.toBeNull(); + expect(project.id).toBe(items[0].id); + expect(project.title).toBeDefined(); + expect(project.status).toBeDefined(); + }); + + it('lists todos', async () => { + const result = await guest().graphql({ query: listTodos }); + const items = (result as any).data.listTodos.items; + + expect(Array.isArray(items)).toBe(true); + }); + + it('reads a todo by id', async () => { + const listResult = await guest().graphql({ query: listTodos }); + const items = (listResult as any).data.listTodos.items; + if (items.length === 0) return; + + const result = await guest().graphql({ query: getTodo, variables: { id: items[0].id } }); + const todo = (result as any).data.getTodo; + + expect(todo).not.toBeNull(); + expect(todo.id).toBe(items[0].id); + expect(todo.name).toBeDefined(); + }); + + it('cannot create a project', async () => { + await expect( + guest().graphql({ + query: createProject, + variables: { input: { title: `Unauthorized ${Date.now()}`, status: ProjectStatus.ACTIVE } }, + }), + ).rejects.toBeDefined(); + }); + + it('cannot create a todo', async () => { + await expect( + guest().graphql({ + query: createTodo, + variables: { input: { name: `Unauthorized ${Date.now()}` } }, + }), + ).rejects.toBeDefined(); + }); +}); + + +describe('auth', () => { + describe('Project', () => { + it('creates a project with correct fields', async () => { + const deadline = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + const input = { + title: `Test Project ${Date.now()}`, + status: ProjectStatus.ACTIVE, + description: 'Created by jest', + deadline, + color: '#007bff', + }; + + const result = await auth().graphql({ query: createProject, variables: { input } }); + const project = (result as any).data.createProject; + + expect(typeof project.id).toBe('string'); + expect(project.id.length).toBeGreaterThan(0); + expect(project.title).toBe(input.title); + expect(project.status).toBe(ProjectStatus.ACTIVE); + expect(project.description).toBe('Created by jest'); + expect(project.deadline).toBe(deadline); + expect(project.color).toBe('#007bff'); + expect(project.createdAt).toBeDefined(); + expect(project.updatedAt).toBeDefined(); + expect(project.owner).toBeDefined(); + }); + + it('reads a project by id', async () => { + const createResult = await auth().graphql({ + query: createProject, + variables: { input: { title: `Read Test ${Date.now()}`, status: ProjectStatus.COMPLETED, description: 'For read test' } }, + }); + const created = (createResult as any).data.createProject; + + const getResult = await auth().graphql({ query: getProject, variables: { id: created.id } }); + const fetched = (getResult as any).data.getProject; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.title).toBe(created.title); + expect(fetched.status).toBe(ProjectStatus.COMPLETED); + expect(fetched.description).toBe('For read test'); + }); + + it('updates a project and persists changes', async () => { + const createResult = await auth().graphql({ + query: createProject, + variables: { input: { title: `Update Test ${Date.now()}`, status: ProjectStatus.ACTIVE, color: '#000000' } }, + }); + const created = (createResult as any).data.createProject; + + await auth().graphql({ + query: updateProject, + variables: { input: { id: created.id, title: 'Updated Title', status: ProjectStatus.ON_HOLD, color: '#28a745', description: 'Now updated' } }, + }); + + const getResult = await auth().graphql({ query: getProject, variables: { id: created.id } }); + const fetched = (getResult as any).data.getProject; + + expect(fetched.title).toBe('Updated Title'); + expect(fetched.status).toBe(ProjectStatus.ON_HOLD); + expect(fetched.color).toBe('#28a745'); + expect(fetched.description).toBe('Now updated'); + }); + + it('deletes a project', async () => { + const createResult = await auth().graphql({ + query: createProject, + variables: { input: { title: `Delete Test ${Date.now()}`, status: ProjectStatus.ARCHIVED } }, + }); + const created = (createResult as any).data.createProject; + + await auth().graphql({ query: deleteProject, variables: { input: { id: created.id } } }); + + const getResult = await auth().graphql({ query: getProject, variables: { id: created.id } }); + expect((getResult as any).data.getProject).toBeNull(); + }); + + it('lists projects including a newly created one', async () => { + const title = `List Test ${Date.now()}`; + const createResult = await auth().graphql({ + query: createProject, + variables: { input: { title, status: ProjectStatus.ACTIVE } }, + }); + const created = (createResult as any).data.createProject; + + const listResult = await auth().graphql({ query: listProjects }); + const items = (listResult as any).data.listProjects.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((p: any) => p.id === created.id); + expect(found).toBeDefined(); + expect(found.title).toBe(title); + }); + }); + + describe('Todo', () => { + async function createParentProject(): Promise { + const result = await auth().graphql({ + query: createProject, + variables: { input: { title: `Todo Parent ${Date.now()}`, status: ProjectStatus.ACTIVE } }, + }); + return (result as any).data.createProject.id; + } + + it('creates a todo linked to a project', async () => { + const projectId = await createParentProject(); + const input = { name: `Test Todo ${Date.now()}`, description: 'Created by jest', projectID: projectId, images: [] as string[] }; + + const result = await auth().graphql({ query: createTodo, variables: { input } }); + const todo = (result as any).data.createTodo; + + expect(typeof todo.id).toBe('string'); + expect(todo.id.length).toBeGreaterThan(0); + expect(todo.name).toBe(input.name); + expect(todo.description).toBe('Created by jest'); + expect(todo.projectID).toBe(projectId); + expect(todo.images).toEqual([]); + expect(todo.createdAt).toBeDefined(); + expect(todo.owner).toBeDefined(); + }); + + it('reads a todo by id', async () => { + const projectId = await createParentProject(); + const createResult = await auth().graphql({ + query: createTodo, + variables: { input: { name: `Read Todo ${Date.now()}`, description: 'For read test', projectID: projectId } }, + }); + const created = (createResult as any).data.createTodo; + + const getResult = await auth().graphql({ query: getTodo, variables: { id: created.id } }); + const fetched = (getResult as any).data.getTodo; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.name).toBe(created.name); + expect(fetched.description).toBe('For read test'); + expect(fetched.projectID).toBe(projectId); + }); + + it('updates a todo and persists changes', async () => { + const projectId = await createParentProject(); + const createResult = await auth().graphql({ + query: createTodo, + variables: { input: { name: `Update Todo ${Date.now()}`, description: 'Original', projectID: projectId } }, + }); + const created = (createResult as any).data.createTodo; + + await auth().graphql({ + query: updateTodo, + variables: { input: { id: created.id, name: 'Updated Todo', description: 'Now updated', projectID: projectId } }, + }); + + const getResult = await auth().graphql({ query: getTodo, variables: { id: created.id } }); + const fetched = (getResult as any).data.getTodo; + + expect(fetched.name).toBe('Updated Todo'); + expect(fetched.description).toBe('Now updated'); + }); + + it('deletes a todo', async () => { + const projectId = await createParentProject(); + const createResult = await auth().graphql({ + query: createTodo, + variables: { input: { name: `Delete Todo ${Date.now()}`, projectID: projectId } }, + }); + const created = (createResult as any).data.createTodo; + + await auth().graphql({ query: deleteTodo, variables: { input: { id: created.id } } }); + + const getResult = await auth().graphql({ query: getTodo, variables: { id: created.id } }); + expect((getResult as any).data.getTodo).toBeNull(); + }); + + it('lists todos including a newly created one', async () => { + const projectId = await createParentProject(); + const name = `List Todo ${Date.now()}`; + const createResult = await auth().graphql({ + query: createTodo, + variables: { input: { name, projectID: projectId } }, + }); + const created = (createResult as any).data.createTodo; + + const listResult = await auth().graphql({ query: listTodos }); + const items = (listResult as any).data.listTodos.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((t: any) => t.id === created.id); + expect(found).toBeDefined(); + expect(found.name).toBe(name); + }); + + it('creates a todo with an S3 image path', async () => { + const imageBuffer = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64'); + const fileName = `todo-image-${Date.now()}.png`; + const s3Path = `public/images/${fileName}`; + + const uploadResult = await uploadData({ + path: s3Path, + data: imageBuffer, + options: { contentType: 'image/png' }, + }).result; + + const result = await auth().graphql({ + query: createTodo, + variables: { input: { name: `Todo with image ${Date.now()}`, description: 'Has an image', images: [uploadResult.path] } }, + }); + const todo = (result as any).data.createTodo; + + expect(todo.images).toBeDefined(); + expect(Array.isArray(todo.images)).toBe(true); + expect(todo.images.length).toBe(1); + expect(todo.images[0]).toBe(uploadResult.path); + expect(todo.images[0]).toContain('public/images/'); + }); + }); +}); diff --git a/amplify-migration-apps/imported-resources/tests/jest.setup.ts b/amplify-migration-apps/imported-resources/tests/jest.setup.ts new file mode 100644 index 00000000000..bb0b4613b66 --- /dev/null +++ b/amplify-migration-apps/imported-resources/tests/jest.setup.ts @@ -0,0 +1 @@ +jest.retryTimes(3); diff --git a/amplify-migration-apps/imported-resources/tests/signup.ts b/amplify-migration-apps/imported-resources/tests/signup.ts new file mode 100644 index 00000000000..1c84498437d --- /dev/null +++ b/amplify-migration-apps/imported-resources/tests/signup.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Amplify } from 'aws-amplify'; +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, + AdminSetUserPasswordCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import * as fs from 'fs'; +import { randomBytes } from 'crypto'; + +import { webcrypto } from 'crypto'; +if (typeof globalThis.crypto === 'undefined') { + (globalThis as any).crypto = webcrypto; +} + +const CONFIG_PATH = process.env.APP_CONFIG_PATH; +if (!CONFIG_PATH) { + throw new Error('APP_CONFIG_PATH environment variable is required'); +} + +export const config = JSON.parse(fs.readFileSync(CONFIG_PATH, { encoding: 'utf-8' })); +Amplify.configure(config); + +export async function signUp(cfg: any): Promise<{ username: string; password: string }> { + const gen2Auth = (cfg as any)?.auth; + const userPoolId = cfg.aws_user_pools_id ?? gen2Auth?.user_pool_id; + const region = cfg.aws_cognito_region ?? gen2Auth?.aws_region; + + const uname = generateTestEmail(); + const pwd = generateTestPassword(); + + const cognitoClient = new CognitoIdentityProviderClient({ region }); + + await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: userPoolId, + Username: uname, + TemporaryPassword: pwd, + MessageAction: 'SUPPRESS', + UserAttributes: [ + { Name: 'email', Value: uname }, + { Name: 'email_verified', Value: 'true' }, + ], + })); + + await cognitoClient.send(new AdminSetUserPasswordCommand({ + UserPoolId: userPoolId, + Username: uname, + Password: pwd, + Permanent: true, + })); + + return { username: uname, password: pwd }; +} + +function generateTestPassword(): string { + return `Test${randomSuffix()}!Aa1`; +} + +function generateTestEmail(): string { + return `testuser-${randomSuffix()}@test.example.com`; +} + +function randomSuffix(): string { + return randomBytes(4).toString('hex'); +} diff --git a/amplify-migration-apps/imported-resources/tests/storage.test.ts b/amplify-migration-apps/imported-resources/tests/storage.test.ts new file mode 100644 index 00000000000..301752e2da9 --- /dev/null +++ b/amplify-migration-apps/imported-resources/tests/storage.test.ts @@ -0,0 +1,99 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { signIn, signOut } from 'aws-amplify/auth'; +import { uploadData, getUrl, downloadData, getProperties, remove } from 'aws-amplify/storage'; +import { signUp, config } from './signup'; + +const testImageBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAA3klEQVR42u3QMQEAAAgDILV/51nBzwci0JlYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqz8WgGPGAGBPQqrHAAAAABJRU5ErkJggg=='; + +function uploadTestImage(): Promise<{ path: string }> { + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `test-image-${Date.now()}.png`; + const s3Path = `public/images/${fileName}`; + return uploadData({ path: s3Path, data: imageBuffer, options: { contentType: 'image/png' } }).result; +} + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('guest', () => { + it('can read a public file', async () => { + const { path } = await uploadTestImage(); + await signOut(); + + const downloadResult = await downloadData({ path }).result; + const blob = await downloadResult.body.blob(); + const buffer = Buffer.from(await blob.arrayBuffer()); + + expect(buffer.length).toBeGreaterThan(0); + + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); + }); + + it('cannot upload a file', async () => { + await signOut(); + + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const s3Path = `public/images/unauthorized-${Date.now()}.png`; + + await expect( + uploadData({ path: s3Path, data: imageBuffer, options: { contentType: 'image/png' } }).result, + ).rejects.toBeDefined(); + + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); + }); +}); + +describe('auth', () => { + it('uploads a file', async () => { + const result = await uploadTestImage(); + + expect(result.path).toBeDefined(); + expect(typeof result.path).toBe('string'); + expect(result.path).toContain('public/images/'); + }); + + it('gets a signed URL', async () => { + const { path } = await uploadTestImage(); + const result = await getUrl({ path, options: { expiresIn: 3600 } }); + + expect(result.url).toBeDefined(); + expect(result.url.toString()).toContain('http'); + expect(result.expiresAt).toBeDefined(); + }); + + it('gets file properties', async () => { + const { path } = await uploadTestImage(); + const properties = await getProperties({ path }); + + expect(properties).toBeDefined(); + expect((properties as any).contentType).toBeDefined(); + expect((properties as any).size).toBeGreaterThan(0); + }); + + it('downloads a file', async () => { + const { path } = await uploadTestImage(); + const downloadResult = await downloadData({ path }).result; + const blob = await downloadResult.body.blob(); + const arrayBuffer = await blob.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + expect(buffer.length).toBeGreaterThan(0); + expect(blob.type).toBeDefined(); + }); + + it('deletes a file', async () => { + const { path } = await uploadTestImage(); + await remove({ path }); + + await expect(getProperties({ path })).rejects.toBeDefined(); + }); +}); diff --git a/amplify-migration-apps/media-vault/README.md b/amplify-migration-apps/media-vault/README.md index 91e1e4c319e..0b6e512a41d 100644 --- a/amplify-migration-apps/media-vault/README.md +++ b/amplify-migration-apps/media-vault/README.md @@ -394,92 +394,14 @@ git checkout -b gen2-main npx amplify gen2-migration generate ``` -**Edit in `./amplify/data/resource.ts`:** - -```diff -- branchName: "main" -+ branchName: "gen2-main" -``` - -**Edit in `./amplify/auth/resource.ts`:** - -```diff -+ const branchName = process.env.AWS_BRANCH ?? "sandbox"; -``` - -```diff -- callbackUrls: ["https://main.d1086iitvfyy6.amplifyapp.com/"], -+ callbackUrls: ["https://main.d1086iitvfyy6.amplifyapp.com/", `https://${branchName}.d1086iitvfyy6.amplifyapp.com/`], - -- logoutUrls: ["https://main.d1086iitvfyy6.amplifyapp.com/"], -+ logoutUrls: ["https://main.d1086iitvfyy6.amplifyapp.com/", `https://${branchName}.d1086iitvfyy6.amplifyapp.com/`], -``` - -**Edit in `./amplify/storage/thumbnailgen/resource.ts`:** - -```diff -- entry: "./index.js", -+ entry: "./index.js", -+ resourceGroupName: 'storage', -``` - -**Edit in `./amplify/function/addusertogroup/resource.ts`:** - -```diff -- entry: "./index.js", -+ entry: "./index.js", -+ resourceGroupName: 'auth', -``` - -**Edit in `./amplify/function/removeuserfromgroup/resource.ts`:** - -```diff -- entry: "./index.js", -+ entry: "./index.js", -+ resourceGroupName: 'auth', -``` - -**Edit in `./amplify/backend.ts`:** - -```diff -- (L88) const branchName = process.env.AWS_BRANCH ?? "sandbox"; -+ (L11) const branchName = process.env.AWS_BRANCH ?? "sandbox"; -``` - -```diff -- callbackUrls: ["https://main.d1086iitvfyy6.amplifyapp.com/"], -+ callbackUrls: ["https://main.d1086iitvfyy6.amplifyapp.com/", `https://${branchName}.d1086iitvfyy6.amplifyapp.com/`], - -- logoutUrls: ["https://main.d1086iitvfyy6.amplifyapp.com/"], -+ logoutUrls: ["https://main.d1086iitvfyy6.amplifyapp.com/", `https://${branchName}.d1086iitvfyy6.amplifyapp.com/`], -``` - -**Edit in `./amplify/function/addusertogroup/index.js`:** - -```diff -- exports.handler = async (event) => { -+ export async function handler(event) { -``` - -**Edit in `./amplify/function/removeuserfromgroup/index.js`:** - -```diff -- exports.handler = async (event) => { -+ export async function handler(event) { -``` - -**Edit in `./amplify/storage/thumbnailgen/index.js`:** - -```diff -- exports.handler = async (event) => { -+ export async function handler(event) { +```console +npm run post-generate ``` -**Edit in `./src/main.tsx`:** - -```diff -- import amplifyconfig from './amplifyconfiguration.json'; -+ import amplifyconfig from '../amplify_outputs.json'; +```console +rm -rf node_modules package-lock.json +npm install +npm install --package-lock-only ``` In the AWS Amplify console, navigate to _Hosting_ → _Secrets_ → _Manage secrets_ → _Add new_ and add the following secrets: diff --git a/amplify-migration-apps/media-vault/_snapshot.post.generate/package.json b/amplify-migration-apps/media-vault/_snapshot.post.generate/package.json index 28435d1bdf4..c0e794f4840 100644 --- a/amplify-migration-apps/media-vault/_snapshot.post.generate/package.json +++ b/amplify-migration-apps/media-vault/_snapshot.post.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/media-vault", + "name": "@amplify-migration-apps/media-vault-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/media-vault/_snapshot.pre.generate/package.json b/amplify-migration-apps/media-vault/_snapshot.pre.generate/package.json index 602a9062982..4c23d508f3d 100644 --- a/amplify-migration-apps/media-vault/_snapshot.pre.generate/package.json +++ b/amplify-migration-apps/media-vault/_snapshot.pre.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/media-vault", + "name": "@amplify-migration-apps/media-vault-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/media-vault/addusertogroup.js b/amplify-migration-apps/media-vault/backend/addusertogroup.js similarity index 100% rename from amplify-migration-apps/media-vault/addusertogroup.js rename to amplify-migration-apps/media-vault/backend/addusertogroup.js diff --git a/amplify-migration-apps/media-vault/addusertogroup.package.json b/amplify-migration-apps/media-vault/backend/addusertogroup.package.json similarity index 100% rename from amplify-migration-apps/media-vault/addusertogroup.package.json rename to amplify-migration-apps/media-vault/backend/addusertogroup.package.json diff --git a/amplify-migration-apps/media-vault/backend/configure.sh b/amplify-migration-apps/media-vault/backend/configure.sh new file mode 100755 index 00000000000..c7da2a36410 --- /dev/null +++ b/amplify-migration-apps/media-vault/backend/configure.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -euxo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cp -f ${script_dir}/schema.graphql ${script_dir}/../amplify/backend/api/mediavault/schema.graphql +cp -f ${script_dir}/thumbnailgen.js ${script_dir}/../amplify/backend/function/thumbnailgen/src/index.js +cp -f ${script_dir}/addusertogroup.js ${script_dir}/../amplify/backend/function/addusertogroup/src/index.js +cp -f ${script_dir}/addusertogroup.package.json ${script_dir}/../amplify/backend/function/addusertogroup/src/package.json +cp -f ${script_dir}/removeuserfromgroup.js ${script_dir}/../amplify/backend/function/removeuserfromgroup/src/index.js +cp -f ${script_dir}/removeuserfromgroup.package.json ${script_dir}/../amplify/backend/function/removeuserfromgroup/src/package.json diff --git a/amplify-migration-apps/media-vault/removeuserfromgroup.js b/amplify-migration-apps/media-vault/backend/removeuserfromgroup.js similarity index 100% rename from amplify-migration-apps/media-vault/removeuserfromgroup.js rename to amplify-migration-apps/media-vault/backend/removeuserfromgroup.js diff --git a/amplify-migration-apps/media-vault/removeuserfromgroup.package.json b/amplify-migration-apps/media-vault/backend/removeuserfromgroup.package.json similarity index 100% rename from amplify-migration-apps/media-vault/removeuserfromgroup.package.json rename to amplify-migration-apps/media-vault/backend/removeuserfromgroup.package.json diff --git a/amplify-migration-apps/media-vault/schema.graphql b/amplify-migration-apps/media-vault/backend/schema.graphql similarity index 100% rename from amplify-migration-apps/media-vault/schema.graphql rename to amplify-migration-apps/media-vault/backend/schema.graphql diff --git a/amplify-migration-apps/media-vault/thumbnailgen.js b/amplify-migration-apps/media-vault/backend/thumbnailgen.js similarity index 100% rename from amplify-migration-apps/media-vault/thumbnailgen.js rename to amplify-migration-apps/media-vault/backend/thumbnailgen.js diff --git a/amplify-migration-apps/media-vault/configure.sh b/amplify-migration-apps/media-vault/configure.sh deleted file mode 100755 index 202e39d6d8f..00000000000 --- a/amplify-migration-apps/media-vault/configure.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -cp -f schema.graphql ./amplify/backend/api/mediavault/schema.graphql -cp -f thumbnailgen.js ./amplify/backend/function/thumbnailgen/src/index.js -cp -f addusertogroup.js ./amplify/backend/function/addusertogroup/src/index.js -cp -f addusertogroup.package.json ./amplify/backend/function/addusertogroup/src/package.json -cp -f removeuserfromgroup.js ./amplify/backend/function/removeuserfromgroup/src/index.js -cp -f removeuserfromgroup.package.json ./amplify/backend/function/removeuserfromgroup/src/package.json diff --git a/amplify-migration-apps/media-vault/gen1-test-script.ts b/amplify-migration-apps/media-vault/gen1-test-script.ts deleted file mode 100644 index 8553b4bea60..00000000000 --- a/amplify-migration-apps/media-vault/gen1-test-script.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Gen1 Test Script for MediaVault App - * - * This script tests all functionality: - * 1. Authenticated GraphQL Queries (requires auth) - * 2. Authenticated GraphQL Mutations (requires auth) - * 3. Lambda Function Operations (Thumbnails, User Groups) - * - * Credentials are provisioned automatically via Cognito AdminCreateUser + AdminSetUserPassword. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import amplifyconfig from './src/amplifyconfiguration.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting MediaVault Gen1 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. Authenticated GraphQL Queries (Notes)'); - console.log(' 2. Authenticated GraphQL Mutations (Notes)'); - console.log(' 3. Lambda Function Operations (Thumbnails, User Groups)'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - // Sign in from this module so the auth tokens are available to api/storage - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { runQueryTests, runMutationTests, runLambdaFunctionTests } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Queries - await runQueryTests(); - - // Part 2: Mutations - await runMutationTests(); - - // Part 3: Lambda Functions - await runLambdaFunctionTests(); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/media-vault/gen2-test-script.ts b/amplify-migration-apps/media-vault/gen2-test-script.ts deleted file mode 100644 index 7c0d2aba015..00000000000 --- a/amplify-migration-apps/media-vault/gen2-test-script.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Gen2 Test Script for MediaVault App - * - * This script tests all functionality for Amplify Gen2: - * 1. Authenticated GraphQL Queries (Notes) - * 2. Authenticated GraphQL Mutations (Notes) - * 3. Lambda Function Operations (Thumbnails, User Groups) - * - * Credentials are provisioned automatically via Cognito AdminCreateUser. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import amplifyconfig from './amplify_outputs.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify with Gen2 outputs -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting MediaVault Gen2 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. Authenticated GraphQL Queries (Notes)'); - console.log(' 2. Authenticated GraphQL Mutations (Notes)'); - console.log(' 3. Lambda Function Operations (Thumbnails, User Groups)'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { runQueryTests, runMutationTests, runLambdaFunctionTests } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Queries - await runQueryTests(); - - // Part 2: Mutations - await runMutationTests(); - - // Part 3: Lambda Functions - await runLambdaFunctionTests(); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/media-vault/jest.config.js b/amplify-migration-apps/media-vault/jest.config.js new file mode 100644 index 00000000000..fb5a9b20fe0 --- /dev/null +++ b/amplify-migration-apps/media-vault/jest.config.js @@ -0,0 +1,23 @@ +/** @type {import('jest').Config} */ +export default { + testMatch: ['/tests/**/*.test.ts'], + modulePathIgnorePatterns: ['/_snapshot', '/amplify'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: { + target: 'ES2022', + module: 'CommonJS', + moduleResolution: 'node', + esModuleInterop: true, + allowJs: true, + noEmit: true, + strict: true, + skipLibCheck: true, + types: ['node', 'jest'], + }, + }], + }, + testTimeout: 30_000, + setupFilesAfterEnv: ['/tests/jest.setup.ts'], +}; diff --git a/amplify-migration-apps/media-vault/migration-config.json b/amplify-migration-apps/media-vault/migration-config.json deleted file mode 100644 index 660f06b29e8..00000000000 --- a/amplify-migration-apps/media-vault/migration-config.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "app": { - "name": "media-vault", - "description": "Personal media vault with social authentication, user groups, and thumbnail generation", - "framework": "react" - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["COGNITO_USER_POOLS", "API_KEY"] - }, - "auth": { - "signInMethods": ["email", "phone"], - "socialProviders": ["facebook", "google"], - "userPoolGroups": ["Admin", "Basic"] - }, - "storage": { - "buckets": [ - { - "name": "mediavault", - "access": ["auth", "guest"] - } - ] - }, - "function": { - "functions": [ - { - "name": "thumbnailgen", - "runtime": "nodejs", - "template": "hello-world" - }, - { - "name": "addusertogroup", - "runtime": "nodejs", - "template": "hello-world" - }, - { - "name": "removeuserfromgroup", - "runtime": "nodejs", - "template": "hello-world" - } - ] - }, - "hosting": { - "type": "amplify-console" - } - } -} diff --git a/amplify-migration-apps/media-vault/migration/config.json b/amplify-migration-apps/media-vault/migration/config.json new file mode 100644 index 00000000000..90954c2cf76 --- /dev/null +++ b/amplify-migration-apps/media-vault/migration/config.json @@ -0,0 +1,3 @@ +{ + "refactor": { "skip": true } +} diff --git a/amplify-migration-apps/media-vault/migration/post-generate.ts b/amplify-migration-apps/media-vault/migration/post-generate.ts new file mode 100644 index 00000000000..43acc54fffc --- /dev/null +++ b/amplify-migration-apps/media-vault/migration/post-generate.ts @@ -0,0 +1,159 @@ +#!/usr/bin/env npx ts-node +/** + * Post-generate script for media-vault app. + * + * Applies manual edits required after `amplify gen2-migration generate`: + * 1. Update branchName in amplify/data/resource.ts to the value of AWS_BRANCH + * env var, or the current git branch if AWS_BRANCH is not set + * 2. Convert addusertogroup function from CommonJS to ESM + * 3. Convert removeuserfromgroup function from CommonJS to ESM + * 4. Convert thumbnailgen function from CommonJS to ESM + * 5. Update frontend import from amplifyconfiguration.json to amplify_outputs.json + * 6. Add resourceGroupName to function resource.ts files + * 7. Monkey-patch auth resource so secret() uses local plaintext values + */ + +import { execSync } from 'child_process'; +import fs from 'fs/promises'; +import path from 'path'; + +function resolveTargetBranch(): string { + if (process.env.AWS_BRANCH) { + return process.env.AWS_BRANCH; + } + return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); +} + +async function updateBranchName(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); + + const content = await fs.readFile(resourcePath, 'utf-8'); + + const targetBranch = resolveTargetBranch(); + + const updated = content.replace( + /branchName:\s*['"]([^'"]+)['"]/, + `branchName: '${targetBranch}'`, + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function convertFunctionToESM(appPath: string, functionDir: string, functionName: string): Promise { + const handlerPath = path.join(appPath, 'amplify', functionDir, functionName, 'index.js'); + + const content = await fs.readFile(handlerPath, 'utf-8'); + + let updated = content.replace( + /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + updated = updated.replace( + /module\.exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + await fs.writeFile(handlerPath, updated, 'utf-8'); +} + +async function updateFrontendConfig(appPath: string): Promise { + const mainPath = path.join(appPath, 'src', 'main.tsx'); + + const content = await fs.readFile(mainPath, 'utf-8'); + + const updated = content.replace( + /from\s*["']\.\/amplifyconfiguration\.json["']/g, + "from '../amplify_outputs.json'", + ); + + await fs.writeFile(mainPath, updated, 'utf-8'); +} + +async function addResourceGroupName(appPath: string, functionPath: string, groupName: string): Promise { + const resourcePath = path.join(appPath, 'amplify', functionPath, 'resource.ts'); + const content = await fs.readFile(resourcePath, 'utf-8'); + + if (content.includes('resourceGroupName')) return; + + const updated = content.replace( + /(entry:\s*["'][^"']+["'],?)/, + `$1\n resourceGroupName: '${groupName}',`, + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +/** + * Replaces the imported `secret()` in auth/resource.ts with a local + * implementation backed by `SecretValue.unsafePlainText` so the app + * can be deployed without real secrets. + */ +async function monkeyPatchAuthSecret(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'auth', 'resource.ts'); + let content = await fs.readFile(resourcePath, 'utf-8'); + + // Remove `secret` from the @aws-amplify/backend import + content = content.replace( + /import\s*\{([^}]*)\bsecret\b([^}]*)\}\s*from\s*['"]@aws-amplify\/backend['"]/, + (_, before, after) => { + const remaining = [before, after] + .join('') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .join(', '); + return `import { ${remaining} } from '@aws-amplify/backend'`; + }, + ); + + // Add SecretValue import and local secret() definition after the last import + const localSecret = [ + '', + "import { SecretValue } from 'aws-cdk-lib';", + '', + 'const secret = (name: string) => ({', + ' resolve: () => SecretValue.unsafePlainText(`local-${name}`),', + ' resolvePath: () => ({', + ' branchSecretPath: `local/${name}`,', + ' sharedSecretPath: `local/shared/${name}`,', + ' }),', + '});', + ].join('\n'); + + // Insert after the last import statement + const importRegex = /^import\s.+;$/gm; + let lastImportEnd = 0; + let match: RegExpExecArray | null; + while ((match = importRegex.exec(content)) !== null) { + lastImportEnd = match.index + match[0].length; + } + content = + content.slice(0, lastImportEnd) + + localSecret + + content.slice(lastImportEnd); + + await fs.writeFile(resourcePath, content, 'utf-8'); +} + +export async function postGenerate(appPath: string): Promise { + await updateBranchName(appPath); + await convertFunctionToESM(appPath, 'function', 'addusertogroup'); + await convertFunctionToESM(appPath, 'function', 'removeuserfromgroup'); + await convertFunctionToESM(appPath, 'storage', 'thumbnailgen'); + await updateFrontendConfig(appPath); + await addResourceGroupName(appPath, 'storage/thumbnailgen', 'storage'); + await addResourceGroupName(appPath, 'function/addusertogroup', 'auth'); + await addResourceGroupName(appPath, 'function/removeuserfromgroup', 'auth'); + await monkeyPatchAuthSecret(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postGenerate(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/media-vault/migration/pre-push.ts b/amplify-migration-apps/media-vault/migration/pre-push.ts new file mode 100644 index 00000000000..e4a40dc61a2 --- /dev/null +++ b/amplify-migration-apps/media-vault/migration/pre-push.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env npx ts-node +/** + * Pre-push script for media-vault app. + * + * Writes dummy Facebook/Google OAuth credentials into the deployment + * secrets file (~/.aws/amplify/deployment-secrets.json) so that + * `amplify push` can configure the Cognito social identity providers. + * The credentials don't need to be real — Cognito accepts any string + * values during deployment. Real OAuth flows won't work, but the + * e2e tests use AdminCreateUser and don't exercise social login. + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +function readMeta(appPath: string): { rootStackId: string; envName: string } { + const tpiPath = path.join(appPath, 'amplify', 'team-provider-info.json'); + const tpi = JSON.parse(fs.readFileSync(tpiPath, 'utf-8')); + const envName = Object.keys(tpi)[0]; + const stackIdArn = tpi[envName].awscloudformation?.StackId as string; + const rootStackId = stackIdArn.split('/').pop()!; + return { rootStackId, envName }; +} + +// See: packages/amplify-cli-core/src/deploymentSecretsHelper.ts +function writeDeploymentSecrets(rootStackId: string, envName: string): void { + const secretsDir = path.join(os.homedir(), '.aws', 'amplify'); + const secretsPath = path.join(secretsDir, 'deployment-secrets.json'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let secrets: any = { appSecrets: [] }; + if (fs.existsSync(secretsPath)) { + secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf-8')); + } + + const creds = JSON.stringify([ + { ProviderName: 'Facebook', client_id: 'dummy-facebook-id', client_secret: 'dummy-facebook-secret' }, + { ProviderName: 'Google', client_id: 'dummy-google-id', client_secret: 'dummy-google-secret' }, + ]); + + secrets.appSecrets.push({ + rootStackId, + environments: { + [envName]: { + auth: { + mediavault1f08412d: { hostedUIProviderCreds: creds }, + }, + }, + }, + }); + + fs.mkdirSync(secretsDir, { recursive: true }); + fs.writeFileSync(secretsPath, JSON.stringify(secrets, null, 2), 'utf-8'); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + const { rootStackId, envName } = readMeta(appPath); + writeDeploymentSecrets(rootStackId, envName); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/media-vault/package.json b/amplify-migration-apps/media-vault/package.json index 602a9062982..81a79831463 100644 --- a/amplify-migration-apps/media-vault/package.json +++ b/amplify-migration-apps/media-vault/package.json @@ -11,9 +11,18 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "configure": "./configure.sh", + "configure": "./backend/configure.sh", "sanitize": "tsx ../sanitize.ts", - "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit" + "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit", + "test:gen1": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-src/amplifyconfiguration.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:gen2": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-amplify_outputs.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:e2e": "cd ../../packages/amplify-gen2-migration-e2e-system && npx tsx src/cli.ts --app media-vault --profile ${AWS_PROFILE:-default}", + "pre-push": "npx tsx migration/pre-push.ts", + "post-generate": "npx tsx migration/post-generate.ts", + "post-refactor": "true", + "post-sandbox": "true", + "pre-sandbox": "true", + "post-push": "true" }, "dependencies": { "@aws-amplify/storage": "^6.10.1", @@ -25,7 +34,9 @@ "sharp": "^0.34.5" }, "devDependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.936.0", "@eslint/js": "^9.39.1", + "@types/jest": "^29.5.14", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -34,6 +45,8 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jest": "^29.7.0", + "ts-jest": "^29.3.4", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" diff --git a/amplify-migration-apps/media-vault/test-utils.ts b/amplify-migration-apps/media-vault/test-utils.ts deleted file mode 100644 index 4f128e809da..00000000000 --- a/amplify-migration-apps/media-vault/test-utils.ts +++ /dev/null @@ -1,282 +0,0 @@ -// test-utils.ts -/** - * Shared test utilities for MediaVault Gen1 and Gen2 test scripts - */ - -import { Amplify } from 'aws-amplify'; -import { generateClient } from 'aws-amplify/api'; -import { fetchAuthSession } from 'aws-amplify/auth'; -import { uploadData } from 'aws-amplify/storage'; -import { readFileSync } from 'fs'; -import { getNote, listNotes, generateThumbnail, addUserToGroup, removeUserFromGroup } from './src/graphql/queries'; -import { createNote, updateNote, deleteNote } from './src/graphql/mutations'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import amplifyconfig from './src/amplifyconfiguration.json'; - -// Configure Amplify in this module to ensure api/storage singletons see the config -Amplify.configure(amplifyconfig); - -// Test data for Lambda functions -const TEST_IMAGE_PATH = './images/app.png'; // Uses existing app screenshot as test image -const TEST_GROUP = 'Admin'; // Group name for user management tests - -// ============================================================ -// Shared Test Functions Factory -// ============================================================ - -export function createTestFunctions() { - // ============================================================ - // Helper Functions - // ============================================================ - - async function getUserSub(): Promise { - try { - const session = await fetchAuthSession(); - return (session.tokens?.idToken?.payload.sub as string) || null; - } catch (error) { - console.log('❌ Error getting user sub:', error); - return null; - } - } - - // ============================================================ - // Query Test Functions - Notes - // ============================================================ - - async function testListNotes(): Promise { - console.log('\n📋 Testing listNotes...'); - const authClient = generateClient({ authMode: 'userPool' }); - const result = await authClient.graphql({ query: listNotes }); - const notes = (result as any).data.listNotes.items; - console.log(`✅ Found ${notes.length} notes:`); - notes.forEach((n: any) => console.log(` - [${n.id}] ${n.title}${n.content ? ` - ${n.content.substring(0, 50)}...` : ''}`)); - return notes.length > 0 ? notes[0].id : null; - } - - async function testGetNote(id: string): Promise { - console.log(`\n🔍 Testing getNote (id: ${id})...`); - const authClient = generateClient({ authMode: 'userPool' }); - const result = await authClient.graphql({ - query: getNote, - variables: { id }, - }); - const note = (result as any).data.getNote; - console.log('✅ Note:', { - id: note.id, - title: note.title, - content: note.content, - owner: note.owner, - createdAt: note.createdAt, - }); - } - - // ============================================================ - // Mutation Test Functions - Notes - // ============================================================ - - async function testCreateNote(): Promise { - console.log('\n🆕 Testing createNote...'); - const authClient = generateClient({ authMode: 'userPool' }); - const result = await authClient.graphql({ - query: createNote, - variables: { - input: { - title: `Test Note ${Date.now()}`, - content: 'This is a test note created by the test script. It contains sample content for testing purposes.', - }, - }, - }); - const note = (result as any).data.createNote; - console.log('✅ Created note:', { - id: note.id, - title: note.title, - content: note.content, - owner: note.owner, - }); - return note.id; - } - - async function testUpdateNote(noteId: string): Promise { - console.log(`\n✏️ Testing updateNote (id: ${noteId})...`); - const authClient = generateClient({ authMode: 'userPool' }); - const result = await authClient.graphql({ - query: updateNote, - variables: { - input: { - id: noteId, - title: 'Updated Test Note', - content: 'This note was updated by the test script with new content.', - }, - }, - }); - const note = (result as any).data.updateNote; - console.log('✅ Updated note:', { - id: note.id, - title: note.title, - content: note.content, - }); - } - - async function testDeleteNote(noteId: string): Promise { - console.log(`\n🗑️ Testing deleteNote (id: ${noteId})...`); - const authClient = generateClient({ authMode: 'userPool' }); - const result = await authClient.graphql({ - query: deleteNote, - variables: { input: { id: noteId } }, - }); - const deleted = (result as any).data.deleteNote; - console.log('✅ Deleted note:', deleted.title); - } - - // ============================================================ - // Lambda Function Test Functions - // ============================================================ - - async function testGenerateThumbnail(): Promise { - console.log('\n🖼️ Testing generateThumbnail Lambda function...'); - - try { - // Step 1: Upload a test image to S3 - console.log(' 📤 Uploading test image to S3...'); - const imageBuffer = readFileSync(TEST_IMAGE_PATH); - const key = `media/test-${Date.now()}.jpg`; - - const uploadResult = await uploadData({ - path: ({ identityId }: { identityId: string }) => `private/${identityId}/${key}`, - data: imageBuffer, - }).result; - - console.log(` ✅ Image uploaded: ${key}`); - - // Step 2: Get the full S3 path from upload result - const fullKey = uploadResult.path; - console.log(` 🔑 Full S3 key: ${fullKey}`); - - // Step 3: Call the thumbnail generation Lambda - console.log(' 🎨 Generating thumbnail...'); - const publicClient = generateClient({ authMode: 'apiKey' }); - const result = await publicClient.graphql({ - query: generateThumbnail, - variables: { mediaFileKey: fullKey }, - }); - const response = (result as any).data.generateThumbnail; - console.log('✅ Thumbnail generation response:', { - statusCode: response.statusCode, - message: response.message, - }); - } catch (error: any) { - if (error.code === 'ENOENT') { - console.log('⏭️ Skipping thumbnail test - test image file not found'); - console.log(` Please add an image file at: ${TEST_IMAGE_PATH}`); - console.log(' Or update TEST_IMAGE_PATH to point to an existing image'); - return; - } - throw error; - } - } - - async function testAddUserToGroup(): Promise { - console.log(`\n👥 Testing addUserToGroup Lambda function...`); - const userSub = await getUserSub(); - if (!userSub) { - throw new Error('Could not retrieve user sub'); - } - - // Use API Key auth mode (same as frontend publicClient) - const publicClient = generateClient({ authMode: 'apiKey' }); - const result = await publicClient.graphql({ - query: addUserToGroup, - variables: { - userSub: userSub, - group: TEST_GROUP, - }, - }); - const response = (result as any).data.addUserToGroup; - console.log('✅ Add user to group response:', { - statusCode: response.statusCode, - message: response.message, - }); - } - - async function testRemoveUserFromGroup(): Promise { - console.log(`\n👥 Testing removeUserFromGroup Lambda function...`); - const userSub = await getUserSub(); - if (!userSub) { - throw new Error('Could not retrieve user sub'); - } - - // Use API Key auth mode (same as frontend publicClient) - const publicClient = generateClient({ authMode: 'apiKey' }); - const result = await publicClient.graphql({ - query: removeUserFromGroup, - variables: { - userSub: userSub, - group: TEST_GROUP, - }, - }); - const response = (result as any).data.removeUserFromGroup; - console.log('✅ Remove user from group response:', { - statusCode: response.statusCode, - message: response.message, - }); - } - - return { - testListNotes, - testGetNote, - testCreateNote, - testUpdateNote, - testDeleteNote, - testGenerateThumbnail, - testAddUserToGroup, - testRemoveUserFromGroup, - }; -} - -// ============================================================ -// Shared Test Orchestration Functions -// ============================================================ - -export function createTestOrchestrator(testFunctions: ReturnType, runner: TestRunner) { - async function runQueryTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('📖 PART 1: Authenticated GraphQL Queries'); - console.log('='.repeat(50)); - - const noteId = await runner.runTest('listNotes', testFunctions.testListNotes); - if (noteId) await runner.runTest('getNote', () => testFunctions.testGetNote(noteId)); - } - - async function runMutationTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('✏️ PART 2: Authenticated GraphQL Mutations'); - console.log('='.repeat(50)); - - // Create, update, and delete note - const noteId = await runner.runTest('createNote', testFunctions.testCreateNote); - if (noteId) { - await runner.runTest('updateNote', () => testFunctions.testUpdateNote(noteId)); - await runner.runTest('deleteNote', () => testFunctions.testDeleteNote(noteId)); - } - } - - async function runLambdaFunctionTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('⚡ PART 3: Lambda Function Operations'); - console.log('='.repeat(50)); - - console.log('\n💡 Note: These tests require proper setup:'); - console.log(' - Thumbnail generation requires a valid S3 media file key'); - console.log(' - User group management requires Admin permissions\n'); - - await runner.runTest('generateThumbnail', testFunctions.testGenerateThumbnail); - await runner.runTest('addUserToGroup', testFunctions.testAddUserToGroup); - await runner.runTest('removeUserFromGroup', testFunctions.testRemoveUserFromGroup); - } - - return { - runQueryTests, - runMutationTests, - runLambdaFunctionTests, - }; -} diff --git a/amplify-migration-apps/media-vault/tests/api.test.ts b/amplify-migration-apps/media-vault/tests/api.test.ts new file mode 100644 index 00000000000..044ce8f5071 --- /dev/null +++ b/amplify-migration-apps/media-vault/tests/api.test.ts @@ -0,0 +1,184 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { generateClient } from 'aws-amplify/api'; +import { signIn, signOut, fetchAuthSession } from 'aws-amplify/auth'; +import { uploadData } from 'aws-amplify/storage'; +import { getNote, listNotes, generateThumbnail, addUserToGroup, removeUserFromGroup } from '../src/graphql/queries'; +import { createNote, updateNote, deleteNote } from '../src/graphql/mutations'; +import { signUp, config } from './signup'; + +const guest = () => generateClient({ authMode: 'apiKey' }); +const auth = () => generateClient({ authMode: 'userPool' }); + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('guest', () => { + it('generates a thumbnail for an uploaded image', async () => { + const testImageBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + + const uploadResult = await uploadData({ + path: ({ identityId }) => `private/${identityId}/media/test-${Date.now()}.png`, + data: imageBuffer, + }).result; + + const fullKey = uploadResult.path; + expect(typeof fullKey).toBe('string'); + expect(fullKey.length).toBeGreaterThan(0); + + const result = await guest().graphql({ + query: generateThumbnail, + variables: { mediaFileKey: fullKey }, + }); + const response = (result as any).data.generateThumbnail; + + expect(response).toBeDefined(); + expect(typeof response.statusCode).toBe('number'); + expect(typeof response.message).toBe('string'); + expect(response.message.length).toBeGreaterThan(0); + }); + + it('adds the current user to a group', async () => { + const session = await fetchAuthSession(); + const userSub = session.tokens?.idToken?.payload.sub as string; + expect(typeof userSub).toBe('string'); + + const result = await guest().graphql({ + query: addUserToGroup, + variables: { userSub, group: 'Admin' }, + }); + const response = (result as any).data.addUserToGroup; + + expect(response).toBeDefined(); + expect(typeof response.statusCode).toBe('number'); + expect(typeof response.message).toBe('string'); + expect(response.message.length).toBeGreaterThan(0); + }); + + it('removes the current user from a group', async () => { + const session = await fetchAuthSession(); + const userSub = session.tokens?.idToken?.payload.sub as string; + expect(typeof userSub).toBe('string'); + + const result = await guest().graphql({ + query: removeUserFromGroup, + variables: { userSub, group: 'Admin' }, + }); + const response = (result as any).data.removeUserFromGroup; + + expect(response).toBeDefined(); + expect(typeof response.statusCode).toBe('number'); + expect(typeof response.message).toBe('string'); + expect(response.message.length).toBeGreaterThan(0); + }); + + it('cannot create a note', async () => { + await expect( + guest().graphql({ + query: createNote, + variables: { input: { title: `Unauthorized ${Date.now()}`, content: 'Should fail' } }, + }), + ).rejects.toBeDefined(); + }); + + it('cannot list notes', async () => { + await expect( + guest().graphql({ query: listNotes }), + ).rejects.toBeDefined(); + }); +}); + +describe('auth', () => { + describe('Note', () => { + it('creates a note with correct fields', async () => { + const input = { + title: `Test Note ${Date.now()}`, + content: 'Created by jest', + }; + + const result = await auth().graphql({ query: createNote, variables: { input } }); + const note = (result as any).data.createNote; + + expect(typeof note.id).toBe('string'); + expect(note.id.length).toBeGreaterThan(0); + expect(note.title).toBe(input.title); + expect(note.content).toBe('Created by jest'); + expect(note.createdAt).toBeDefined(); + expect(note.updatedAt).toBeDefined(); + expect(note.owner).toBeDefined(); + }); + + it('reads a note by id', async () => { + const createResult = await auth().graphql({ + query: createNote, + variables: { input: { title: `Read Test ${Date.now()}`, content: 'For read test' } }, + }); + const created = (createResult as any).data.createNote; + + const getResult = await auth().graphql({ query: getNote, variables: { id: created.id } }); + const fetched = (getResult as any).data.getNote; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.title).toBe(created.title); + expect(fetched.content).toBe('For read test'); + }); + + it('updates a note and persists changes', async () => { + const createResult = await auth().graphql({ + query: createNote, + variables: { input: { title: `Update Test ${Date.now()}`, content: 'Original' } }, + }); + const created = (createResult as any).data.createNote; + + await auth().graphql({ + query: updateNote, + variables: { input: { id: created.id, title: 'Updated Title', content: 'Now updated' } }, + }); + + const getResult = await auth().graphql({ query: getNote, variables: { id: created.id } }); + const fetched = (getResult as any).data.getNote; + + expect(fetched.title).toBe('Updated Title'); + expect(fetched.content).toBe('Now updated'); + }); + + it('deletes a note', async () => { + const createResult = await auth().graphql({ + query: createNote, + variables: { input: { title: `Delete Test ${Date.now()}`, content: 'Delete me' } }, + }); + const created = (createResult as any).data.createNote; + + await auth().graphql({ query: deleteNote, variables: { input: { id: created.id } } }); + + const getResult = await auth().graphql({ query: getNote, variables: { id: created.id } }); + expect((getResult as any).data.getNote).toBeNull(); + }); + + it('lists notes including a newly created one', async () => { + const title = `List Test ${Date.now()}`; + const createResult = await auth().graphql({ + query: createNote, + variables: { input: { title, content: 'For list test' } }, + }); + const created = (createResult as any).data.createNote; + + const listResult = await auth().graphql({ query: listNotes }); + const items = (listResult as any).data.listNotes.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((n: any) => n.id === created.id); + expect(found).toBeDefined(); + expect(found.title).toBe(title); + }); + }); +}); diff --git a/amplify-migration-apps/media-vault/tests/auth.test.ts b/amplify-migration-apps/media-vault/tests/auth.test.ts new file mode 100644 index 00000000000..f50c95fb4ee --- /dev/null +++ b/amplify-migration-apps/media-vault/tests/auth.test.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { generateClient } from 'aws-amplify/api'; +import { signIn, signOut, fetchAuthSession } from 'aws-amplify/auth'; +import { getNote, addUserToGroup, removeUserFromGroup } from '../src/graphql/queries'; +import { createNote } from '../src/graphql/mutations'; +import { signUp, config } from './signup'; + +const guest = () => generateClient({ authMode: 'apiKey' }); +const auth = () => generateClient({ authMode: 'userPool' }); + +it('admin group member can read notes owned by other users', async () => { + // Ensure no leftover session from other test files + await signOut().catch(() => {}); + + // User A creates a note + const userA = await signUp(config); + await signIn({ username: userA.username, password: userA.password }); + + const createResult = await auth().graphql({ + query: createNote, + variables: { input: { title: `Admin Test ${Date.now()}`, content: 'Created by user A' } }, + }); + const noteId = (createResult as any).data.createNote.id; + + // Create user B and sign in + const userB = await signUp(config); + await signOut(); + await signIn({ username: userB.username, password: userB.password }); + + // User B (not admin) cannot see user A's note + await expect( + auth().graphql({ query: getNote, variables: { id: noteId } }), + ).rejects.toBeDefined(); + + // Add user B to Admin group via public API + const session = await fetchAuthSession(); + const userSub = session.tokens?.idToken?.payload.sub as string; + await guest().graphql({ query: addUserToGroup, variables: { userSub, group: 'Admin' } }); + + // Re-sign-in to refresh tokens with Admin group claim + await signOut(); + await signIn({ username: userB.username, password: userB.password }); + + // User B (now admin) can see user A's note + const afterAdmin = await auth().graphql({ query: getNote, variables: { id: noteId } }); + const note = (afterAdmin as any).data.getNote; + expect(note).not.toBeNull(); + expect(note.id).toBe(noteId); + expect(note.content).toBe('Created by user A'); + + // Cleanup + await guest().graphql({ query: removeUserFromGroup, variables: { userSub, group: 'Admin' } }); + await signOut(); +}, 60_000); diff --git a/amplify-migration-apps/media-vault/tests/jest.setup.ts b/amplify-migration-apps/media-vault/tests/jest.setup.ts new file mode 100644 index 00000000000..bb0b4613b66 --- /dev/null +++ b/amplify-migration-apps/media-vault/tests/jest.setup.ts @@ -0,0 +1 @@ +jest.retryTimes(3); diff --git a/amplify-migration-apps/media-vault/tests/signup.ts b/amplify-migration-apps/media-vault/tests/signup.ts new file mode 100644 index 00000000000..1c84498437d --- /dev/null +++ b/amplify-migration-apps/media-vault/tests/signup.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Amplify } from 'aws-amplify'; +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, + AdminSetUserPasswordCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import * as fs from 'fs'; +import { randomBytes } from 'crypto'; + +import { webcrypto } from 'crypto'; +if (typeof globalThis.crypto === 'undefined') { + (globalThis as any).crypto = webcrypto; +} + +const CONFIG_PATH = process.env.APP_CONFIG_PATH; +if (!CONFIG_PATH) { + throw new Error('APP_CONFIG_PATH environment variable is required'); +} + +export const config = JSON.parse(fs.readFileSync(CONFIG_PATH, { encoding: 'utf-8' })); +Amplify.configure(config); + +export async function signUp(cfg: any): Promise<{ username: string; password: string }> { + const gen2Auth = (cfg as any)?.auth; + const userPoolId = cfg.aws_user_pools_id ?? gen2Auth?.user_pool_id; + const region = cfg.aws_cognito_region ?? gen2Auth?.aws_region; + + const uname = generateTestEmail(); + const pwd = generateTestPassword(); + + const cognitoClient = new CognitoIdentityProviderClient({ region }); + + await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: userPoolId, + Username: uname, + TemporaryPassword: pwd, + MessageAction: 'SUPPRESS', + UserAttributes: [ + { Name: 'email', Value: uname }, + { Name: 'email_verified', Value: 'true' }, + ], + })); + + await cognitoClient.send(new AdminSetUserPasswordCommand({ + UserPoolId: userPoolId, + Username: uname, + Password: pwd, + Permanent: true, + })); + + return { username: uname, password: pwd }; +} + +function generateTestPassword(): string { + return `Test${randomSuffix()}!Aa1`; +} + +function generateTestEmail(): string { + return `testuser-${randomSuffix()}@test.example.com`; +} + +function randomSuffix(): string { + return randomBytes(4).toString('hex'); +} diff --git a/amplify-migration-apps/media-vault/tests/storage.test.ts b/amplify-migration-apps/media-vault/tests/storage.test.ts new file mode 100644 index 00000000000..8c2abbcb21c --- /dev/null +++ b/amplify-migration-apps/media-vault/tests/storage.test.ts @@ -0,0 +1,169 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fetchAuthSession, signIn, signOut } from 'aws-amplify/auth'; +import { uploadData, getUrl, downloadData, getProperties, remove } from 'aws-amplify/storage'; +import { signUp, config } from './signup'; + +const testImageBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAA3klEQVR42u3QMQEAAAgDILV/51nBzwci0JlYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqz8WgGPGAGBPQqrHAAAAABJRU5ErkJggg=='; + +function uploadTestImage(): Promise<{ path: string }> { + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `test-image-${Date.now()}.png`; + const s3Path = `public/images/${fileName}`; + return uploadData({ path: s3Path, data: imageBuffer, options: { contentType: 'image/png' } }).result; +} + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('guest', () => { + it('can read a public file', async () => { + const { path } = await uploadTestImage(); + await signOut(); + + const downloadResult = await downloadData({ path }).result; + const blob = await downloadResult.body.blob(); + const buffer = Buffer.from(await blob.arrayBuffer()); + + expect(buffer.length).toBeGreaterThan(0); + + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); + }); + + it('cannot upload a file', async () => { + await signOut(); + + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const s3Path = `public/images/unauthorized-${Date.now()}.png`; + + await expect( + uploadData({ path: s3Path, data: imageBuffer, options: { contentType: 'image/png' } }).result, + ).rejects.toBeDefined(); + + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); + }); +}); + +describe('auth', () => { + it('uploads a file', async () => { + const result = await uploadTestImage(); + + expect(result.path).toBeDefined(); + expect(typeof result.path).toBe('string'); + expect(result.path).toContain('public/images/'); + }); + + it('gets a signed URL', async () => { + const { path } = await uploadTestImage(); + const result = await getUrl({ path, options: { expiresIn: 3600 } }); + + expect(result.url).toBeDefined(); + expect(result.url.toString()).toContain('http'); + expect(result.expiresAt).toBeDefined(); + }); + + it('gets file properties', async () => { + const { path } = await uploadTestImage(); + const properties = await getProperties({ path }); + + expect(properties).toBeDefined(); + expect((properties as any).contentType).toBeDefined(); + expect((properties as any).size).toBeGreaterThan(0); + }); + + it('downloads a file', async () => { + const { path } = await uploadTestImage(); + const downloadResult = await downloadData({ path }).result; + const blob = await downloadResult.body.blob(); + const arrayBuffer = await blob.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + expect(buffer.length).toBeGreaterThan(0); + expect(blob.type).toBeDefined(); + }); + + it('deletes a file', async () => { + const { path } = await uploadTestImage(); + await remove({ path }); + + await expect(getProperties({ path })).rejects.toBeDefined(); + }); +}); + +describe('private storage', () => { + it('uploads and downloads a private file', async () => { + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `media/test-private-${Date.now()}.png`; + + const uploadResult = await uploadData({ + path: ({ identityId }) => `private/${identityId}/${fileName}`, + data: imageBuffer, + options: { contentType: 'image/png' }, + }).result; + + expect(uploadResult.path).toBeDefined(); + expect(uploadResult.path).toContain('private/'); + expect(uploadResult.path).toContain(fileName); + + const downloadResult = await downloadData({ path: uploadResult.path }).result; + const blob = await downloadResult.body.blob(); + const buffer = Buffer.from(await blob.arrayBuffer()); + + expect(buffer.length).toBe(imageBuffer.length); + }); + + it('gets a signed URL for a private file', async () => { + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `media/test-private-url-${Date.now()}.png`; + + const uploadResult = await uploadData({ + path: ({ identityId }) => `private/${identityId}/${fileName}`, + data: imageBuffer, + }).result; + + const result = await getUrl({ path: uploadResult.path, options: { expiresIn: 3600 } }); + + expect(result.url).toBeDefined(); + expect(result.url.toString()).toContain('http'); + }); + + it('deletes a private file', async () => { + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `media/test-private-delete-${Date.now()}.png`; + + const uploadResult = await uploadData({ + path: ({ identityId }) => `private/${identityId}/${fileName}`, + data: imageBuffer, + }).result; + + await remove({ path: uploadResult.path }); + + await expect(getProperties({ path: uploadResult.path })).rejects.toBeDefined(); + }); + + it('guest cannot read a private file', async () => { + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `media/test-private-guest-${Date.now()}.png`; + + const uploadResult = await uploadData({ + path: ({ identityId }) => `private/${identityId}/${fileName}`, + data: imageBuffer, + }).result; + + const privatePath = uploadResult.path; + await signOut(); + + await expect(downloadData({ path: privatePath }).result).rejects.toBeDefined(); + + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); + }); +}); diff --git a/amplify-migration-apps/mood-board/README.md b/amplify-migration-apps/mood-board/README.md index bfe762889ac..6334331ab04 100644 --- a/amplify-migration-apps/mood-board/README.md +++ b/amplify-migration-apps/mood-board/README.md @@ -266,79 +266,14 @@ npx amplify gen2-migration lock git checkout -b gen2-main npx amplify gen2-migration generate ``` -**Edit in `./amplify/data/resource.ts`:** - -```diff -- branchName: "main" -+ branchName: "gen2-main" -``` - -**Edit in `./amplify/function/moodboardRandomEmojiGenerator/index.js`** - -```diff -- exports.handler = async (event) => { -+ export async function handler(event) { -``` - -**Edit in `./amplify/function/moodboardKinesisReader/index.js`** - -```diff -- exports.handler = async (event) => { -+ export async function handler(event) { -``` - -**Edit in `./amplify/function/moodboardKinesisReader/resource.ts`** - -```diff -- environment: { ANALYTICS_MOODBOARDKINESIS_KINESISSTREAMARN: "arn:aws:kinesis:us-east-1:014148916658:stream/moodboardKinesis-main", ENV: `${branchName}`, REGION: "us-east-1" }, -``` - -**Edit in `./src/main.tsx`:** - -```diff -- import amplifyconfig from './amplifyconfiguration.json'; -+ import amplifyconfig from '../amplify_outputs.json'; -``` - -**Edit in `./src/components/SurpriseMeButton.tsx`:** - -```diff -- const STREAM_NAME = 'moodboardKinesis-main'; -+ const STREAM_NAME = 'moodboardKinesis-gen2-main'; -``` - -**Edit in `./amplify/backend.ts`:** - -```diff -- import { Duration } from "aws-cdk-lib"; -+ import { Duration, aws_iam } from "aws-cdk-lib"; -``` - -```diff -+ backend.moodboardKinesisReader.resources.lambda.addToRolePolicy( -+ new aws_iam.PolicyStatement({ -+ actions: [ -+ "kinesis:ListShards", -+ "kinesis:ListShards", -+ "kinesis:ListStreams", -+ "kinesis:ListStreamConsumers", -+ "kinesis:DescribeStream", -+ "kinesis:DescribeStreamSummary", -+ "kinesis:DescribeStreamConsumer", -+ "kinesis:GetRecords", -+ "kinesis:GetShardIterator", -+ "kinesis:SubscribeToShard", -+ "kinesis:DescribeLimits", -+ "kinesis:ListTagsForStream", -+ "kinesis:SubscribeToShard" -+ ], -+ resources: [analytics.kinesisStreamArn] -+ }) -+ ); +```console +npm run post-generate ``` -```diff -+ backend.moodboardKinesisReader.addEnvironment("ANALYTICS_MOODBOARDKINESIS_KINESISSTREAMARN",analytics.kinesisStreamArn) +```console +rm -rf node_modules package-lock.json +npm install +npm install --package-lock-only ``` ```console diff --git a/amplify-migration-apps/mood-board/_snapshot.post.generate/amplify/data/resource.ts b/amplify-migration-apps/mood-board/_snapshot.post.generate/amplify/data/resource.ts index 46a04f3bc1b..4ed996d4efc 100644 --- a/amplify-migration-apps/mood-board/_snapshot.post.generate/amplify/data/resource.ts +++ b/amplify-migration-apps/mood-board/_snapshot.post.generate/amplify/data/resource.ts @@ -18,7 +18,7 @@ type Board @model @auth(rules: [{ allow: public }]) { type Query { getRandomEmoji: String @function(name: "moodboardGetRandomEmoji-${branchName}") @auth(rules: [{ allow: private }]) - getKinesisEvents: AWSJSON @function(name: "moodboardKinesisreader-${branchName}") @auth(rules: [{ allow: private }]) + getKinesisEvents: AWSJSON @function(name: "moodboardKinesisReader-${branchName}") @auth(rules: [{ allow: private }]) } `; diff --git a/amplify-migration-apps/mood-board/_snapshot.post.generate/package.json b/amplify-migration-apps/mood-board/_snapshot.post.generate/package.json index 3980e691fc5..5a612d1e146 100644 --- a/amplify-migration-apps/mood-board/_snapshot.post.generate/package.json +++ b/amplify-migration-apps/mood-board/_snapshot.post.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/mood-board-app", + "name": "@amplify-migration-apps/mood-board-app-snapshot", "private": true, "version": "0.0.1", "type": "module", diff --git a/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/#current-cloud-backend/api/moodboard/build/stacks/FunctionDirectiveStack.json b/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/#current-cloud-backend/api/moodboard/build/stacks/FunctionDirectiveStack.json index 29023a13b05..c984a8bbed7 100644 --- a/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/#current-cloud-backend/api/moodboard/build/stacks/FunctionDirectiveStack.json +++ b/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/#current-cloud-backend/api/moodboard/build/stacks/FunctionDirectiveStack.json @@ -261,7 +261,7 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-${env}", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-${env}", { "env": { "Ref": "referencetotransformerrootstackenv10C5A902Ref" @@ -270,7 +270,7 @@ ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader" } ] }, @@ -283,7 +283,7 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-${env}", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-${env}", { "env": { "Ref": "referencetotransformerrootstackenv10C5A902Ref" @@ -292,7 +292,7 @@ ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader" } ] }, @@ -325,7 +325,7 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-${env}", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-${env}", { "env": { "Ref": "referencetotransformerrootstackenv10C5A902Ref" @@ -334,7 +334,7 @@ ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader" } ] } @@ -492,4 +492,4 @@ "Type": "String" } } -} \ No newline at end of file +} diff --git a/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/#current-cloud-backend/api/moodboard/schema.graphql b/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/#current-cloud-backend/api/moodboard/schema.graphql index f8ffc7743d6..879005218c1 100644 --- a/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/#current-cloud-backend/api/moodboard/schema.graphql +++ b/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/#current-cloud-backend/api/moodboard/schema.graphql @@ -15,5 +15,5 @@ type Board @model @auth(rules: [{ allow: public }]) { type Query { getRandomEmoji: String @function(name: "moodboardGetRandomEmoji-${env}") @auth(rules: [{ allow: private }]) - getKinesisEvents: AWSJSON @function(name: "moodboardKinesisreader-${env}") @auth(rules: [{ allow: private }]) + getKinesisEvents: AWSJSON @function(name: "moodboardKinesisReader-${env}") @auth(rules: [{ allow: private }]) } diff --git a/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/build/stacks/FunctionDirectiveStack.json b/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/build/stacks/FunctionDirectiveStack.json index 29023a13b05..c984a8bbed7 100644 --- a/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/build/stacks/FunctionDirectiveStack.json +++ b/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/build/stacks/FunctionDirectiveStack.json @@ -261,7 +261,7 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-${env}", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-${env}", { "env": { "Ref": "referencetotransformerrootstackenv10C5A902Ref" @@ -270,7 +270,7 @@ ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader" } ] }, @@ -283,7 +283,7 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-${env}", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-${env}", { "env": { "Ref": "referencetotransformerrootstackenv10C5A902Ref" @@ -292,7 +292,7 @@ ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader" } ] }, @@ -325,7 +325,7 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-${env}", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-${env}", { "env": { "Ref": "referencetotransformerrootstackenv10C5A902Ref" @@ -334,7 +334,7 @@ ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader" } ] } @@ -492,4 +492,4 @@ "Type": "String" } } -} \ No newline at end of file +} diff --git a/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/schema.graphql b/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/schema.graphql index f8ffc7743d6..879005218c1 100644 --- a/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/schema.graphql +++ b/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/schema.graphql @@ -15,5 +15,5 @@ type Board @model @auth(rules: [{ allow: public }]) { type Query { getRandomEmoji: String @function(name: "moodboardGetRandomEmoji-${env}") @auth(rules: [{ allow: private }]) - getKinesisEvents: AWSJSON @function(name: "moodboardKinesisreader-${env}") @auth(rules: [{ allow: private }]) + getKinesisEvents: AWSJSON @function(name: "moodboardKinesisReader-${env}") @auth(rules: [{ allow: private }]) } diff --git a/amplify-migration-apps/mood-board/_snapshot.pre.generate/package.json b/amplify-migration-apps/mood-board/_snapshot.pre.generate/package.json index f16cb006a2d..c94ff868ee4 100644 --- a/amplify-migration-apps/mood-board/_snapshot.pre.generate/package.json +++ b/amplify-migration-apps/mood-board/_snapshot.pre.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/mood-board-app", + "name": "@amplify-migration-apps/mood-board-app-snapshot", "private": true, "version": "0.0.1", "type": "module", diff --git a/amplify-migration-apps/mood-board/_snapshot.pre.refactor/amplify-moodboard-gen2main-branch-f7e4-amplifyDataFunctionDirectiveStackNestedSta-SQ5IB8G0WA1E.template.json b/amplify-migration-apps/mood-board/_snapshot.pre.refactor/amplify-moodboard-gen2main-branch-f7e4-amplifyDataFunctionDirectiveStackNestedSta-SQ5IB8G0WA1E.template.json index 5a40e25a7a1..0a4b5504427 100644 --- a/amplify-migration-apps/mood-board/_snapshot.pre.refactor/amplify-moodboard-gen2main-branch-f7e4-amplifyDataFunctionDirectiveStackNestedSta-SQ5IB8G0WA1E.template.json +++ b/amplify-migration-apps/mood-board/_snapshot.pre.refactor/amplify-moodboard-gen2main-branch-f7e4-amplifyDataFunctionDirectiveStackNestedSta-SQ5IB8G0WA1E.template.json @@ -561,12 +561,12 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-gen2-main", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-gen2-main", {} ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-gen2-main" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-gen2-main" } ] }, @@ -579,12 +579,12 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-gen2-main", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-gen2-main", {} ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-gen2-main" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-gen2-main" } ] }, @@ -620,12 +620,12 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-gen2-main", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-gen2-main", {} ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-gen2-main" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-gen2-main" } ] } @@ -800,4 +800,4 @@ "Type": "String" } } -} \ No newline at end of file +} diff --git a/amplify-migration-apps/mood-board/_snapshot.pre.refactor/amplify-moodboard-main-1959a-apimoodboard-18X5SE6E7DN8B-FunctionDirectiveStack-156N8PP509YUZ.template.json b/amplify-migration-apps/mood-board/_snapshot.pre.refactor/amplify-moodboard-main-1959a-apimoodboard-18X5SE6E7DN8B-FunctionDirectiveStack-156N8PP509YUZ.template.json index 29023a13b05..c984a8bbed7 100644 --- a/amplify-migration-apps/mood-board/_snapshot.pre.refactor/amplify-moodboard-main-1959a-apimoodboard-18X5SE6E7DN8B-FunctionDirectiveStack-156N8PP509YUZ.template.json +++ b/amplify-migration-apps/mood-board/_snapshot.pre.refactor/amplify-moodboard-main-1959a-apimoodboard-18X5SE6E7DN8B-FunctionDirectiveStack-156N8PP509YUZ.template.json @@ -261,7 +261,7 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-${env}", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-${env}", { "env": { "Ref": "referencetotransformerrootstackenv10C5A902Ref" @@ -270,7 +270,7 @@ ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader" } ] }, @@ -283,7 +283,7 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-${env}", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-${env}", { "env": { "Ref": "referencetotransformerrootstackenv10C5A902Ref" @@ -292,7 +292,7 @@ ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader" } ] }, @@ -325,7 +325,7 @@ "HasEnvironmentParameter", { "Fn::Sub": [ - "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader-${env}", + "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader-${env}", { "env": { "Ref": "referencetotransformerrootstackenv10C5A902Ref" @@ -334,7 +334,7 @@ ] }, { - "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisreader" + "Fn::Sub": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:moodboardKinesisReader" } ] } @@ -492,4 +492,4 @@ "Type": "String" } } -} \ No newline at end of file +} diff --git a/amplify-migration-apps/mood-board/backend/configure.sh b/amplify-migration-apps/mood-board/backend/configure.sh new file mode 100755 index 00000000000..5b31b6d8ad6 --- /dev/null +++ b/amplify-migration-apps/mood-board/backend/configure.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -euxo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cp -f ${script_dir}/schema.graphql ${script_dir}/../amplify/backend/api/moodboard/schema.graphql +cp -f ${script_dir}/getRandomEmoji.js ${script_dir}/../amplify/backend/function/moodboardGetRandomEmoji/src/index.js +cp -f ${script_dir}/kinesisReader.js ${script_dir}/../amplify/backend/function/moodboardKinesisReader/src/index.js diff --git a/amplify-migration-apps/mood-board/getRandomEmoji.js b/amplify-migration-apps/mood-board/backend/getRandomEmoji.js similarity index 100% rename from amplify-migration-apps/mood-board/getRandomEmoji.js rename to amplify-migration-apps/mood-board/backend/getRandomEmoji.js diff --git a/amplify-migration-apps/mood-board/kinesisReader.js b/amplify-migration-apps/mood-board/backend/kinesisReader.js similarity index 100% rename from amplify-migration-apps/mood-board/kinesisReader.js rename to amplify-migration-apps/mood-board/backend/kinesisReader.js diff --git a/amplify-migration-apps/mood-board/schema.graphql b/amplify-migration-apps/mood-board/backend/schema.graphql similarity index 100% rename from amplify-migration-apps/mood-board/schema.graphql rename to amplify-migration-apps/mood-board/backend/schema.graphql diff --git a/amplify-migration-apps/mood-board/configure-functions.sh b/amplify-migration-apps/mood-board/configure-functions.sh deleted file mode 100755 index 7b30842aa5f..00000000000 --- a/amplify-migration-apps/mood-board/configure-functions.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -euxo pipefail -cp -f getRandomEmoji.js ./amplify/backend/function/moodboardGetRandomEmoji/src/index.js -cp -f kinesisReader.js ./amplify/backend/function/moodboardKinesisReader/src/index.js diff --git a/amplify-migration-apps/mood-board/configure-schema.sh b/amplify-migration-apps/mood-board/configure-schema.sh deleted file mode 100755 index c085c35c502..00000000000 --- a/amplify-migration-apps/mood-board/configure-schema.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -set -euxo pipefail -cp -f schema.graphql ./amplify/backend/api/moodboard/schema.graphql diff --git a/amplify-migration-apps/mood-board/configure.sh b/amplify-migration-apps/mood-board/configure.sh deleted file mode 100755 index 2fc99c178b1..00000000000 --- a/amplify-migration-apps/mood-board/configure.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -set -euxo pipefail -./configure-functions.sh diff --git a/amplify-migration-apps/mood-board/gen1-test-script.ts b/amplify-migration-apps/mood-board/gen1-test-script.ts deleted file mode 100644 index e66e0f89f3e..00000000000 --- a/amplify-migration-apps/mood-board/gen1-test-script.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Gen1 Test Script for Mood Board App - * - * This script tests all functionality for Amplify Gen1: - * 1. GraphQL Queries (Boards, MoodItems) - * 2. Board CRUD Operations - * 3. MoodItem CRUD Operations - * 4. Lambda Function Operations (getRandomEmoji, getKinesisEvents) - * 5. S3 Storage Operations - * 6. Cleanup (Delete Test Data) - * - * Credentials are provisioned automatically via Cognito AdminCreateUser + AdminSetUserPassword. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import amplifyconfig from './src/amplifyconfiguration.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Mood Board Gen1 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. GraphQL Queries (Boards, MoodItems)'); - console.log(' 2. Board CRUD Operations'); - console.log(' 3. MoodItem CRUD Operations'); - console.log(' 4. Lambda Function Operations (Emoji, Kinesis)'); - console.log(' 5. S3 Storage Operations'); - console.log(' 6. Cleanup (Delete Test Data)'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - // Sign in from this module so the auth tokens are available to api/storage - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { - runQueryTests, - runBoardMutationTests, - runMoodItemMutationTests, - runLambdaTests, - runStorageTests, - runCleanupTests, - } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Query tests - await runQueryTests(); - - // Part 2: Board mutations - const boardId = await runBoardMutationTests(); - - // Part 3: MoodItem mutations (requires board) - let moodItemId: string | null = null; - if (boardId) { - moodItemId = await runMoodItemMutationTests(boardId); - } - - // Part 4: Lambda functions (requires auth) - await runLambdaTests(); - - // Part 5: S3 Storage - await runStorageTests(); - - // Part 6: Cleanup - await runCleanupTests(boardId, moodItemId); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/mood-board/gen2-test-script.ts b/amplify-migration-apps/mood-board/gen2-test-script.ts deleted file mode 100644 index 4f5fb998084..00000000000 --- a/amplify-migration-apps/mood-board/gen2-test-script.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Gen2 Test Script for Mood Board App - * - * This script tests all functionality for Amplify Gen2: - * 1. GraphQL Queries (Boards, MoodItems) - * 2. Board CRUD Operations - * 3. MoodItem CRUD Operations - * 4. Lambda Function Operations (getRandomEmoji, getKinesisEvents) - * 5. S3 Storage Operations - * 6. Cleanup (Delete Test Data) - * - * Credentials are provisioned automatically via Cognito AdminCreateUser. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import amplifyconfig from './amplify_outputs.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify with Gen2 outputs -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Mood Board Gen2 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. GraphQL Queries (Boards, MoodItems)'); - console.log(' 2. Board CRUD Operations'); - console.log(' 3. MoodItem CRUD Operations'); - console.log(' 4. Lambda Function Operations (Emoji, Kinesis)'); - console.log(' 5. S3 Storage Operations'); - console.log(' 6. Cleanup (Delete Test Data)'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { - runQueryTests, - runBoardMutationTests, - runMoodItemMutationTests, - runLambdaTests, - runStorageTests, - runCleanupTests, - } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Query tests - await runQueryTests(); - - // Part 2: Board mutations - const boardId = await runBoardMutationTests(); - - // Part 3: MoodItem mutations (requires board) - let moodItemId: string | null = null; - if (boardId) { - moodItemId = await runMoodItemMutationTests(boardId); - } - - // Part 4: Lambda functions (requires auth) - await runLambdaTests(); - - // Part 5: S3 Storage - await runStorageTests(); - - // Part 6: Cleanup - await runCleanupTests(boardId, moodItemId); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/mood-board/jest.config.js b/amplify-migration-apps/mood-board/jest.config.js new file mode 100644 index 00000000000..fb5a9b20fe0 --- /dev/null +++ b/amplify-migration-apps/mood-board/jest.config.js @@ -0,0 +1,23 @@ +/** @type {import('jest').Config} */ +export default { + testMatch: ['/tests/**/*.test.ts'], + modulePathIgnorePatterns: ['/_snapshot', '/amplify'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: { + target: 'ES2022', + module: 'CommonJS', + moduleResolution: 'node', + esModuleInterop: true, + allowJs: true, + noEmit: true, + strict: true, + skipLibCheck: true, + types: ['node', 'jest'], + }, + }], + }, + testTimeout: 30_000, + setupFilesAfterEnv: ['/tests/jest.setup.ts'], +}; diff --git a/amplify-migration-apps/mood-board/migration-config.json b/amplify-migration-apps/mood-board/migration-config.json deleted file mode 100644 index 4d249462be3..00000000000 --- a/amplify-migration-apps/mood-board/migration-config.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "app": { - "name": "mood-board", - "description": "Visual board app with emoji generator, Kinesis analytics, and S3 image storage", - "framework": "react" - }, - "categories": { - "auth": { - "signInMethods": ["email"], - "socialProviders": [] - }, - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["API_KEY", "COGNITO_USER_POOLS"], - "customQueries": ["getRandomEmoji", "getKinesisEvents"] - }, - "storage": { - "buckets": [ - { - "name": "moodboardStorage", - "access": ["auth", "guest"] - } - ] - }, - "function": { - "functions": [ - { - "name": "moodboardGetRandomEmoji", - "runtime": "nodejs", - "template": "hello-world" - }, - { - "name": "moodboardKinesisReader", - "runtime": "nodejs", - "template": "hello-world" - } - ] - }, - "hosting": { - "type": "amplify-console" - }, - "analytics": { - "type": "kinesis", - "name": "moodboardKinesis", - "shards": 1 - } - } -} diff --git a/amplify-migration-apps/mood-board/migration/post-generate.ts b/amplify-migration-apps/mood-board/migration/post-generate.ts new file mode 100644 index 00000000000..fc376355aff --- /dev/null +++ b/amplify-migration-apps/mood-board/migration/post-generate.ts @@ -0,0 +1,166 @@ +#!/usr/bin/env npx ts-node +/** + * Post-generate script for mood-board app. + * + * Applies manual edits required after `amplify gen2-migration generate`: + * 1. Update branchName in amplify/data/resource.ts to the value of AWS_BRANCH + * env var, or the current git branch if AWS_BRANCH is not set + * 2. Convert moodboardGetRandomEmoji function from CommonJS to ESM + * 3. Convert moodboardKinesisReader function from CommonJS to ESM + * 4. Remove hardcoded Kinesis ARN from moodboardKinesisReader environment + * 5. Update frontend import from amplifyconfiguration.json to amplify_outputs.json + * 6. Update SurpriseMeButton stream name to use gen2 prefix + * 7. Add Kinesis IAM policy and environment variable to backend.ts + */ + +import { execSync } from 'child_process'; +import fs from 'fs/promises'; +import path from 'path'; + +function resolveTargetBranch(): string { + if (process.env.AWS_BRANCH) { + return process.env.AWS_BRANCH; + } + return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); +} + +async function updateBranchName(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); + + const content = await fs.readFile(resourcePath, 'utf-8'); + + const targetBranch = resolveTargetBranch(); + + const updated = content.replace( + /branchName:\s*['"]([^'"]+)['"]/, + `branchName: '${targetBranch}'`, + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function convertFunctionToESM(appPath: string, functionName: string): Promise { + const handlerPath = path.join(appPath, 'amplify', 'function', functionName, 'index.js'); + + const content = await fs.readFile(handlerPath, 'utf-8'); + + let updated = content.replace( + /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + updated = updated.replace( + /module\.exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + await fs.writeFile(handlerPath, updated, 'utf-8'); +} + +async function removeHardcodedKinesisArn(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'function', 'moodboardKinesisReader', 'resource.ts'); + + const content = await fs.readFile(resourcePath, 'utf-8'); + + const updated = content.replace( + /,?\s*ANALYTICS_MOODBOARDKINESIS_KINESISSTREAMARN:\s*["'][^"']+["']/g, + '', + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function updateFrontendConfig(appPath: string): Promise { + const mainPath = path.join(appPath, 'src', 'main.tsx'); + + const content = await fs.readFile(mainPath, 'utf-8'); + + const updated = content.replace( + /from\s*["']\.\/amplifyconfiguration\.json["']/g, + "from '../amplify_outputs.json'", + ); + + await fs.writeFile(mainPath, updated, 'utf-8'); +} + +async function updateSurpriseMeStreamName(appPath: string, envName: string): Promise { + const constantsPath = path.join(appPath, 'src', 'constants.ts'); + + const content = await fs.readFile(constantsPath, 'utf-8'); + + const gen2StreamName = `moodboardKinesis-gen2-${envName}`; + const updated = content.replace( + /export const KINESIS_STREAM_NAME\s*=\s*['"][^'"]+['"]/, + `export const KINESIS_STREAM_NAME = '${gen2StreamName}'`, + ); + + await fs.writeFile(constantsPath, updated, 'utf-8'); +} + +async function addKinesisConfigToBackend(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + + let content = await fs.readFile(backendPath, 'utf-8'); + + if (content.includes('kinesis:GetRecords')) return; + + if (!content.includes('aws_iam')) { + content = content.replace( + /import\s*\{\s*Duration\s*\}\s*from\s*["']aws-cdk-lib["']/, + "import { Duration, aws_iam } from 'aws-cdk-lib'", + ); + } + + const kinesisConfig = ` +// Grant Kinesis read permissions to moodboardKinesisReader +backend.moodboardKinesisReader.resources.lambda.addToRolePolicy( + new aws_iam.PolicyStatement({ + actions: [ + 'kinesis:ListShards', + 'kinesis:ListStreams', + 'kinesis:ListStreamConsumers', + 'kinesis:DescribeStream', + 'kinesis:DescribeStreamSummary', + 'kinesis:DescribeStreamConsumer', + 'kinesis:GetRecords', + 'kinesis:GetShardIterator', + 'kinesis:SubscribeToShard', + 'kinesis:DescribeLimits', + 'kinesis:ListTagsForStream', + ], + resources: [analytics.kinesisStreamArn], + }), +); + +// Add Kinesis stream ARN environment variable +backend.moodboardKinesisReader.addEnvironment('ANALYTICS_MOODBOARDKINESIS_KINESISSTREAMARN', analytics.kinesisStreamArn); +`; + + if (content.includes('export {')) { + content = content.replace(/(\nexport\s*\{)/, `${kinesisConfig}\n$1`); + } else { + content = content.trimEnd() + '\n' + kinesisConfig; + } + + await fs.writeFile(backendPath, content, 'utf-8'); +} + +export async function postGenerate(appPath: string, envName = 'sandbox'): Promise { + await updateBranchName(appPath); + await convertFunctionToESM(appPath, 'moodboardGetRandomEmoji'); + await convertFunctionToESM(appPath, 'moodboardKinesisReader'); + await removeHardcodedKinesisArn(appPath); + await updateFrontendConfig(appPath); + await updateSurpriseMeStreamName(appPath, envName); + await addKinesisConfigToBackend(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd(), envName] = process.argv.slice(2); + await postGenerate(appPath, envName); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/mood-board/migration/post-refactor.ts b/amplify-migration-apps/mood-board/migration/post-refactor.ts new file mode 100644 index 00000000000..a48b94b01f8 --- /dev/null +++ b/amplify-migration-apps/mood-board/migration/post-refactor.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env npx ts-node +/** + * Post-refactor script for mood-board app. + * + * Applies manual edits required after `amplify gen2-migration refactor`: + * 1. Uncomment the Kinesis stream name override in analytics/resource.ts + * 2. Uncomment the S3 bucket name override in backend.ts + * 3. Update SurpriseMeButton stream name back to original (without gen2 prefix) + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function uncommentKinesisStreamName(appPath: string, envName: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'analytics', 'resource.ts'); + + const content = await fs.readFile(resourcePath, 'utf-8'); + + const streamName = `moodboardKinesis-${envName}`; + + let updated = content.replace( + /\/\/\s*\(analytics\.node\.findChild\(['"]KinesisStream['"]\)\s*as\s*CfnStream\)\.name\s*=\s*["'][^"']*["']/, + `(analytics.node.findChild('KinesisStream') as CfnStream).name = "${streamName}"`, + ); + + if (updated === content) { + if (content.includes('export const analytics')) { + updated = content.replace( + /(export const analytics[^;]+;)/, + `$1\n(analytics.node.findChild('KinesisStream') as CfnStream).name = "${streamName}";`, + ); + } + } + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function uncommentS3BucketName(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + + const content = await fs.readFile(backendPath, 'utf-8'); + + const updated = content.replace(/\/\/\s*(s3Bucket\.bucketName\s*=\s*['"][^'"]+['"];?)/, '$1'); + + await fs.writeFile(backendPath, updated, 'utf-8'); +} + +async function updateSurpriseMeStreamName(appPath: string, envName: string): Promise { + const constantsPath = path.join(appPath, 'src', 'constants.ts'); + + const content = await fs.readFile(constantsPath, 'utf-8'); + + const originalStreamName = `moodboardKinesis-${envName}`; + const updated = content.replace( + /export const KINESIS_STREAM_NAME\s*=\s*['"][^'"]+['"]/, + `export const KINESIS_STREAM_NAME = '${originalStreamName}'`, + ); + + await fs.writeFile(constantsPath, updated, 'utf-8'); +} + +export async function postRefactor(appPath: string, envName = 'main'): Promise { + await uncommentKinesisStreamName(appPath, envName); + await uncommentS3BucketName(appPath); + await updateSurpriseMeStreamName(appPath, envName); +} + +async function main(): Promise { + const [appPath = process.cwd(), envName] = process.argv.slice(2); + await postRefactor(appPath, envName); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/mood-board/package.json b/amplify-migration-apps/mood-board/package.json index f16cb006a2d..ec9db895445 100644 --- a/amplify-migration-apps/mood-board/package.json +++ b/amplify-migration-apps/mood-board/package.json @@ -10,9 +10,18 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "configure": "./configure.sh", + "configure": "./backend/configure.sh", "sanitize": "tsx ../sanitize.ts", - "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit" + "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit", + "test:gen1": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-src/amplifyconfiguration.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:gen2": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-amplify_outputs.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:e2e": "cd ../../packages/amplify-gen2-migration-e2e-system && npx tsx src/cli.ts --app mood-board --profile ${AWS_PROFILE:-default}", + "pre-push": "true", + "post-generate": "npx tsx migration/post-generate.ts", + "post-refactor": "npx tsx migration/post-refactor.ts", + "post-sandbox": "true", + "pre-sandbox": "true", + "post-push": "true" }, "dependencies": { "@aws-amplify/ui-react": "^6.10.1", @@ -21,9 +30,13 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.936.0", + "@types/jest": "^29.5.14", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "jest": "^29.7.0", + "ts-jest": "^29.3.4", "typescript": "^5.5.3", "vite": "^5.4.2" } diff --git a/amplify-migration-apps/mood-board/post-generate.ts b/amplify-migration-apps/mood-board/post-generate.ts deleted file mode 100644 index b92b08aee77..00000000000 --- a/amplify-migration-apps/mood-board/post-generate.ts +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/env npx ts-node -/** - * Post-generate script for mood-board app. - * - * Applies manual edits required after `amplify gen2-migration generate`: - * 1. Update branchName in amplify/data/resource.ts to "sandbox" - * 2. Convert moodboardGetRandomEmoji function from CommonJS to ESM - * 3. Convert moodboardKinesisReader function from CommonJS to ESM - * 4. Remove hardcoded Kinesis ARN from moodboardKinesisReader environment - * 5. Update frontend import from amplifyconfiguration.json to amplify_outputs.json - * 6. Update SurpriseMeButton stream name to use gen2 prefix - * 7. Add Kinesis IAM policy and environment variable to backend.ts - * 8. Fix missing awsRegion in GraphQL API userPoolConfig - */ - -import fs from 'fs/promises'; -import path from 'path'; - -interface PostGenerateOptions { - appPath: string; - envName?: string; -} - -async function updateBranchName(appPath: string): Promise { - const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); - - console.log(`Updating branchName in ${resourcePath}...`); - - let content: string; - try { - content = await fs.readFile(resourcePath, 'utf-8'); - } catch { - console.log(' resource.ts not found, skipping'); - return; - } - - // For sandbox deployments, Gen2 hardcodes the branch lookup to 'sandbox' - const targetBranch = 'sandbox'; - - const branchNameMatch = content.match(/branchName:\s*['"]([^'"]+)['"]/); - if (branchNameMatch) { - console.log(` Found branchName: '${branchNameMatch[1]}'`); - } else { - console.log(' WARNING: No branchName property found'); - return; - } - - const updated = content.replace(/branchName:\s*['"]([^'"]+)['"]/, `branchName: '${targetBranch}'`); - - if (updated === content) { - console.log(' No branchName found to update, skipping'); - return; - } - - await fs.writeFile(resourcePath, updated, 'utf-8'); - console.log(` Updated branchName to "${targetBranch}"`); -} - -async function convertFunctionToESM(appPath: string, functionName: string): Promise { - // Gen2 migration puts functions in amplify/function/ (singular) - const handlerPath = path.join(appPath, 'amplify', 'function', functionName, 'index.js'); - - console.log(`Converting ${functionName} to ESM in ${handlerPath}...`); - - let content: string; - try { - content = await fs.readFile(handlerPath, 'utf-8'); - } catch { - console.log(' index.js not found, skipping'); - return; - } - - // Convert exports.handler = async (event) => { to export async function handler(event) { - let updated = content.replace(/exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, 'export async function handler($1) {'); - - // Also handle module.exports pattern - updated = updated.replace(/module\.exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, 'export async function handler($1) {'); - - if (updated === content) { - console.log(' No CommonJS exports found, skipping'); - return; - } - - await fs.writeFile(handlerPath, updated, 'utf-8'); - console.log(' Converted to ESM syntax'); -} - -async function removeHardcodedKinesisArn(appPath: string): Promise { - const resourcePath = path.join(appPath, 'amplify', 'function', 'moodboardKinesisReader', 'resource.ts'); - - console.log(`Removing hardcoded Kinesis ARN from ${resourcePath}...`); - - let content: string; - try { - content = await fs.readFile(resourcePath, 'utf-8'); - } catch { - console.log(' resource.ts not found, skipping'); - return; - } - - // Remove the hardcoded ANALYTICS_MOODBOARDKINESIS_KINESISSTREAMARN from environment - // The line looks like: ANALYTICS_MOODBOARDKINESIS_KINESISSTREAMARN: "arn:aws:kinesis:..." - const updated = content.replace( - /,?\s*ANALYTICS_MOODBOARDKINESIS_KINESISSTREAMARN:\s*["'][^"']+["']/g, - '', - ); - - if (updated === content) { - console.log(' No hardcoded Kinesis ARN found, skipping'); - return; - } - - await fs.writeFile(resourcePath, updated, 'utf-8'); - console.log(' Removed hardcoded Kinesis ARN'); -} - -async function updateFrontendConfig(appPath: string): Promise { - const mainPath = path.join(appPath, 'src', 'main.tsx'); - - console.log(`Updating frontend config import in ${mainPath}...`); - - let content: string; - try { - content = await fs.readFile(mainPath, 'utf-8'); - } catch { - console.log(' main.tsx not found, skipping'); - return; - } - - // Change: import amplifyconfig from './amplifyconfiguration.json'; - // To: import amplifyconfig from '../amplify_outputs.json'; - const updated = content.replace( - /from\s*["']\.\/amplifyconfiguration\.json["']/g, - "from '../amplify_outputs.json'", - ); - - if (updated === content) { - console.log(' No amplifyconfiguration.json import found, skipping'); - return; - } - - await fs.writeFile(mainPath, updated, 'utf-8'); - console.log(' Updated import to amplify_outputs.json'); -} - -async function updateSurpriseMeStreamName(appPath: string, envName: string): Promise { - const componentPath = path.join(appPath, 'src', 'components', 'SurpriseMeButton.tsx'); - - console.log(`Updating stream name in ${componentPath}...`); - - let content: string; - try { - content = await fs.readFile(componentPath, 'utf-8'); - } catch { - console.log(' SurpriseMeButton.tsx not found, skipping'); - return; - } - - // During generate phase, update to gen2 prefix since Gen2 creates a new stream - const gen2StreamName = `moodboardKinesis-gen2-${envName}`; - const updated = content.replace(/const STREAM_NAME\s*=\s*['"][^'"]+['"]/, `const STREAM_NAME = '${gen2StreamName}'`); - - if (updated === content) { - console.log(' No STREAM_NAME found to update, skipping'); - return; - } - - await fs.writeFile(componentPath, updated, 'utf-8'); - console.log(` Updated STREAM_NAME to "${gen2StreamName}"`); -} - -async function addKinesisConfigToBackend(appPath: string): Promise { - const backendPath = path.join(appPath, 'amplify', 'backend.ts'); - - console.log(`Adding Kinesis IAM policy and environment variable to ${backendPath}...`); - - let content: string; - try { - content = await fs.readFile(backendPath, 'utf-8'); - } catch { - console.log(' backend.ts not found, skipping'); - return; - } - - // Check if already added - if (content.includes('kinesis:GetRecords')) { - console.log(' Kinesis IAM policy already present, skipping'); - return; - } - - // Add aws_iam import if not present - if (!content.includes('aws_iam')) { - content = content.replace( - /import\s*\{\s*Duration\s*\}\s*from\s*["']aws-cdk-lib["']/, - "import { Duration, aws_iam } from 'aws-cdk-lib'", - ); - } - - // Add the Kinesis IAM policy and environment variable after the backend definition - // Look for the last line that configures backend resources - const kinesisConfig = ` -// Grant Kinesis read permissions to moodboardKinesisReader -backend.moodboardKinesisReader.resources.lambda.addToRolePolicy( - new aws_iam.PolicyStatement({ - actions: [ - 'kinesis:ListShards', - 'kinesis:ListStreams', - 'kinesis:ListStreamConsumers', - 'kinesis:DescribeStream', - 'kinesis:DescribeStreamSummary', - 'kinesis:DescribeStreamConsumer', - 'kinesis:GetRecords', - 'kinesis:GetShardIterator', - 'kinesis:SubscribeToShard', - 'kinesis:DescribeLimits', - 'kinesis:ListTagsForStream', - ], - resources: [analytics.kinesisStreamArn], - }), -); - -// Add Kinesis stream ARN environment variable -backend.moodboardKinesisReader.addEnvironment('ANALYTICS_MOODBOARDKINESIS_KINESISSTREAMARN', analytics.kinesisStreamArn); -`; - - // Find a good insertion point - after the last backend configuration - // Look for the export statement or end of file - if (content.includes('export {')) { - content = content.replace(/(\nexport\s*\{)/, `${kinesisConfig}\n$1`); - } else { - // Append to end of file - content = content.trimEnd() + '\n' + kinesisConfig; - } - - await fs.writeFile(backendPath, content, 'utf-8'); - console.log(' Added Kinesis IAM policy and environment variable'); -} - -async function fixUserPoolRegionInGraphqlApi(appPath: string): Promise { - const backendPath = path.join(appPath, 'amplify', 'backend.ts'); - - console.log(`Fixing user pool region in GraphQL API config in ${backendPath}...`); - - let content: string; - try { - content = await fs.readFile(backendPath, 'utf-8'); - } catch { - console.log(' backend.ts not found, skipping'); - return; - } - - // The generated code sets additionalAuthenticationProviders with userPoolConfig - // but is missing the awsRegion property. We need to add it. - // Pattern: userPoolConfig: { userPoolId: backend.auth.resources.userPool.userPoolId, } - const updated = content.replace( - /userPoolConfig:\s*\{\s*userPoolId:\s*backend\.auth\.resources\.userPool\.userPoolId,?\s*\}/g, - `userPoolConfig: { - userPoolId: backend.auth.resources.userPool.userPoolId, - awsRegion: backend.auth.stack.region, - }`, - ); - - if (updated === content) { - console.log(' No userPoolConfig found to fix, skipping'); - return; - } - - await fs.writeFile(backendPath, updated, 'utf-8'); - console.log(' Added awsRegion to userPoolConfig'); -} - -export async function postGenerate(options: PostGenerateOptions): Promise { - const { appPath, envName = 'sandbox' } = options; - - console.log(`Running post-generate for mood-board at ${appPath}`); - console.log(`Using envName: ${envName}`); - console.log(''); - - await updateBranchName(appPath); - await convertFunctionToESM(appPath, 'moodboardGetRandomEmoji'); - await convertFunctionToESM(appPath, 'moodboardKinesisReader'); - await removeHardcodedKinesisArn(appPath); - await updateFrontendConfig(appPath); - await updateSurpriseMeStreamName(appPath, envName); - await addKinesisConfigToBackend(appPath); - await fixUserPoolRegionInGraphqlApi(appPath); - - console.log(''); - console.log('Post-generate completed'); -} - -// CLI entry point -const isMainModule = import.meta.url === `file://${process.argv[1]}`; -if (isMainModule) { - const appPath = process.argv[2] || process.cwd(); - const envName = process.argv[3] || 'sandbox'; - - postGenerate({ appPath, envName }).catch((error) => { - console.error('Post-generate failed:', error); - process.exit(1); - }); -} diff --git a/amplify-migration-apps/mood-board/post-refactor.ts b/amplify-migration-apps/mood-board/post-refactor.ts deleted file mode 100644 index f0051b943bc..00000000000 --- a/amplify-migration-apps/mood-board/post-refactor.ts +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env npx ts-node -/** - * Post-refactor script for mood-board app. - * - * Applies manual edits required after `amplify gen2-migration refactor`: - * 1. Uncomment the Kinesis stream name override in analytics/resource.ts - * 2. Uncomment the S3 bucket name override in backend.ts - * 3. Update SurpriseMeButton stream name back to original (without gen2 prefix) - */ - -import fs from 'fs/promises'; -import path from 'path'; - -interface PostRefactorOptions { - appPath: string; - envName?: string; -} - -async function uncommentKinesisStreamName(appPath: string, envName: string): Promise { - const resourcePath = path.join(appPath, 'amplify', 'analytics', 'resource.ts'); - - console.log(`Uncommenting Kinesis stream name in ${resourcePath}...`); - - let content: string; - try { - content = await fs.readFile(resourcePath, 'utf-8'); - } catch { - console.log(' analytics/resource.ts not found, skipping'); - return; - } - - // The generated code has a commented line like: - // //(analytics.node.findChild('KinesisStream') as CfnStream).name = "..." - // We need to uncomment it and set the correct stream name - const streamName = `moodboardKinesis-${envName}`; - - // First try to find and uncomment the existing line - let updated = content.replace( - /\/\/\s*\(analytics\.node\.findChild\(['"]KinesisStream['"]\)\s*as\s*CfnStream\)\.name\s*=\s*["'][^"']*["']/, - `(analytics.node.findChild('KinesisStream') as CfnStream).name = "${streamName}"`, - ); - - if (updated === content) { - // If no commented line found, try to add it after the analytics definition - console.log(' No commented Kinesis stream name found, trying to add it'); - - // Look for the analytics export and add the name override after it - if (content.includes('export const analytics')) { - updated = content.replace( - /(export const analytics[^;]+;)/, - `$1\n(analytics.node.findChild('KinesisStream') as CfnStream).name = "${streamName}";`, - ); - } - } - - if (updated === content) { - console.log(' Could not find place to add Kinesis stream name, skipping'); - return; - } - - await fs.writeFile(resourcePath, updated, 'utf-8'); - console.log(` Set Kinesis stream name to "${streamName}"`); -} - -async function uncommentS3BucketName(appPath: string): Promise { - const backendPath = path.join(appPath, 'amplify', 'backend.ts'); - - console.log(`Uncommenting S3 bucket name in ${backendPath}...`); - - let content: string; - try { - content = await fs.readFile(backendPath, 'utf-8'); - } catch { - console.log(' backend.ts not found, skipping'); - return; - } - - // The generated code has a commented line like: - // // s3Bucket.bucketName = '...'; - // We need to uncomment it - const updated = content.replace(/\/\/\s*(s3Bucket\.bucketName\s*=\s*['"][^'"]+['"];?)/, '$1'); - - if (updated === content) { - console.log(' No commented S3 bucket name found, skipping'); - return; - } - - await fs.writeFile(backendPath, updated, 'utf-8'); - console.log(' Uncommented S3 bucket name'); -} - -async function updateSurpriseMeStreamName(appPath: string, envName: string): Promise { - const componentPath = path.join(appPath, 'src', 'components', 'SurpriseMeButton.tsx'); - - console.log(`Updating stream name in ${componentPath}...`); - - let content: string; - try { - content = await fs.readFile(componentPath, 'utf-8'); - } catch { - console.log(' SurpriseMeButton.tsx not found, skipping'); - return; - } - - // After refactor, the stream name should be the original (without gen2 prefix) - // because the Kinesis stream has been moved from Gen1 to Gen2 - const originalStreamName = `moodboardKinesis-${envName}`; - const updated = content.replace(/const STREAM_NAME\s*=\s*['"][^'"]+['"]/, `const STREAM_NAME = '${originalStreamName}'`); - - if (updated === content) { - console.log(' No STREAM_NAME found to update, skipping'); - return; - } - - await fs.writeFile(componentPath, updated, 'utf-8'); - console.log(` Updated STREAM_NAME to "${originalStreamName}"`); -} - -export async function postRefactor(options: PostRefactorOptions): Promise { - const { appPath, envName = 'main' } = options; - - console.log(`Running post-refactor for mood-board at ${appPath}`); - console.log(`Using envName: ${envName}`); - console.log(''); - - await uncommentKinesisStreamName(appPath, envName); - await uncommentS3BucketName(appPath); - await updateSurpriseMeStreamName(appPath, envName); - - console.log(''); - console.log('Post-refactor completed'); -} - -// CLI entry point -const isMainModule = import.meta.url === `file://${process.argv[1]}`; -if (isMainModule) { - const appPath = process.argv[2] || process.cwd(); - const envName = process.argv[3] || 'main'; - - postRefactor({ appPath, envName }).catch((error) => { - console.error('Post-refactor failed:', error); - process.exit(1); - }); -} diff --git a/amplify-migration-apps/mood-board/src/components/SurpriseMeButton.tsx b/amplify-migration-apps/mood-board/src/components/SurpriseMeButton.tsx index e9e7a94cc3a..a98bbbb2a1f 100644 --- a/amplify-migration-apps/mood-board/src/components/SurpriseMeButton.tsx +++ b/amplify-migration-apps/mood-board/src/components/SurpriseMeButton.tsx @@ -2,8 +2,7 @@ import { useState } from 'react'; import { generateClient } from 'aws-amplify/api'; import { record } from 'aws-amplify/analytics/kinesis'; import { getRandomEmoji, getKinesisEvents } from '../graphql/queries'; - -const STREAM_NAME = 'moodboardKinesis-main'; +import { KINESIS_STREAM_NAME } from '../constants'; type KinesisEvent = { data: string | null; @@ -38,7 +37,7 @@ export default function SurpriseMeButton() { record({ data: eventData, partitionKey: 'surpriseMe', - streamName: STREAM_NAME, + streamName: KINESIS_STREAM_NAME, }); } catch (analyticsErr) { console.warn('Analytics error:', analyticsErr); diff --git a/amplify-migration-apps/mood-board/src/constants.ts b/amplify-migration-apps/mood-board/src/constants.ts new file mode 100644 index 00000000000..0e2278407b2 --- /dev/null +++ b/amplify-migration-apps/mood-board/src/constants.ts @@ -0,0 +1 @@ +export const KINESIS_STREAM_NAME = 'moodboardKinesis-main'; diff --git a/amplify-migration-apps/mood-board/test-utils.ts b/amplify-migration-apps/mood-board/test-utils.ts deleted file mode 100644 index 670aabbb960..00000000000 --- a/amplify-migration-apps/mood-board/test-utils.ts +++ /dev/null @@ -1,413 +0,0 @@ -// test-utils.ts -/** - * Shared test utilities for Mood Board Gen1 and Gen2 test scripts - */ - -import { generateClient } from 'aws-amplify/api'; -import { uploadData, getUrl, remove } from 'aws-amplify/storage'; -import { - getMoodItem, - listMoodItems, - getBoard, - listBoards, - moodItemsByBoardID, - getRandomEmoji, - getKinesisEvents, -} from './src/graphql/queries'; -import { - createMoodItem, - updateMoodItem, - deleteMoodItem, - createBoard, - updateBoard, - deleteBoard, -} from './src/graphql/mutations'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; - -// NOTE: Amplify.configure() must be called by the importing script (gen1 or gen2) -// before any test functions are invoked. The gen1 script configures with -// amplifyconfiguration.json, the gen2 script with amplify_outputs.json. - -// ============================================================ -// Shared Test Functions Factory -// ============================================================ - -export function createTestFunctions() { - const publicClient = generateClient({ authMode: 'apiKey' }); - - // ============================================================ - // Query Test Functions - // ============================================================ - - async function testListBoards(): Promise { - console.log('\n📋 Testing listBoards...'); - const result = await publicClient.graphql({ query: listBoards }); - const boards = (result as any).data.listBoards.items; - console.log(`✅ Found ${boards.length} boards:`); - boards.forEach((b: any) => console.log(` - [${b.id}] ${b.name}`)); - return boards.length > 0 ? boards[0].id : null; - } - - async function testGetBoard(id: string): Promise { - console.log(`\n🔍 Testing getBoard (id: ${id.substring(0, 8)}...)...`); - const result = await publicClient.graphql({ - query: getBoard, - variables: { id }, - }); - const board = (result as any).data.getBoard; - console.log('✅ Board:', { - id: board.id.substring(0, 8) + '...', - name: board.name, - }); - } - - async function testListMoodItems(): Promise { - console.log('\n🎨 Testing listMoodItems...'); - const result = await publicClient.graphql({ query: listMoodItems }); - const items = (result as any).data.listMoodItems.items; - console.log(`✅ Found ${items.length} mood items:`); - items.slice(0, 5).forEach((m: any) => { - console.log(` - [${m.id.substring(0, 8)}...] ${m.title} (board: ${m.boardID.substring(0, 8)}...)`); - }); - if (items.length > 5) console.log(` ... and ${items.length - 5} more`); - return items.length > 0 ? items[0].id : null; - } - - async function testGetMoodItem(id: string): Promise { - console.log(`\n🔍 Testing getMoodItem (id: ${id.substring(0, 8)}...)...`); - const result = await publicClient.graphql({ - query: getMoodItem, - variables: { id }, - }); - const item = (result as any).data.getMoodItem; - console.log('✅ MoodItem:', { - id: item.id.substring(0, 8) + '...', - title: item.title, - image: item.image.substring(0, 50) + (item.image.length > 50 ? '...' : ''), - boardID: item.boardID.substring(0, 8) + '...', - }); - } - - async function testMoodItemsByBoardID(boardID: string): Promise { - console.log(`\n📋 Testing moodItemsByBoardID (boardID: ${boardID.substring(0, 8)}...)...`); - const result = await publicClient.graphql({ - query: moodItemsByBoardID, - variables: { boardID }, - }); - const items = (result as any).data.moodItemsByBoardID.items; - console.log(`✅ Found ${items.length} mood items for board:`); - items.slice(0, 5).forEach((m: any) => { - console.log(` - [${m.id.substring(0, 8)}...] ${m.title}`); - }); - if (items.length > 5) console.log(` ... and ${items.length - 5} more`); - } - - // ============================================================ - // Lambda Function Test Functions - // ============================================================ - - async function testGetRandomEmoji(): Promise { - console.log('\n🎲 Testing getRandomEmoji (Lambda)...'); - const authClient = generateClient({ authMode: 'userPool' }); - const result = await authClient.graphql({ query: getRandomEmoji }); - const emoji = (result as any).data.getRandomEmoji; - console.log(`✅ Got random emoji: ${emoji}`); - } - - async function testGetKinesisEvents(): Promise { - console.log('\n📊 Testing getKinesisEvents (Lambda)...'); - const authClient = generateClient({ authMode: 'userPool' }); - const result = await authClient.graphql({ query: getKinesisEvents }); - const raw = (result as any).data.getKinesisEvents; - let parsed: any; - try { - parsed = JSON.parse(raw); - } catch { - parsed = raw; - } - if (parsed?.events) { - console.log(`✅ Got ${parsed.events.length} Kinesis events`); - parsed.events.slice(0, 3).forEach((e: any) => { - console.log(` - seq: ${e.sequenceNumber?.substring(0, 12)}... ts: ${e.timestamp || 'N/A'}`); - }); - } else if (parsed?.error) { - console.log(`✅ Kinesis reader responded (stream may be empty): ${parsed.error}`); - } else { - console.log('✅ Kinesis reader responded:', JSON.stringify(parsed).substring(0, 100)); - } - } - - // ============================================================ - // Mutation Test Functions - Boards - // ============================================================ - - async function testCreateBoard(): Promise { - console.log('\n🆕 Testing createBoard...'); - const result = await publicClient.graphql({ - query: createBoard, - variables: { - input: { - name: `Test Board ${Date.now()}`, - }, - }, - }); - const board = (result as any).data.createBoard; - console.log('✅ Created board:', { - id: board.id.substring(0, 8) + '...', - name: board.name, - }); - return board.id; - } - - async function testUpdateBoard(boardId: string): Promise { - console.log(`\n✏️ Testing updateBoard (id: ${boardId.substring(0, 8)}...)...`); - const result = await publicClient.graphql({ - query: updateBoard, - variables: { - input: { - id: boardId, - name: `Updated Board ${Date.now()}`, - }, - }, - }); - const board = (result as any).data.updateBoard; - console.log('✅ Updated board:', { - id: board.id.substring(0, 8) + '...', - name: board.name, - }); - } - - async function testDeleteBoard(boardId: string): Promise { - console.log(`\n🗑️ Testing deleteBoard (id: ${boardId.substring(0, 8)}...)...`); - const result = await publicClient.graphql({ - query: deleteBoard, - variables: { input: { id: boardId } }, - }); - const deleted = (result as any).data.deleteBoard; - console.log('✅ Deleted board:', deleted.name); - } - - // ============================================================ - // Mutation Test Functions - MoodItems - // ============================================================ - - async function testCreateMoodItem(boardId: string): Promise { - console.log('\n🆕 Testing createMoodItem...'); - const result = await publicClient.graphql({ - query: createMoodItem, - variables: { - input: { - title: `Test Mood ${Date.now()}`, - description: 'A test mood item created by the test script', - image: 'https://example.com/test-mood.png', - boardID: boardId, - }, - }, - }); - const item = (result as any).data.createMoodItem; - console.log('✅ Created mood item:', { - id: item.id.substring(0, 8) + '...', - title: item.title, - boardID: item.boardID.substring(0, 8) + '...', - }); - return item.id; - } - - async function testUpdateMoodItem(itemId: string): Promise { - console.log(`\n✏️ Testing updateMoodItem (id: ${itemId.substring(0, 8)}...)...`); - const result = await publicClient.graphql({ - query: updateMoodItem, - variables: { - input: { - id: itemId, - title: `Updated Mood ${Date.now()}`, - description: 'This mood item was updated by the test script', - }, - }, - }); - const item = (result as any).data.updateMoodItem; - console.log('✅ Updated mood item:', { - id: item.id.substring(0, 8) + '...', - title: item.title, - }); - } - - async function testDeleteMoodItem(itemId: string): Promise { - console.log(`\n🗑️ Testing deleteMoodItem (id: ${itemId.substring(0, 8)}...)...`); - const result = await publicClient.graphql({ - query: deleteMoodItem, - variables: { input: { id: itemId } }, - }); - const deleted = (result as any).data.deleteMoodItem; - console.log('✅ Deleted mood item:', deleted.title); - } - - // ============================================================ - // S3 Storage Test Functions - // ============================================================ - - async function testUploadImage(): Promise { - console.log('\n📤 Testing uploadData (S3 image upload)...'); - // 1x1 transparent PNG - const testImageBase64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; - const imageBuffer = Buffer.from(testImageBase64, 'base64'); - const fileName = `test-mood-image-${Date.now()}.png`; - - console.log(` Uploading to: ${fileName}`); - console.log(` File size: ${imageBuffer.length} bytes`); - - const result = await uploadData({ - key: fileName, - data: imageBuffer, - options: { contentType: 'image/png' }, - }).result; - - console.log('✅ Upload successful!'); - console.log(' Key:', result.key); - return result.key; - } - - async function testGetImageUrl(imageKey: string): Promise { - console.log('\n🔗 Testing getUrl (S3 signed URL)...'); - console.log(` Image key: ${imageKey}`); - - const result = await getUrl({ - key: imageKey, - options: { expiresIn: 3600 }, - }); - console.log('✅ Got signed URL!'); - console.log(' URL:', result.url.toString().substring(0, 80) + '...'); - return result.url.toString(); - } - - async function testRemoveImage(imageKey: string): Promise { - console.log('\n🗑️ Testing remove (S3 image delete)...'); - console.log(` Image key: ${imageKey}`); - - await remove({ key: imageKey }); - console.log('✅ Image removed successfully!'); - } - - return { - testListBoards, - testGetBoard, - testListMoodItems, - testGetMoodItem, - testMoodItemsByBoardID, - testGetRandomEmoji, - testGetKinesisEvents, - testCreateBoard, - testUpdateBoard, - testDeleteBoard, - testCreateMoodItem, - testUpdateMoodItem, - testDeleteMoodItem, - testUploadImage, - testGetImageUrl, - testRemoveImage, - }; -} - - -// ============================================================ -// Shared Test Orchestration Functions -// ============================================================ - -export function createTestOrchestrator(testFunctions: ReturnType, runner: TestRunner) { - async function runQueryTests(): Promise<{ boardId: string | null; moodItemId: string | null }> { - console.log('\n' + '='.repeat(60)); - console.log('📖 PART 1: GraphQL Queries'); - console.log('='.repeat(60)); - - const boardId = await runner.runTest('listBoards', testFunctions.testListBoards); - if (boardId) await runner.runTest('getBoard', () => testFunctions.testGetBoard(boardId)); - - const moodItemId = await runner.runTest('listMoodItems', testFunctions.testListMoodItems); - if (moodItemId) await runner.runTest('getMoodItem', () => testFunctions.testGetMoodItem(moodItemId)); - - if (boardId) { - console.log('\n--- Testing mood items filtered by board ---'); - await runner.runTest('moodItemsByBoardID', () => testFunctions.testMoodItemsByBoardID(boardId)); - } - - return { boardId, moodItemId }; - } - - async function runBoardMutationTests(): Promise { - console.log('\n' + '='.repeat(60)); - console.log('📝 PART 2: Board CRUD Operations'); - console.log('='.repeat(60)); - - const boardId = await runner.runTest('createBoard', testFunctions.testCreateBoard); - if (!boardId) { - console.log('❌ Failed to create board, skipping remaining board tests'); - return null; - } - - await runner.runTest('getBoard (verify create)', () => testFunctions.testGetBoard(boardId)); - await runner.runTest('updateBoard', () => testFunctions.testUpdateBoard(boardId)); - await runner.runTest('getBoard (verify update)', () => testFunctions.testGetBoard(boardId)); - - return boardId; - } - - async function runMoodItemMutationTests(boardId: string): Promise { - console.log('\n' + '='.repeat(60)); - console.log('🎨 PART 3: MoodItem CRUD Operations'); - console.log('='.repeat(60)); - - const itemId = await runner.runTest('createMoodItem', () => testFunctions.testCreateMoodItem(boardId)); - if (!itemId) { - console.log('❌ Failed to create mood item, skipping remaining mood item tests'); - return null; - } - - await runner.runTest('getMoodItem (verify create)', () => testFunctions.testGetMoodItem(itemId)); - await runner.runTest('updateMoodItem', () => testFunctions.testUpdateMoodItem(itemId)); - await runner.runTest('getMoodItem (verify update)', () => testFunctions.testGetMoodItem(itemId)); - await runner.runTest('moodItemsByBoardID (for board)', () => testFunctions.testMoodItemsByBoardID(boardId)); - - return itemId; - } - - async function runLambdaTests(): Promise { - console.log('\n' + '='.repeat(60)); - console.log('⚡ PART 4: Lambda Function Operations'); - console.log('='.repeat(60)); - - await runner.runTest('getRandomEmoji', testFunctions.testGetRandomEmoji); - await runner.runTest('getKinesisEvents', testFunctions.testGetKinesisEvents); - } - - async function runStorageTests(): Promise { - console.log('\n' + '='.repeat(60)); - console.log('📸 PART 5: S3 Storage Operations'); - console.log('='.repeat(60)); - - const imageKey = await runner.runTest('uploadImage', testFunctions.testUploadImage); - if (imageKey) { - await runner.runTest('getImageUrl', () => testFunctions.testGetImageUrl(imageKey)); - await runner.runTest('removeImage', () => testFunctions.testRemoveImage(imageKey)); - } - } - - async function runCleanupTests(boardId: string | null, moodItemId: string | null): Promise { - console.log('\n' + '='.repeat(60)); - console.log('🧹 PART 6: Cleanup (Delete Test Data)'); - console.log('='.repeat(60)); - - // Delete in reverse order of creation (mood items -> boards) - if (moodItemId) await runner.runTest('deleteMoodItem', () => testFunctions.testDeleteMoodItem(moodItemId)); - if (boardId) await runner.runTest('deleteBoard', () => testFunctions.testDeleteBoard(boardId)); - } - - return { - runQueryTests, - runBoardMutationTests, - runMoodItemMutationTests, - runLambdaTests, - runStorageTests, - runCleanupTests, - }; -} diff --git a/amplify-migration-apps/mood-board/tests/analytics.test.ts b/amplify-migration-apps/mood-board/tests/analytics.test.ts new file mode 100644 index 00000000000..78cadb1e911 --- /dev/null +++ b/amplify-migration-apps/mood-board/tests/analytics.test.ts @@ -0,0 +1,29 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { generateClient } from 'aws-amplify/api'; +import { signIn, signOut } from 'aws-amplify/auth'; +import { getKinesisEvents } from '../src/graphql/queries'; +import { signUp, config } from './signup'; + +const auth = () => generateClient({ authMode: 'userPool' }); + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('auth', () => { + it('getKinesisEvents returns parseable JSON', async () => { + const result = await auth().graphql({ query: getKinesisEvents }); + const raw = (result as any).data.getKinesisEvents; + + expect(typeof raw).toBe('string'); + const parsed = JSON.parse(raw); + expect(parsed).toBeDefined(); + expect(typeof parsed).toBe('object'); + }); + +}); diff --git a/amplify-migration-apps/mood-board/tests/api.test.ts b/amplify-migration-apps/mood-board/tests/api.test.ts new file mode 100644 index 00000000000..bde61f708fa --- /dev/null +++ b/amplify-migration-apps/mood-board/tests/api.test.ts @@ -0,0 +1,260 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { generateClient } from 'aws-amplify/api'; +import { signIn, signOut } from 'aws-amplify/auth'; +import { + getMoodItem, listMoodItems, + getBoard, listBoards, + moodItemsByBoardID, + getRandomEmoji, + getKinesisEvents, +} from '../src/graphql/queries'; +import { + createMoodItem, updateMoodItem, deleteMoodItem, + createBoard, updateBoard, deleteBoard, +} from '../src/graphql/mutations'; +import { signUp, config } from './signup'; + +const guest = () => generateClient({ authMode: 'apiKey' }); +const auth = () => generateClient({ authMode: 'userPool' }); + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('guest', () => { + describe('Board', () => { + it('creates a board with correct fields', async () => { + const name = `Test Board ${Date.now()}`; + const result = await guest().graphql({ + query: createBoard, + variables: { input: { name } }, + }); + const board = (result as any).data.createBoard; + + expect(typeof board.id).toBe('string'); + expect(board.id.length).toBeGreaterThan(0); + expect(board.name).toBe(name); + expect(board.createdAt).toBeDefined(); + expect(board.updatedAt).toBeDefined(); + }); + + it('reads a board by id', async () => { + const name = `Read Board ${Date.now()}`; + const createResult = await guest().graphql({ + query: createBoard, + variables: { input: { name } }, + }); + const created = (createResult as any).data.createBoard; + + const getResult = await guest().graphql({ query: getBoard, variables: { id: created.id } }); + const fetched = (getResult as any).data.getBoard; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.name).toBe(name); + }); + + it('updates a board and persists changes', async () => { + const createResult = await guest().graphql({ + query: createBoard, + variables: { input: { name: `Update Board ${Date.now()}` } }, + }); + const created = (createResult as any).data.createBoard; + + const updatedName = `Updated Board ${Date.now()}`; + await guest().graphql({ + query: updateBoard, + variables: { input: { id: created.id, name: updatedName } }, + }); + + const getResult = await guest().graphql({ query: getBoard, variables: { id: created.id } }); + const fetched = (getResult as any).data.getBoard; + + expect(fetched.name).toBe(updatedName); + }); + + it('deletes a board', async () => { + const createResult = await guest().graphql({ + query: createBoard, + variables: { input: { name: `Delete Board ${Date.now()}` } }, + }); + const created = (createResult as any).data.createBoard; + + await guest().graphql({ query: deleteBoard, variables: { input: { id: created.id } } }); + + const getResult = await guest().graphql({ query: getBoard, variables: { id: created.id } }); + expect((getResult as any).data.getBoard).toBeNull(); + }); + + it('lists boards including a newly created one', async () => { + const name = `List Board ${Date.now()}`; + const createResult = await guest().graphql({ + query: createBoard, + variables: { input: { name } }, + }); + const created = (createResult as any).data.createBoard; + + const listResult = await guest().graphql({ query: listBoards }); + const items = (listResult as any).data.listBoards.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((b: any) => b.id === created.id); + expect(found).toBeDefined(); + expect(found.name).toBe(name); + }); + }); + + describe('MoodItem', () => { + let boardId: string; + + beforeAll(async () => { + const result = await guest().graphql({ + query: createBoard, + variables: { input: { name: `MoodItem Parent ${Date.now()}` } }, + }); + boardId = (result as any).data.createBoard.id; + }); + + it('creates a mood item with correct fields', async () => { + const input = { + title: `Test Mood ${Date.now()}`, + description: 'A test mood item', + image: 'https://example.com/test-mood.png', + boardID: boardId, + }; + + const result = await guest().graphql({ query: createMoodItem, variables: { input } }); + const item = (result as any).data.createMoodItem; + + expect(typeof item.id).toBe('string'); + expect(item.id.length).toBeGreaterThan(0); + expect(item.title).toBe(input.title); + expect(item.description).toBe('A test mood item'); + expect(item.image).toBe('https://example.com/test-mood.png'); + expect(item.boardID).toBe(boardId); + expect(item.createdAt).toBeDefined(); + expect(item.updatedAt).toBeDefined(); + }); + + it('reads a mood item by id', async () => { + const title = `Read Mood ${Date.now()}`; + const createResult = await guest().graphql({ + query: createMoodItem, + variables: { input: { title, description: 'For read test', image: 'https://example.com/read.png', boardID: boardId } }, + }); + const created = (createResult as any).data.createMoodItem; + + const getResult = await guest().graphql({ query: getMoodItem, variables: { id: created.id } }); + const fetched = (getResult as any).data.getMoodItem; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.title).toBe(title); + expect(fetched.description).toBe('For read test'); + expect(fetched.image).toBe('https://example.com/read.png'); + expect(fetched.boardID).toBe(boardId); + }); + + it('updates a mood item and persists changes', async () => { + const createResult = await guest().graphql({ + query: createMoodItem, + variables: { input: { title: `Update Mood ${Date.now()}`, description: 'Original', image: 'https://example.com/update.png', boardID: boardId } }, + }); + const created = (createResult as any).data.createMoodItem; + + const updatedTitle = `Updated Mood ${Date.now()}`; + await guest().graphql({ + query: updateMoodItem, + variables: { input: { id: created.id, title: updatedTitle, description: 'Now updated' } }, + }); + + const getResult = await guest().graphql({ query: getMoodItem, variables: { id: created.id } }); + const fetched = (getResult as any).data.getMoodItem; + + expect(fetched.title).toBe(updatedTitle); + expect(fetched.description).toBe('Now updated'); + }); + + it('deletes a mood item', async () => { + const createResult = await guest().graphql({ + query: createMoodItem, + variables: { input: { title: `Delete Mood ${Date.now()}`, image: 'https://example.com/delete.png', boardID: boardId } }, + }); + const created = (createResult as any).data.createMoodItem; + + await guest().graphql({ query: deleteMoodItem, variables: { input: { id: created.id } } }); + + const getResult = await guest().graphql({ query: getMoodItem, variables: { id: created.id } }); + expect((getResult as any).data.getMoodItem).toBeNull(); + }); + + it('lists mood items including a newly created one', async () => { + const title = `List Mood ${Date.now()}`; + const createResult = await guest().graphql({ + query: createMoodItem, + variables: { input: { title, image: 'https://example.com/list.png', boardID: boardId } }, + }); + const created = (createResult as any).data.createMoodItem; + + const listResult = await guest().graphql({ query: listMoodItems }); + const items = (listResult as any).data.listMoodItems.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((m: any) => m.id === created.id); + expect(found).toBeDefined(); + expect(found.title).toBe(title); + }); + + it('returns mood items filtered by board ID', async () => { + const boardResult = await guest().graphql({ + query: createBoard, + variables: { input: { name: `ByBoardID Test ${Date.now()}` } }, + }); + const bid = (boardResult as any).data.createBoard.id; + + const title = `ByBoard Mood ${Date.now()}`; + await guest().graphql({ + query: createMoodItem, + variables: { input: { title, description: 'For byBoardID test', image: 'https://example.com/byboard.png', boardID: bid } }, + }); + + const result = await guest().graphql({ query: moodItemsByBoardID, variables: { boardID: bid } }); + const items = (result as any).data.moodItemsByBoardID.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((m: any) => m.title === title); + expect(found).toBeDefined(); + expect(found.boardID).toBe(bid); + }); + }); + + it('cannot call getRandomEmoji', async () => { + await expect( + guest().graphql({ query: getRandomEmoji }), + ).rejects.toBeDefined(); + }); + + it('cannot call getKinesisEvents', async () => { + await expect( + guest().graphql({ query: getKinesisEvents }), + ).rejects.toBeDefined(); + }); +}); + +describe('auth', () => { + it('getRandomEmoji returns a non-empty string', async () => { + const result = await auth().graphql({ query: getRandomEmoji }); + const emoji = (result as any).data.getRandomEmoji; + + expect(typeof emoji).toBe('string'); + expect(emoji.length).toBeGreaterThan(0); + }); +}); diff --git a/amplify-migration-apps/mood-board/tests/jest.setup.ts b/amplify-migration-apps/mood-board/tests/jest.setup.ts new file mode 100644 index 00000000000..bb0b4613b66 --- /dev/null +++ b/amplify-migration-apps/mood-board/tests/jest.setup.ts @@ -0,0 +1 @@ +jest.retryTimes(3); diff --git a/amplify-migration-apps/mood-board/tests/signup.ts b/amplify-migration-apps/mood-board/tests/signup.ts new file mode 100644 index 00000000000..c33ab0cc535 --- /dev/null +++ b/amplify-migration-apps/mood-board/tests/signup.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Amplify } from 'aws-amplify'; +import { parseAmplifyConfig } from 'aws-amplify/utils'; +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, + AdminSetUserPasswordCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import * as fs from 'fs'; +import { randomBytes } from 'crypto'; + +import { webcrypto } from 'crypto'; +if (typeof globalThis.crypto === 'undefined') { + (globalThis as any).crypto = webcrypto; +} + +const CONFIG_PATH = process.env.APP_CONFIG_PATH; +if (!CONFIG_PATH) { + throw new Error('APP_CONFIG_PATH environment variable is required'); +} + +export const config = JSON.parse(fs.readFileSync(CONFIG_PATH, { encoding: 'utf-8' })); +Amplify.configure({ + ...parseAmplifyConfig(config), + Analytics: { + Kinesis: { + region: config.aws_project_region ?? config.auth?.aws_region ?? 'us-east-1', + }, + }, +}); + +export async function signUp(cfg: any): Promise<{ username: string; password: string }> { + const gen2Auth = (cfg as any)?.auth; + const userPoolId = cfg.aws_user_pools_id ?? gen2Auth?.user_pool_id; + const region = cfg.aws_cognito_region ?? gen2Auth?.aws_region; + + const uname = generateTestEmail(); + const pwd = generateTestPassword(); + + const cognitoClient = new CognitoIdentityProviderClient({ region }); + + await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: userPoolId, + Username: uname, + TemporaryPassword: pwd, + MessageAction: 'SUPPRESS', + UserAttributes: [ + { Name: 'email', Value: uname }, + { Name: 'email_verified', Value: 'true' }, + ], + })); + + await cognitoClient.send(new AdminSetUserPasswordCommand({ + UserPoolId: userPoolId, + Username: uname, + Password: pwd, + Permanent: true, + })); + + return { username: uname, password: pwd }; +} + +function generateTestPassword(): string { + return `Test${randomSuffix()}!Aa1`; +} + +function generateTestEmail(): string { + return `testuser-${randomSuffix()}@test.example.com`; +} + +function randomSuffix(): string { + return randomBytes(4).toString('hex'); +} diff --git a/amplify-migration-apps/mood-board/tests/storage.test.ts b/amplify-migration-apps/mood-board/tests/storage.test.ts new file mode 100644 index 00000000000..9cf3b07c53a --- /dev/null +++ b/amplify-migration-apps/mood-board/tests/storage.test.ts @@ -0,0 +1,98 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { signIn, signOut } from 'aws-amplify/auth'; +import { uploadData, getUrl, downloadData, remove } from 'aws-amplify/storage'; +import { signUp, config } from './signup'; + +const testImageBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('guest', () => { + it('can read a public file', async () => { + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `test-guest-read-${Date.now()}.png`; + + await uploadData({ + key: fileName, + data: imageBuffer, + options: { contentType: 'image/png' }, + }).result; + + await signOut(); + + const downloadResult = await downloadData({ key: fileName }).result; + const blob = await downloadResult.body.blob(); + const buffer = Buffer.from(await blob.arrayBuffer()); + + expect(buffer.length).toBeGreaterThan(0); + + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); + }); + + it('cannot upload a file', async () => { + await signOut(); + + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `unauthorized-${Date.now()}.png`; + + await expect( + uploadData({ key: fileName, data: imageBuffer, options: { contentType: 'image/png' } }).result, + ).rejects.toBeDefined(); + + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); + }); +}); + +describe('auth', () => { + it('uploads a file', async () => { + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `test-upload-${Date.now()}.png`; + + const result = await uploadData({ + key: fileName, + data: imageBuffer, + options: { contentType: 'image/png' }, + }).result; + + expect(result.key).toBe(fileName); + }); + + it('gets a signed URL', async () => { + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `test-geturl-${Date.now()}.png`; + + await uploadData({ + key: fileName, + data: imageBuffer, + options: { contentType: 'image/png' }, + }).result; + + const result = await getUrl({ key: fileName, options: { expiresIn: 3600 } }); + + expect(result.url).toBeDefined(); + expect(result.url.toString()).toContain(fileName); + }); + + it('removes a file', async () => { + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `test-remove-${Date.now()}.png`; + + await uploadData({ + key: fileName, + data: imageBuffer, + options: { contentType: 'image/png' }, + }).result; + + await remove({ key: fileName }); + }); +}); diff --git a/amplify-migration-apps/product-catalog/README.md b/amplify-migration-apps/product-catalog/README.md index fd7b451b6e0..fd98ef0bfb4 100644 --- a/amplify-migration-apps/product-catalog/README.md +++ b/amplify-migration-apps/product-catalog/README.md @@ -281,68 +281,20 @@ git checkout -b gen2-main npx amplify gen2-migration generate ``` -**Edit in `./amplify/data/resource.ts`:** - -```diff -- branchName: "main" -+ branchName: "gen2-main" -``` - -**Edit in `./amplify/backend.ts`:** - -```diff -- import { Duration } from "aws-cdk-lib"; -+ import { Duration, aws_iam } from "aws-cdk-lib"; +```console +npm run post-generate ``` On the AppSync AWS Console, locate the ID of Gen1 API, it will be named `productcatalog-main`. ![](./images/gen1-appsync-api-id.png) -**Edit in `./amplify/backend.ts`:** - -```diff -+ backend.auth.resources.authenticatedUserIamRole.addToPrincipalPolicy(new aws_iam.PolicyStatement({ -+ effect: aws_iam.Effect.ALLOW, -+ actions: ['appsync:GraphQL'], -+ resources: [`arn:aws:appsync:${backend.data.stack.region}:${backend.data.stack.account}:apis//*`] -+ })) -``` - -**Edit in `./amplify/function/lowstockproducts/index.js`:** - -```diff -- exports.handler = async (event) => { -+ export async function handler(event) { -``` - -```diff -- const secretValue = await fetchSecret(); -+ const secretValue = process.env['PRODUCT_CATALOG_SECRET']; -``` - -**Edit in `./amplify/function/lowstockproducts/resource.ts`:** - -```diff -- import { defineFunction } from "@aws-amplify/backend"; -+ import { defineFunction, secret } from "@aws-amplify/backend"; - -- PRODUCT_CATALOG_SECRET: "/amplify/d3ttwn44fldtgx/main/AMPLIFY_lowstockproducts_PRODUCT_CATALOG_SECRET" -+ PRODUCT_CATALOG_SECRET: secret("PRODUCT_CATALOG_SECRET") -``` - -**Edit in `./amplify/storage/S3Trigger/index.js`:** - -```diff -- exports.handler = async function (event) { -+ export async function handler(event) { -``` - -**Edit in `./src/main.tsx`:** +Update the placeholder `` in `./amplify/backend.ts` with the actual API ID. -```diff -- import amplifyconfig from './amplifyconfiguration.json'; -+ import amplifyconfig from '../amplify_outputs.json'; +```console +rm -rf node_modules package-lock.json +npm install +npm install --package-lock-only ``` In the Amplify console, recreate the `PRODUCT_CATALOG_SECRET` secret: diff --git a/amplify-migration-apps/product-catalog/_snapshot.post.generate/package.json b/amplify-migration-apps/product-catalog/_snapshot.post.generate/package.json index 5f84f6cdaea..d29f255fd88 100644 --- a/amplify-migration-apps/product-catalog/_snapshot.post.generate/package.json +++ b/amplify-migration-apps/product-catalog/_snapshot.post.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/product-catalog", + "name": "@amplify-migration-apps/product-catalog-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/product-catalog/_snapshot.pre.generate/package.json b/amplify-migration-apps/product-catalog/_snapshot.pre.generate/package.json index cb822ea32b6..0c31b2e89a0 100644 --- a/amplify-migration-apps/product-catalog/_snapshot.pre.generate/package.json +++ b/amplify-migration-apps/product-catalog/_snapshot.pre.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/product-catalog", + "name": "@amplify-migration-apps/product-catalog-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/product-catalog/backend/configure.sh b/amplify-migration-apps/product-catalog/backend/configure.sh new file mode 100755 index 00000000000..71eeb60d442 --- /dev/null +++ b/amplify-migration-apps/product-catalog/backend/configure.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euxo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +s3_trigger_function_name=$(ls ${script_dir}/../amplify/backend/function | grep S3Trigger) + +cp -f ${script_dir}/schema.graphql ${script_dir}/../amplify/backend/api/productcatalog/schema.graphql +cp -f ${script_dir}/lowstockproducts.js ${script_dir}/../amplify/backend/function/lowstockproducts/src/index.js +cp -f ${script_dir}/lowstockproducts.package.json ${script_dir}/../amplify/backend/function/lowstockproducts/src/package.json +cp -f ${script_dir}/onimageuploaded.js ${script_dir}/../amplify/backend/function/${s3_trigger_function_name}/src/index.js +cp -f ${script_dir}/onimageuploaded.package.json ${script_dir}/../amplify/backend/function/${s3_trigger_function_name}/src/package.json +cp -f ${script_dir}/custom-roles.json ${script_dir}/../amplify/backend/api/productcatalog/custom-roles.json diff --git a/amplify-migration-apps/product-catalog/custom-roles.json b/amplify-migration-apps/product-catalog/backend/custom-roles.json similarity index 100% rename from amplify-migration-apps/product-catalog/custom-roles.json rename to amplify-migration-apps/product-catalog/backend/custom-roles.json diff --git a/amplify-migration-apps/product-catalog/lowstockproducts.js b/amplify-migration-apps/product-catalog/backend/lowstockproducts.js similarity index 100% rename from amplify-migration-apps/product-catalog/lowstockproducts.js rename to amplify-migration-apps/product-catalog/backend/lowstockproducts.js diff --git a/amplify-migration-apps/product-catalog/lowstockproducts.package.json b/amplify-migration-apps/product-catalog/backend/lowstockproducts.package.json similarity index 100% rename from amplify-migration-apps/product-catalog/lowstockproducts.package.json rename to amplify-migration-apps/product-catalog/backend/lowstockproducts.package.json diff --git a/amplify-migration-apps/product-catalog/onimageuploaded.js b/amplify-migration-apps/product-catalog/backend/onimageuploaded.js similarity index 100% rename from amplify-migration-apps/product-catalog/onimageuploaded.js rename to amplify-migration-apps/product-catalog/backend/onimageuploaded.js diff --git a/amplify-migration-apps/product-catalog/onimageuploaded.package.json b/amplify-migration-apps/product-catalog/backend/onimageuploaded.package.json similarity index 100% rename from amplify-migration-apps/product-catalog/onimageuploaded.package.json rename to amplify-migration-apps/product-catalog/backend/onimageuploaded.package.json diff --git a/amplify-migration-apps/product-catalog/schema.graphql b/amplify-migration-apps/product-catalog/backend/schema.graphql similarity index 94% rename from amplify-migration-apps/product-catalog/schema.graphql rename to amplify-migration-apps/product-catalog/backend/schema.graphql index 68493ba9e31..c087f61d18d 100644 --- a/amplify-migration-apps/product-catalog/schema.graphql +++ b/amplify-migration-apps/product-catalog/backend/schema.graphql @@ -60,7 +60,6 @@ type LowStockResponse { type Query { checkLowStock: LowStockResponse @function(name: "lowstockproducts-${env}") @auth(rules: [ - { allow: private, provider: iam }, - { allow: public, provider: apiKey } + { allow: private, provider: iam } ]) } diff --git a/amplify-migration-apps/product-catalog/configure.sh b/amplify-migration-apps/product-catalog/configure.sh deleted file mode 100755 index b4f6510f709..00000000000 --- a/amplify-migration-apps/product-catalog/configure.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -s3_trigger_function_name=$(ls amplify/backend/function | grep S3Trigger) - -cp -f schema.graphql ./amplify/backend/api/productcatalog/schema.graphql -cp -f lowstockproducts.js ./amplify/backend/function/lowstockproducts/src/index.js -cp -f lowstockproducts.package.json ./amplify/backend/function/lowstockproducts/src/package.json -cp -f onimageuploaded.js ./amplify/backend/function/${s3_trigger_function_name}/src/index.js -cp -f onimageuploaded.package.json ./amplify/backend/function/${s3_trigger_function_name}/src/package.json -cp -f custom-roles.json ./amplify/backend/api/productcatalog/custom-roles.json \ No newline at end of file diff --git a/amplify-migration-apps/product-catalog/gen1-test-script.ts b/amplify-migration-apps/product-catalog/gen1-test-script.ts deleted file mode 100644 index a67bf793276..00000000000 --- a/amplify-migration-apps/product-catalog/gen1-test-script.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Gen1 Test Script for Product Catalog App - * - * This script tests all functionality for Amplify Gen1: - * 1. GraphQL Queries (Products, Users, Comments, Low Stock Lambda) - * 2. Product Mutations (Create, Update, Delete) - * 3. User Mutations (Create, Update Role, Delete) - * 4. Comment Mutations (Create, Update, Delete) - * 5. S3 Storage Operations (Upload, Get URL, Product with Image) - * 6. Role-Based Access Control - * 7. Business Logic (Filtering, Sorting, Reports) - * - * Credentials are provisioned automatically via Cognito SignUp + AdminConfirmSignUp. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import amplifyconfig from './src/amplifyconfiguration.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Product Catalog Gen1 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. GraphQL Queries (Products, Users, Comments, Low Stock)'); - console.log(' 2. Product Mutations (Create, Update, Delete)'); - console.log(' 3. User Mutations (Create, Update Role, Delete)'); - console.log(' 4. Comment Mutations (Create, Update, Delete)'); - console.log(' 5. S3 Storage Operations (Upload, Get URL)'); - console.log(' 6. Role-Based Access Control'); - console.log(' 7. Business Logic (Filtering, Sorting, Reports)'); - - // Provision user via SDK, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - // Sign in from this module so the auth tokens are available to api/storage - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn has failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { - runQueryTests, - runProductMutationTests, - runUserMutationTests, - runCommentMutationTests, - runStorageTests, - runRBACTests, - runBusinessLogicTests, - } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Query tests - const { productId: existingProductId } = await runQueryTests(); - - // Part 2: Product mutations - const testProductId = await runProductMutationTests(); - - // Part 3: User mutations - await runUserMutationTests(); - - // Part 4: Comment mutations (use test product or existing product) - const productForComments = testProductId || existingProductId; - if (productForComments) { - await runCommentMutationTests(productForComments); - } else { - console.log('\n⚠️ Skipping comment tests - no product available'); - } - - // Part 5: Storage tests - await runStorageTests(); - - // Part 6: RBAC tests - await runRBACTests(); - - // Part 7: Business logic tests - await runBusinessLogicTests(); - - // Cleanup: delete test product if created - if (testProductId) { - console.log('\n🧹 Cleanup: Deleting test product...'); - await runner.runTest('deleteProduct_final', () => testFunctions.testDeleteProduct(testProductId)); - } - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/product-catalog/gen2-test-script.ts b/amplify-migration-apps/product-catalog/gen2-test-script.ts deleted file mode 100644 index 539f1d7c39e..00000000000 --- a/amplify-migration-apps/product-catalog/gen2-test-script.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Gen2 Test Script for Product Catalog App - * - * This script tests all functionality for Amplify Gen2: - * 1. GraphQL Queries (Products, Users, Comments, Low Stock Lambda) - * 2. Product Mutations (Create, Update, Delete) - * 3. User Mutations (Create, Update Role, Delete) - * 4. Comment Mutations (Create, Update, Delete) - * 5. S3 Storage Operations (Upload, Get URL, Product with Image) - * 6. Role-Based Access Control - * 7. Business Logic (Filtering, Sorting, Reports) - * - * Credentials are provisioned automatically via Cognito AdminCreateUser. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import amplifyconfig from './amplify_outputs.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify with Gen2 outputs -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Product Catalog Gen2 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. GraphQL Queries (Products, Users, Comments, Low Stock)'); - console.log(' 2. Product Mutations (Create, Update, Delete)'); - console.log(' 3. User Mutations (Create, Update Role, Delete)'); - console.log(' 4. Comment Mutations (Create, Update, Delete)'); - console.log(' 5. S3 Storage Operations (Upload, Get URL)'); - console.log(' 6. Role-Based Access Control'); - console.log(' 7. Business Logic (Filtering, Sorting, Reports)'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { - runQueryTests, - runProductMutationTests, - runUserMutationTests, - runCommentMutationTests, - runStorageTests, - runRBACTests, - runBusinessLogicTests, - } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Query tests - const { productId: existingProductId } = await runQueryTests(); - - // Part 2: Product mutations - const testProductId = await runProductMutationTests(); - - // Part 3: User mutations - await runUserMutationTests(); - - // Part 4: Comment mutations (use test product or existing product) - const productForComments = testProductId || existingProductId; - if (productForComments) { - await runCommentMutationTests(productForComments); - } else { - console.log('\n⚠️ Skipping comment tests - no product available'); - } - - // Part 5: Storage tests - await runStorageTests(); - - // Part 6: RBAC tests - await runRBACTests(); - - // Part 7: Business logic tests - await runBusinessLogicTests(); - - // Cleanup: delete test product if created - if (testProductId) { - console.log('\n🧹 Cleanup: Deleting test product...'); - await runner.runTest('deleteProduct_final', () => testFunctions.testDeleteProduct(testProductId)); - } - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/product-catalog/jest.config.js b/amplify-migration-apps/product-catalog/jest.config.js new file mode 100644 index 00000000000..fb5a9b20fe0 --- /dev/null +++ b/amplify-migration-apps/product-catalog/jest.config.js @@ -0,0 +1,23 @@ +/** @type {import('jest').Config} */ +export default { + testMatch: ['/tests/**/*.test.ts'], + modulePathIgnorePatterns: ['/_snapshot', '/amplify'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: { + target: 'ES2022', + module: 'CommonJS', + moduleResolution: 'node', + esModuleInterop: true, + allowJs: true, + noEmit: true, + strict: true, + skipLibCheck: true, + types: ['node', 'jest'], + }, + }], + }, + testTimeout: 30_000, + setupFilesAfterEnv: ['/tests/jest.setup.ts'], +}; diff --git a/amplify-migration-apps/product-catalog/migration-config.json b/amplify-migration-apps/product-catalog/migration-config.json deleted file mode 100644 index c2b9ac3e0b9..00000000000 --- a/amplify-migration-apps/product-catalog/migration-config.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "app": { - "name": "product-catalog", - "description": "Product catalog with IAM-based authentication, S3 storage with trigger, and inventory management", - "framework": "react" - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["IAM", "API_KEY", "COGNITO_USER_POOLS"], - "customQueries": ["checkLowStock"] - }, - "auth": { - "signInMethods": ["email"], - "socialProviders": [] - }, - "storage": { - "buckets": [ - { - "name": "productimages", - "access": ["auth"] - } - ], - "triggers": [ - { - "name": "S3Trigger", - "events": ["objectCreated"], - "function": "S3Trigger" - } - ] - }, - "function": { - "functions": [ - { - "name": "lowstockproducts", - "runtime": "nodejs", - "template": "hello-world" - } - ] - }, - "hosting": { - "type": "amplify-console" - } - } -} diff --git a/amplify-migration-apps/product-catalog/migration/post-generate.ts b/amplify-migration-apps/product-catalog/migration/post-generate.ts new file mode 100644 index 00000000000..85f81f7dec1 --- /dev/null +++ b/amplify-migration-apps/product-catalog/migration/post-generate.ts @@ -0,0 +1,189 @@ +#!/usr/bin/env npx ts-node +/** + * Post-generate script for product-catalog app. + * + * Applies manual edits required after `amplify gen2-migration generate`: + * 1. Update branchName in amplify/data/resource.ts to the value of AWS_BRANCH + * env var, or the current git branch if AWS_BRANCH is not set + * 2. Convert lowstockproducts function from CommonJS to ESM + * 3. Replace fetchSecret() with process.env in lowstockproducts + * 4. Update lowstockproducts/resource.ts to use secret() instead of hardcoded SSM path + * 5. Convert S3Trigger function from CommonJS to ESM + * 6. Update frontend import from amplifyconfiguration.json to amplify_outputs.json + * 7. Add IAM policy to backend.ts for authenticated user to access Gen1 AppSync API + * 8. Resolve the Gen1 AppSync API ID and replace the placeholder in backend.ts + */ + +import { execSync } from 'child_process'; +import fs from 'fs/promises'; +import fsSync from 'fs'; +import path from 'path'; +import { AppSyncClient, paginateListGraphqlApis } from '@aws-sdk/client-appsync'; + +function resolveTargetBranch(): string { + if (process.env.AWS_BRANCH) { + return process.env.AWS_BRANCH; + } + return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); +} + +async function updateBranchName(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); + const content = await fs.readFile(resourcePath, 'utf-8'); + + const targetBranch = resolveTargetBranch(); + + const updated = content.replace( + /branchName:\s*['"]([^'"]+)['"]/, + `branchName: '${targetBranch}'`, + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function convertLowstockproductsToESM(appPath: string): Promise { + const handlerPath = path.join(appPath, 'amplify', 'function', 'lowstockproducts', 'index.js'); + const content = await fs.readFile(handlerPath, 'utf-8'); + + let updated = content.replace( + /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + updated = updated.replace( + /module\.exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + // Replace fetchSecret() call with process.env lookup + updated = updated.replace( + /const\s+secretValue\s*=\s*await\s+fetchSecret\(\);?/g, + "const secretValue = process.env['PRODUCT_CATALOG_SECRET'];", + ); + + await fs.writeFile(handlerPath, updated, 'utf-8'); +} + +async function updateLowstockproductsResource(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'function', 'lowstockproducts', 'resource.ts'); + const content = await fs.readFile(resourcePath, 'utf-8'); + + // Add secret to the import from @aws-amplify/backend + let updated = content.replace( + /import\s*\{\s*defineFunction\s*\}\s*from\s*["']@aws-amplify\/backend["']/, + 'import { defineFunction, secret } from "@aws-amplify/backend"', + ); + + // Replace hardcoded SSM path with secret() + updated = updated.replace( + /PRODUCT_CATALOG_SECRET:\s*["'][^"']+["']/, + 'PRODUCT_CATALOG_SECRET: secret("PRODUCT_CATALOG_SECRET")', + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function convertS3TriggerToESM(appPath: string): Promise { + const storageDir = path.join(appPath, 'amplify', 'storage'); + const entries = fsSync.readdirSync(storageDir); + const triggerDirs = entries.filter((e) => e.startsWith('S3Trigger')); + + for (const dir of triggerDirs) { + const handlerPath = path.join(storageDir, dir, 'index.js'); + if (!fsSync.existsSync(handlerPath)) continue; + + const content = await fs.readFile(handlerPath, 'utf-8'); + + let updated = content.replace( + /exports\.handler\s*=\s*async\s*(function\s*)?\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($2) {', + ); + + updated = updated.replace( + /exports\.handler\s*=\s*async\s+function\s*\((\w*)\)\s*\{/g, + 'export async function handler($1) {', + ); + + await fs.writeFile(handlerPath, updated, 'utf-8'); + } +} + +async function updateFrontendConfig(appPath: string): Promise { + const mainPath = path.join(appPath, 'src', 'main.tsx'); + const content = await fs.readFile(mainPath, 'utf-8'); + + const updated = content.replace( + /from\s*["']\.\/amplifyconfiguration\.json["']/g, + "from '../amplify_outputs.json'", + ); + + await fs.writeFile(mainPath, updated, 'utf-8'); +} + +/** + * Look up the Gen1 AppSync API ID by querying all APIs and finding + * the one tagged with "user:Application" matching the app name. + */ +async function resolveGen1AppSyncApiId(appName: string): Promise { + const client = new AppSyncClient({}); + + for await (const page of paginateListGraphqlApis({ client }, {})) { + for (const api of page.graphqlApis ?? []) { + if (api.tags?.['user:Application'] === appName) { + return api.apiId!; + } + } + } + + throw new Error(`No AppSync API found with tag user:Application=${appName}`); +} + +async function addGen1AppSyncPolicy(appPath: string, appName: string): Promise { + const apiId = await resolveGen1AppSyncApiId(appName); + + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + const content = await fs.readFile(backendPath, 'utf-8'); + + // Add aws_iam to the Duration import from aws-cdk-lib + let updated = content.replace( + /import\s*\{([^}]*)\bDuration\b([^}]*)\}\s*from\s*["']aws-cdk-lib["']/, + (match, before, after) => { + if (match.includes('aws_iam')) return match; + return `import {${before}Duration${after}, aws_iam } from "aws-cdk-lib"`; + }, + ); + + const policyBlock = ` +backend.auth.resources.authenticatedUserIamRole.addToPrincipalPolicy(new aws_iam.PolicyStatement({ + effect: aws_iam.Effect.ALLOW, + actions: ['appsync:GraphQL'], + resources: [\`arn:aws:appsync:\${backend.data.stack.region}:\${backend.data.stack.account}:apis/${apiId}/*\`] +})) +`; + + updated = updated.trimEnd() + '\n' + policyBlock; + + await fs.writeFile(backendPath, updated, 'utf-8'); +} + +export async function postGenerate(appPath: string): Promise { + const packageJson = JSON.parse(await fs.readFile(path.join(appPath, 'package.json'), 'utf-8')); + const appName = packageJson.name as string; + + await updateBranchName(appPath); + await convertLowstockproductsToESM(appPath); + await updateLowstockproductsResource(appPath); + await convertS3TriggerToESM(appPath); + await updateFrontendConfig(appPath); + await addGen1AppSyncPolicy(appPath, appName); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postGenerate(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/product-catalog/migration/post-refactor.ts b/amplify-migration-apps/product-catalog/migration/post-refactor.ts new file mode 100644 index 00000000000..22a8e26b778 --- /dev/null +++ b/amplify-migration-apps/product-catalog/migration/post-refactor.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env npx ts-node +/** + * Post-refactor script for product-catalog app. + * + * Applies manual edits required after `amplify gen2-migration refactor`: + * 1. Uncomment s3Bucket.bucketName in amplify/backend.ts to sync with deployed template + */ + +import fs from 'fs/promises'; +import path from 'path'; + +/** + * Uncomment the s3Bucket.bucketName line in backend.ts. + * + * The generate step produces a commented line like: + * // s3Bucket.bucketName = 'bucket-name-here'; + * + * After refactor, we need to uncomment it to sync with the deployed template. + */ +async function uncommentS3BucketName(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + const content = await fs.readFile(backendPath, 'utf-8'); + + // Match commented bucket name line: // s3Bucket.bucketName = '...'; + const updated = content.replace( + /\/\/\s*(s3Bucket\.bucketName\s*=\s*['"][^'"]+['"];?)/g, + '$1', + ); + + await fs.writeFile(backendPath, updated, 'utf-8'); +} + +export async function postRefactor(appPath: string): Promise { + await uncommentS3BucketName(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRefactor(appPath) +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/product-catalog/migration/post-sandbox.ts b/amplify-migration-apps/product-catalog/migration/post-sandbox.ts new file mode 100644 index 00000000000..b5b0e12f615 --- /dev/null +++ b/amplify-migration-apps/product-catalog/migration/post-sandbox.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env npx ts-node +/** + * Post-sandbox script for product-catalog app. + * + * Writes the PRODUCT_CATALOG_SECRET to SSM Parameter Store using the + * path convention that ampx sandbox expects: + * /amplify//<3-last-segments-of-root-stack-name>/ + * + * Requires the APP_GEN2_ROOT_STACK_NAME environment variable to be set + * by the e2e system. + */ + +import { SSMClient, PutParameterCommand } from '@aws-sdk/client-ssm'; +import fs from 'fs'; +import path from 'path'; + +function extractSsmPathSegment(stackName: string): string { + const parts = stackName.split('-'); + return parts.slice(-3).join('-'); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + + const stackName = process.env.APP_GEN2_ROOT_STACK_NAME; + if (!stackName) { + throw new Error('APP_GEN2_ROOT_STACK_NAME environment variable is required'); + } + + const packageJsonPath = path.join(appPath, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + const appName = packageJson.name as string; + + const ssmSegment = extractSsmPathSegment(stackName); + const parameterName = `/amplify/${appName}/${ssmSegment}/PRODUCT_CATALOG_SECRET`; + + const ssm = new SSMClient({}); + await ssm.send(new PutParameterCommand({ + Name: parameterName, + Value: 'e2e-test-secret-value', + Type: 'SecureString', + Overwrite: true, + })); + +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/product-catalog/migration/pre-push.ts b/amplify-migration-apps/product-catalog/migration/pre-push.ts new file mode 100644 index 00000000000..34a0bdf3917 --- /dev/null +++ b/amplify-migration-apps/product-catalog/migration/pre-push.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env npx ts-node +/** + * Pre-push script for product-catalog app. + * + * 1. Substitutes the real Amplify app ID into custom-roles.json so that + * the S3 trigger Lambda (which uses IAM auth) gets admin access to + * the AppSync API. + * 2. Sets the lowStockThreshold and secretsPathAmplifyAppId parameters + * in team-provider-info.json for the current environment so that + * `amplify push --yes` doesn't prompt for missing values. + * 3. Creates the PRODUCT_CATALOG_SECRET in SSM Parameter Store so the + * Gen1 lowstockproducts Lambda can resolve it at runtime. + */ + +import fs from 'fs'; +import path from 'path'; +import { SSMClient, PutParameterCommand } from '@aws-sdk/client-ssm'; + +function readAmplifyAppId(appPath: string): string { + const metaPath = path.join(appPath, 'amplify', 'backend', 'amplify-meta.json'); + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); + return meta.providers?.awscloudformation?.AmplifyAppId as string; +} + +function readEnvName(appPath: string): string { + const tpiPath = path.join(appPath, 'amplify', 'team-provider-info.json'); + const tpi = JSON.parse(fs.readFileSync(tpiPath, 'utf-8')); + return Object.keys(tpi)[0]; +} + +function readDeploymentName(appPath: string): string { + const packageJsonPath = path.join(appPath, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + return packageJson.name as string; +} + +function updateCustomRoles(appPath: string, appName: string): void { + const filePath = path.join(appPath, 'amplify', 'backend', 'api', 'productcatalog', 'custom-roles.json'); + // Gen2 trims role name prefixes to 10 characters + const trimmedName = appName.slice(0, 10); + fs.writeFileSync(filePath, JSON.stringify({ adminRoleNames: [`amplify-${trimmedName}`] }, null, 2), 'utf-8'); +} + +function setFunctionParameters(appPath: string, envName: string, amplifyAppId: string): void { + const tpiPath = path.join(appPath, 'amplify', 'team-provider-info.json'); + const tpi = JSON.parse(fs.readFileSync(tpiPath, 'utf-8')); + + tpi[envName].categories ??= {}; + tpi[envName].categories.function ??= {}; + tpi[envName].categories.function.lowstockproducts = { + ...tpi[envName].categories.function.lowstockproducts, + lowStockThreshold: '5', + secretsPathAmplifyAppId: amplifyAppId, + }; + fs.writeFileSync(tpiPath, JSON.stringify(tpi, null, 2), 'utf-8'); +} + +async function createGen1Secret(amplifyAppId: string, envName: string): Promise { + const parameterName = `/amplify/${amplifyAppId}/${envName}/AMPLIFY_lowstockproducts_PRODUCT_CATALOG_SECRET`; + const ssm = new SSMClient({}); + await ssm.send(new PutParameterCommand({ + Name: parameterName, + Value: 'e2e-test-secret-value', + Type: 'SecureString', + Overwrite: true, + })); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + + const amplifyAppId = readAmplifyAppId(appPath); + const envName = readEnvName(appPath); + const appName = readDeploymentName(appPath); + + updateCustomRoles(appPath, appName); + setFunctionParameters(appPath, envName, amplifyAppId); + await createGen1Secret(amplifyAppId, envName); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/product-catalog/package.json b/amplify-migration-apps/product-catalog/package.json index cb822ea32b6..27dd08fc2c7 100644 --- a/amplify-migration-apps/product-catalog/package.json +++ b/amplify-migration-apps/product-catalog/package.json @@ -11,9 +11,18 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "configure": "./configure.sh", + "configure": "./backend/configure.sh", "sanitize": "tsx ../sanitize.ts", - "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit" + "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit", + "test:gen1": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-src/amplifyconfiguration.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:gen2": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-amplify_outputs.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:e2e": "cd ../../packages/amplify-gen2-migration-e2e-system && npx tsx src/cli.ts --app product-catalog --profile ${AWS_PROFILE:-default}", + "pre-push": "npx tsx migration/pre-push.ts", + "post-generate": "npx tsx migration/post-generate.ts", + "post-refactor": "npx tsx migration/post-refactor.ts", + "post-sandbox": "npx tsx migration/post-sandbox.ts", + "pre-sandbox": "true", + "post-push": "true" }, "dependencies": { "@aws-amplify/ui-react": "^6.13.1", @@ -22,7 +31,11 @@ "react-dom": "^19.2.0" }, "devDependencies": { + "@aws-sdk/client-appsync": "^3.936.0", + "@aws-sdk/client-cognito-identity-provider": "^3.936.0", + "@aws-sdk/client-ssm": "^3.936.0", "@eslint/js": "^9.39.1", + "@types/jest": "^29.5.14", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -31,6 +44,8 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jest": "^29.7.0", + "ts-jest": "^29.3.4", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" diff --git a/amplify-migration-apps/product-catalog/test-utils.ts b/amplify-migration-apps/product-catalog/test-utils.ts deleted file mode 100644 index 5b8a0886b4f..00000000000 --- a/amplify-migration-apps/product-catalog/test-utils.ts +++ /dev/null @@ -1,708 +0,0 @@ -// test-utils.ts -/** - * Shared test utilities for Product Catalog Gen1 and Gen2 test scripts - */ - -import { Amplify } from 'aws-amplify'; -import { generateClient } from 'aws-amplify/api'; -import { uploadData, getUrl } from 'aws-amplify/storage'; -import { getCurrentUser } from 'aws-amplify/auth'; -import * as fs from 'fs'; - -// Import GraphQL queries and mutations -import { getProduct, listProducts, getUser, listUsers, listComments, commentsByProductId, checkLowStock } from './src/graphql/queries'; - -import { - createProduct, - updateProduct, - deleteProduct, - createUser, - updateUser, - deleteUser, - createComment, - updateComment, - deleteComment, -} from './src/graphql/mutations'; - -import { UserRole } from './src/API'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import amplifyconfig from './src/amplifyconfiguration.json'; - -// Configure Amplify in this module to ensure api/storage singletons see the config -Amplify.configure(amplifyconfig); - -// ============================================================ -// Shared Test Functions Factory -// ============================================================ - -export function createTestFunctions() { - const CATEGORIES = ['Electronics', 'Clothing', 'Books', 'Home & Garden', 'Sports', 'Toys', 'Other']; - - function getAuthClient() { - return generateClient(); - } - - // ============================================================ - // PART 1: GraphQL Query Tests - // ============================================================ - - // Test ListProducts - - async function testListProducts(): Promise { - console.log('\n📦 Testing listProducts...'); - const client = getAuthClient(); - - const result = await client.graphql({ query: listProducts }); - const products = (result as any).data.listProducts.items; - console.log(`✅ Found ${products.length} products:`); - products - .slice(0, 5) - .forEach((p: any) => console.log(` - [${p.id.substring(0, 8)}...] ${p.engword} | ${p.price || 'N/A'} | Stock: ${p.stock || 0}`)); - if (products.length > 5) console.log(` ... and ${products.length - 5} more`); - return products.length > 0 ? products[0].id : null; - } - - async function testGetProduct(id: string): Promise { - console.log(`\n🔍 Testing getProduct (id: ${id.substring(0, 8)}...)...`); - const client = getAuthClient(); - - const result = await client.graphql({ - query: getProduct, - variables: { id }, - }); - const product = (result as any).data.getProduct; - console.log('✅ Product details:', { - id: product.id, - name: product.engword, - serialno: product.serialno, - price: product.price, - category: product.category, - stock: product.stock, - brand: product.brand, - }); - } - - async function testListUsers(): Promise { - console.log('\n👥 Testing listUsers...'); - const client = getAuthClient(); - - const result = await client.graphql({ query: listUsers }); - const users = (result as any).data.listUsers.items; - console.log(`✅ Found ${users.length} users:`); - users.forEach((u: any) => console.log(` - [${u.role}] ${u.name} (${u.email})`)); - return users.length > 0 ? users[0].id : null; - } - - async function testGetUser(id: string): Promise { - console.log(`\n🔍 Testing getUser (id: ${id.substring(0, 8)}...)...`); - const client = getAuthClient(); - - const result = await client.graphql({ - query: getUser, - variables: { id }, - }); - const user = (result as any).data.getUser; - console.log('✅ User details:', user); - } - - async function testListComments(): Promise { - console.log('\n💬 Testing listComments...'); - const client = getAuthClient(); - - const result = await client.graphql({ query: listComments }); - const comments = (result as any).data.listComments.items; - console.log(`✅ Found ${comments.length} comments:`); - comments - .slice(0, 5) - .forEach((c: any) => console.log(` - [${c.authorName}] "${c.content.substring(0, 50)}${c.content.length > 50 ? '...' : ''}"`)); - return comments.length > 0 ? comments[0].id : null; - } - - async function testCommentsByProductId(productId: string): Promise { - console.log(`\n💬 Testing commentsByProductId (productId: ${productId.substring(0, 8)}...)...`); - const client = getAuthClient(); - - const result = await client.graphql({ - query: commentsByProductId, - variables: { productId }, - }); - const comments = (result as any).data.commentsByProductId.items; - console.log(`✅ Found ${comments.length} comments for this product:`); - comments.forEach((c: any) => console.log(` - [${c.authorName}] "${c.content}"`)); - } - - async function testCheckLowStock(): Promise { - console.log('\n⚠️ Testing checkLowStock (Lambda function)...'); - const client = getAuthClient(); - - const result = await client.graphql({ query: checkLowStock }); - const data = (result as any).data.checkLowStock; - console.log('✅ Low stock check result:'); - console.log(` Message: ${data.message}`); - if (data.lowStockProducts && data.lowStockProducts.length > 0) { - console.log(` Low stock products (${data.lowStockProducts.length}):`); - data.lowStockProducts.forEach((p: any) => console.log(` - ${p.name}: ${p.stock} remaining`)); - } else { - console.log(' All products are well stocked!'); - } - } - - // ============================================================ - // PART 2: Product Mutation Tests - // ============================================================ - - async function testCreateProduct(): Promise { - console.log('\n🆕 Testing createProduct...'); - const client = getAuthClient(); - - const authUser = await getCurrentUser(); - const result = await client.graphql({ - query: createProduct, - variables: { - input: { - serialno: Math.floor(Math.random() * 10000), - engword: `Test Product ${Date.now()}`, - price: 99.99, - category: CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)], - description: 'This is a test product created by the test script', - stock: 50, - brand: 'TestBrand', - createdBy: authUser.userId, - updatedBy: authUser.userId, - }, - }, - }); - const product = (result as any).data.createProduct; - console.log('✅ Created product:', { - id: product.id, - name: product.engword, - serialno: product.serialno, - price: product.price, - category: product.category, - stock: product.stock, - }); - return product.id; - } - - async function testUpdateProduct(productId: string): Promise { - console.log(`\n✏️ Testing updateProduct (id: ${productId.substring(0, 8)}...)...`); - const client = getAuthClient(); - - const authUser = await getCurrentUser(); - const result = await client.graphql({ - query: updateProduct, - variables: { - input: { - id: productId, - engword: 'Updated Test Product', - description: 'This product was updated by the test script', - price: 149.99, - stock: 75, - updatedBy: authUser.userId, - }, - }, - }); - const product = (result as any).data.updateProduct; - console.log('✅ Updated product:', { - id: product.id, - name: product.engword, - price: product.price, - stock: product.stock, - }); - } - - async function testDeleteProduct(productId: string): Promise { - console.log(`\n🗑️ Testing deleteProduct (id: ${productId.substring(0, 8)}...)...`); - const client = getAuthClient(); - - const result = await client.graphql({ - query: deleteProduct, - variables: { input: { id: productId } }, - }); - const deleted = (result as any).data.deleteProduct; - console.log('✅ Deleted product:', deleted.engword); - } - - // ============================================================ - // PART 3: User Mutation Tests - // ============================================================ - - async function testCreateUser(): Promise { - console.log('\n🆕 Testing createUser...'); - const client = getAuthClient(); - - const testUserId = `test-user-${Date.now()}`; - const result = await client.graphql({ - query: createUser, - variables: { - input: { - id: testUserId, - email: `testuser${Date.now()}@example.com`, - name: `Test User ${Date.now()}`, - role: 'VIEWER', - }, - }, - }); - const user = (result as any).data.createUser; - console.log('✅ Created user:', { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - }); - return user.id; - } - - async function testUpdateUserRole(userId: string, newRole: UserRole): Promise { - console.log(`\n✏️ Testing updateUser role (id: ${userId.substring(0, 8)}..., newRole: ${newRole})...`); - const client = getAuthClient(); - - const result = await client.graphql({ - query: updateUser, - variables: { - input: { - id: userId, - role: newRole, - }, - }, - }); - const user = (result as any).data.updateUser; - console.log('✅ Updated user role:', { - id: user.id, - name: user.name, - role: user.role, - }); - } - - async function testDeleteUser(userId: string): Promise { - console.log(`\n🗑️ Testing deleteUser (id: ${userId.substring(0, 8)}...)...`); - const client = getAuthClient(); - - const result = await client.graphql({ - query: deleteUser, - variables: { input: { id: userId } }, - }); - const deleted = (result as any).data.deleteUser; - console.log('✅ Deleted user:', deleted.email); - } - - // ============================================================ - // PART 4: Comment Mutation Tests - // ============================================================ - - async function testCreateComment(productId: string): Promise { - console.log(`\n🆕 Testing createComment (productId: ${productId.substring(0, 8)}...)...`); - const client = getAuthClient(); - - const authUser = await getCurrentUser(); - const result = await client.graphql({ - query: createComment, - variables: { - input: { - productId, - authorId: authUser.userId, - authorName: authUser.signInDetails?.loginId || 'Test User', - content: `Test comment created at ${new Date().toISOString()}`, - }, - }, - }); - const comment = (result as any).data.createComment; - console.log('✅ Created comment:', { - id: comment.id, - productId: comment.productId, - authorName: comment.authorName, - content: comment.content, - }); - return comment.id; - } - - async function testUpdateComment(commentId: string): Promise { - console.log(`\n✏️ Testing updateComment (id: ${commentId.substring(0, 8)}...)...`); - const client = getAuthClient(); - - const result = await client.graphql({ - query: updateComment, - variables: { - input: { - id: commentId, - content: `Updated comment at ${new Date().toISOString()}`, - }, - }, - }); - const comment = (result as any).data.updateComment; - console.log('✅ Updated comment:', { - id: comment.id, - content: comment.content, - }); - } - - async function testDeleteComment(commentId: string): Promise { - console.log(`\n🗑️ Testing deleteComment (id: ${commentId.substring(0, 8)}...)...`); - const client = getAuthClient(); - - const result = await client.graphql({ - query: deleteComment, - variables: { input: { id: commentId } }, - }); - const deleted = (result as any).data.deleteComment; - console.log('✅ Deleted comment:', deleted.content?.substring(0, 30) + '...'); - } - - // ============================================================ - // PART 5: S3 Storage Operations Tests - // ============================================================ - - async function testUploadProductImage(productId?: string): Promise { - console.log('\n📤 Testing uploadData (S3 image upload)...'); - - // Try to use local image file, fallback to generated image - const localImagePath = './test-image.jpg'; - let imageBuffer: Buffer; - let contentType: string; - let fileExt: string; - - if (fs.existsSync(localImagePath)) { - imageBuffer = fs.readFileSync(localImagePath); - contentType = 'image/jpeg'; - fileExt = 'jpg'; - console.log(' Using local image file'); - } else { - // Fallback: create a simple test image (100x100 gray square PNG) - const testImageBase64 = - 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAA3klEQVR42u3QMQEAAAgDILV/51nBzwci0JlYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqz8WgGPGAGBPQqrHAAAAABJRU5ErkJggg=='; - imageBuffer = Buffer.from(testImageBase64, 'base64'); - contentType = 'image/png'; - fileExt = 'png'; - console.log(' Using generated test image'); - } - - const fileName = `test-image-${Date.now()}.${fileExt}`; - const s3Key = productId ? `products/${productId}_${Date.now()}-${fileName}` : `products/test_${Date.now()}-${fileName}`; - - console.log(` Uploading to: ${s3Key}`); - console.log(` File size: ${imageBuffer.length} bytes`); - - const result = await uploadData({ - key: s3Key, - data: imageBuffer, - options: { contentType }, - }).result; - - console.log('✅ Upload successful!'); - console.log(' Key:', result.key); - return result.key; - } - - async function testGetImageUrl(imageKey: string): Promise { - console.log(`\n🔗 Testing getUrl (S3 signed URL)...`); - console.log(` Image key: ${imageKey}`); - - const result = await getUrl({ - key: imageKey, - options: { expiresIn: 3600 }, - }); - console.log('✅ Got signed URL!'); - console.log(' URL:', result.url.toString().substring(0, 80) + '...'); - console.log(' Expires at:', result.expiresAt); - return result.url.toString(); - } - - async function testCreateProductWithImage(): Promise<{ productId: string | null; imageKey: string | null }> { - console.log('\n📦 Testing full product creation with image upload...'); - const client = getAuthClient(); - - const authUser = await getCurrentUser(); - - // Step 1: Create product - console.log(' Step 1: Creating product...'); - const createResult = await client.graphql({ - query: createProduct, - variables: { - input: { - serialno: Math.floor(Math.random() * 10000), - engword: `Product With Image ${Date.now()}`, - price: 199.99, - category: 'Electronics', - description: 'Test product with image upload', - stock: 25, - brand: 'TestBrand', - createdBy: authUser.userId, - updatedBy: authUser.userId, - }, - }, - }); - const product = (createResult as any).data.createProduct; - console.log(` ✅ Product created: ${product.id}`); - - // Step 2: Upload image - console.log(' Step 2: Uploading image...'); - const imageKey = await testUploadProductImage(product.id); - - if (imageKey) { - // Step 3: Update product with imageKey - console.log(' Step 3: Updating product with imageKey...'); - await client.graphql({ - query: updateProduct, - variables: { - input: { - id: product.id, - imageKey: imageKey, - updatedBy: authUser.userId, - }, - }, - }); - console.log(' ✅ Product updated with imageKey'); - } - - console.log('✅ Full product with image creation complete!'); - return { productId: product.id, imageKey }; - } - - // ============================================================ - // PART 6: Role-Based Access Control Tests - // ============================================================ - - async function testRoleBasedPermissions(): Promise { - console.log('\n🔒 Testing Role-Based Access Control...'); - const client = getAuthClient(); - const authUser = await getCurrentUser(); - - const userResult = await client.graphql({ - query: getUser, - variables: { id: authUser.userId }, - }); - const currentUser = (userResult as any).data.getUser; - - if (!currentUser) { - console.log(' ⚠️ User not found in database.'); - return; - } - - const role = currentUser.role; - console.log(` Current user role: ${role}`); - - // Define expected permissions based on role - const permissions = { - canCreate: role === 'ADMIN' || role === 'MANAGER', - canEdit: role === 'ADMIN' || role === 'MANAGER', - canDelete: role === 'ADMIN', - canManageUsers: role === 'ADMIN', - }; - - console.log(' Expected permissions:'); - console.log(` - Can Create Products: ${permissions.canCreate ? '✅' : '❌'}`); - console.log(` - Can Edit Products: ${permissions.canEdit ? '✅' : '❌'}`); - console.log(` - Can Delete Products: ${permissions.canDelete ? '✅' : '❌'}`); - console.log(` - Can Manage Users: ${permissions.canManageUsers ? '✅' : '❌'}`); - } - - // ============================================================ - // PART 7: Business Logic Tests - // ============================================================ - - async function testProductFiltering(): Promise { - console.log('\n🔍 Testing product filtering logic...'); - const client = getAuthClient(); - - const result = await client.graphql({ query: listProducts }); - const products = (result as any).data.listProducts.items; - - if (products.length === 0) { - console.log(' ⚠️ No products to filter'); - return; - } - - // Test search filter (by name) - const searchTerm = products[0].engword.substring(0, 3).toLowerCase(); - const searchFiltered = products.filter((p: any) => p.engword.toLowerCase().includes(searchTerm)); - console.log(` Search filter "${searchTerm}": ${searchFiltered.length} results`); - - // Test category filter - const categories = [...new Set(products.map((p: any) => p.category).filter(Boolean))]; - if (categories.length > 0) { - const categoryFiltered = products.filter((p: any) => p.category === categories[0]); - console.log(` Category filter "${categories[0]}": ${categoryFiltered.length} results`); - } - - // Test sorting - const sortedByName = [...products].sort((a: any, b: any) => a.engword.localeCompare(b.engword)); - const sortedByPrice = [...products].sort((a: any, b: any) => (a.price || 0) - (b.price || 0)); - const sortedByStock = [...products].sort((a: any, b: any) => (a.stock || 0) - (b.stock || 0)); - - console.log(' ✅ Sorting tests:'); - console.log(` By name: First="${sortedByName[0]?.engword}", Last="${sortedByName[sortedByName.length - 1]?.engword}"`); - console.log(` By price: Min=${sortedByPrice[0]?.price || 0}, Max=${sortedByPrice[sortedByPrice.length - 1]?.price || 0}`); - console.log(` By stock: Min=${sortedByStock[0]?.stock || 0}, Max=${sortedByStock[sortedByStock.length - 1]?.stock || 0}`); - } - - async function testLowStockReportGeneration(): Promise { - console.log('\n📊 Testing low stock report generation...'); - const client = getAuthClient(); - - const result = await client.graphql({ query: checkLowStock }); - const data = (result as any).data.checkLowStock; - - if (data?.lowStockProducts && data.lowStockProducts.length > 0) { - // Generate CSV content (simulating downloadStockReport) - const csvHeader = 'Product Name,Current Stock\n'; - const csvRows = data.lowStockProducts.map((p: any) => `"${p.name}",${p.stock}`).join('\n'); - const csvContent = csvHeader + csvRows; - - console.log(' ✅ CSV Report generated:'); - console.log(' ---'); - console.log(csvContent); - console.log(' ---'); - console.log(` Total low stock items: ${data.lowStockProducts.length}`); - } else { - console.log(' ✅ All products are well stocked - no report needed'); - } - } - - return { - testListProducts, - testGetProduct, - testListUsers, - testGetUser, - testListComments, - testCommentsByProductId, - testCheckLowStock, - testCreateProduct, - testUpdateProduct, - testDeleteProduct, - testCreateUser, - testUpdateUserRole, - testDeleteUser, - testCreateComment, - testUpdateComment, - testDeleteComment, - testUploadProductImage, - testGetImageUrl, - testCreateProductWithImage, - testRoleBasedPermissions, - testProductFiltering, - testLowStockReportGeneration, - }; -} - -// ============================================================ -// Shared Test Orchestration Functions -// ============================================================ - -export function createTestOrchestrator(testFunctions: ReturnType, runner: TestRunner) { - async function runQueryTests(): Promise<{ productId: string | null; userId: string | null }> { - console.log('\n' + '='.repeat(60)); - console.log('📖 PART 1: GraphQL Queries'); - console.log('='.repeat(60)); - - const productId = await runner.runTest('listProducts', testFunctions.testListProducts); - if (productId) await runner.runTest('getProduct', () => testFunctions.testGetProduct(productId)); - - const userId = await runner.runTest('listUsers', testFunctions.testListUsers); - if (userId) await runner.runTest('getUser', () => testFunctions.testGetUser(userId)); - - await runner.runTest('listComments', testFunctions.testListComments); - if (productId) await runner.runTest('commentsByProductId', () => testFunctions.testCommentsByProductId(productId)); - - await runner.runTest('checkLowStock', testFunctions.testCheckLowStock); - - return { productId, userId }; - } - - async function runProductMutationTests(): Promise { - console.log('\n' + '='.repeat(60)); - console.log('📦 PART 2: Product Mutations'); - console.log('='.repeat(60)); - - const productId = await runner.runTest('createProduct', testFunctions.testCreateProduct); - - if (productId) { - await runner.runTest('updateProduct', () => testFunctions.testUpdateProduct(productId)); - } - - return productId; - } - - async function runUserMutationTests(): Promise { - console.log('\n' + '='.repeat(60)); - console.log('👥 PART 3: User Mutations'); - console.log('='.repeat(60)); - - const userId = await runner.runTest('createUser', testFunctions.testCreateUser); - - if (userId) { - await runner.runTest('updateUserRole_MANAGER', () => testFunctions.testUpdateUserRole(userId, 'MANAGER')); - await runner.runTest('updateUserRole_ADMIN', () => testFunctions.testUpdateUserRole(userId, 'ADMIN')); - await runner.runTest('deleteUser', () => testFunctions.testDeleteUser(userId)); - } - } - - async function runCommentMutationTests(productId: string): Promise { - console.log('\n' + '='.repeat(60)); - console.log('💬 PART 4: Comment Mutations'); - console.log('='.repeat(60)); - - const commentId = await runner.runTest('createComment', () => testFunctions.testCreateComment(productId)); - - if (commentId) { - await runner.runTest('updateComment', () => testFunctions.testUpdateComment(commentId)); - await runner.runTest('deleteComment', () => testFunctions.testDeleteComment(commentId)); - } - } - - async function runStorageTests(): Promise { - console.log('\n' + '='.repeat(60)); - console.log('📸 PART 5: S3 Storage Operations'); - console.log('='.repeat(60)); - - const imageKey = await runner.runTest('uploadProductImage', testFunctions.testUploadProductImage); - - if (imageKey) { - await runner.runTest('getImageUrl', () => testFunctions.testGetImageUrl(imageKey)); - } - - // Test full product creation with image - const { productId, imageKey: newImageKey } = (await runner.runTest( - 'createProductWithImage', - testFunctions.testCreateProductWithImage, - )) || { - productId: null, - imageKey: null, - }; - - if (newImageKey) { - await runner.runTest('getImageUrl_newProduct', () => testFunctions.testGetImageUrl(newImageKey)); - } - - // Cleanup: delete the test product - if (productId) { - await runner.runTest('deleteProduct_cleanup', () => testFunctions.testDeleteProduct(productId)); - } - } - - async function runRBACTests(): Promise { - console.log('\n' + '='.repeat(60)); - console.log('🔒 PART 6: Role-Based Access Control Tests'); - console.log('='.repeat(60)); - - await runner.runTest('roleBasedPermissions', testFunctions.testRoleBasedPermissions); - } - - async function runBusinessLogicTests(): Promise { - console.log('\n' + '='.repeat(60)); - console.log('📊 PART 7: Business Logic Tests'); - console.log('='.repeat(60)); - - await runner.runTest('productFiltering', testFunctions.testProductFiltering); - await runner.runTest('lowStockReportGeneration', testFunctions.testLowStockReportGeneration); - } - - return { - runQueryTests, - runProductMutationTests, - runUserMutationTests, - runCommentMutationTests, - runStorageTests, - runRBACTests, - runBusinessLogicTests, - }; -} diff --git a/amplify-migration-apps/product-catalog/tests/api.test.ts b/amplify-migration-apps/product-catalog/tests/api.test.ts new file mode 100644 index 00000000000..b4ebf79ab64 --- /dev/null +++ b/amplify-migration-apps/product-catalog/tests/api.test.ts @@ -0,0 +1,376 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { generateClient } from 'aws-amplify/api'; +import { getCurrentUser, signIn, signOut } from 'aws-amplify/auth'; +import { + getProduct, listProducts, + getUser, listUsers, + getComment, listComments, + commentsByProductId, + checkLowStock, +} from '../src/graphql/queries'; +import { + createProduct, updateProduct, deleteProduct, + createUser, updateUser, deleteUser, + createComment, updateComment, deleteComment, +} from '../src/graphql/mutations'; +import { UserRole } from '../src/API'; +import { signUp, config } from './signup'; + +const guest = () => generateClient({ authMode: 'apiKey' }); +const iam = () => generateClient(); +const userPool = () => generateClient({ authMode: 'userPool' }); + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('guest', () => { + it('cannot list products', async () => { + await expect( + guest().graphql({ query: listProducts }), + ).rejects.toBeDefined(); + }); + + it('cannot list users', async () => { + await expect( + guest().graphql({ query: listUsers }), + ).rejects.toBeDefined(); + }); + + it('cannot list comments', async () => { + await expect( + guest().graphql({ query: listComments }), + ).rejects.toBeDefined(); + }); +}); + + +describe('iam', () => { + describe('Product', () => { + let productId: string; + + it('creates a product with correct fields', async () => { + const currentUser = await getCurrentUser(); + const input = { + serialno: Math.floor(Math.random() * 10000), + engword: `Test Product ${Date.now()}`, + price: 99.99, + category: 'Electronics', + description: 'Test product created by jest', + stock: 50, + brand: 'TestBrand', + createdBy: currentUser.userId, + updatedBy: currentUser.userId, + }; + + const result = await iam().graphql({ query: createProduct, variables: { input } }); + const product = (result as any).data.createProduct; + productId = product.id; + + expect(typeof product.id).toBe('string'); + expect(product.id.length).toBeGreaterThan(0); + expect(product.engword).toBe(input.engword); + expect(product.price).toBe(99.99); + expect(product.category).toBe('Electronics'); + expect(product.description).toBe('Test product created by jest'); + expect(product.stock).toBe(50); + expect(product.brand).toBe('TestBrand'); + expect(product.createdBy).toBe(currentUser.userId); + expect(product.updatedBy).toBe(currentUser.userId); + }); + + it('reads a product by id', async () => { + const result = await iam().graphql({ query: getProduct, variables: { id: productId } }); + const product = (result as any).data.getProduct; + + expect(product).not.toBeNull(); + expect(product.id).toBe(productId); + expect(product.category).toBe('Electronics'); + expect(product.stock).toBe(50); + }); + + it('updates a product and persists changes', async () => { + const currentUser = await getCurrentUser(); + await iam().graphql({ + query: updateProduct, + variables: { + input: { + id: productId, + engword: 'Updated Test Product', + price: 149.99, + stock: 75, + updatedBy: currentUser.userId, + }, + }, + }); + + const result = await iam().graphql({ query: getProduct, variables: { id: productId } }); + const product = (result as any).data.getProduct; + + expect(product.engword).toBe('Updated Test Product'); + expect(product.price).toBe(149.99); + expect(product.stock).toBe(75); + expect(product.updatedBy).toBe(currentUser.userId); + }); + + it('deletes a product', async () => { + await iam().graphql({ query: deleteProduct, variables: { input: { id: productId } } }); + + const result = await iam().graphql({ query: getProduct, variables: { id: productId } }); + expect((result as any).data.getProduct).toBeNull(); + }); + + it('lists products', async () => { + const currentUser = await getCurrentUser(); + const createResult = await iam().graphql({ + query: createProduct, + variables: { + input: { + serialno: Math.floor(Math.random() * 10000), + engword: `List Test ${Date.now()}`, + price: 10, + category: 'Test', + stock: 1, + createdBy: currentUser.userId, + updatedBy: currentUser.userId, + }, + }, + }); + const created = (createResult as any).data.createProduct; + + const result = await iam().graphql({ query: listProducts }); + const items = (result as any).data.listProducts.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((p: any) => p.id === created.id); + expect(found).toBeDefined(); + expect(found.engword).toBe(created.engword); + }); + }); + + describe('User', () => { + let userId: string; + + it('creates a user with correct fields', async () => { + userId = `test-user-${Date.now()}`; + const input = { + id: userId, + email: `testuser${Date.now()}@example.com`, + name: `Test User ${Date.now()}`, + role: UserRole.VIEWER, + }; + + const result = await iam().graphql({ query: createUser, variables: { input } }); + const user = (result as any).data.createUser; + + expect(user.id).toBe(userId); + expect(user.email).toBe(input.email); + expect(user.name).toBe(input.name); + expect(user.role).toBe('VIEWER'); + }); + + it('reads a user by id', async () => { + const result = await iam().graphql({ query: getUser, variables: { id: userId } }); + const user = (result as any).data.getUser; + + expect(user).not.toBeNull(); + expect(user.id).toBe(userId); + expect(user.role).toBe('VIEWER'); + }); + + it('updates user role from VIEWER to MANAGER', async () => { + await iam().graphql({ query: updateUser, variables: { input: { id: userId, role: UserRole.MANAGER } } }); + + const result = await iam().graphql({ query: getUser, variables: { id: userId } }); + const user = (result as any).data.getUser; + + expect(user.role).toBe('MANAGER'); + }); + + it('updates user role from MANAGER to ADMIN', async () => { + await iam().graphql({ query: updateUser, variables: { input: { id: userId, role: UserRole.ADMIN } } }); + + const result = await iam().graphql({ query: getUser, variables: { id: userId } }); + const user = (result as any).data.getUser; + + expect(user.role).toBe('ADMIN'); + }); + + it('deletes a user', async () => { + await iam().graphql({ query: deleteUser, variables: { input: { id: userId } } }); + + const result = await iam().graphql({ query: getUser, variables: { id: userId } }); + expect((result as any).data.getUser).toBeNull(); + }); + + it('lists users', async () => { + const listUserId = `list-user-${Date.now()}`; + await iam().graphql({ + query: createUser, + variables: { input: { id: listUserId, email: `list${Date.now()}@example.com`, name: 'List User', role: UserRole.VIEWER } }, + }); + + const result = await iam().graphql({ query: listUsers }); + const items = (result as any).data.listUsers.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((u: any) => u.id === listUserId); + expect(found).toBeDefined(); + }); + }); + + describe('Comment', () => { + let commentProductId: string; + let commentId: string; + + beforeAll(async () => { + const currentUser = await getCurrentUser(); + const result = await iam().graphql({ + query: createProduct, + variables: { + input: { + serialno: Math.floor(Math.random() * 10000), + engword: `Comment Test Product ${Date.now()}`, + price: 10, + category: 'Test', + stock: 5, + createdBy: currentUser.userId, + updatedBy: currentUser.userId, + }, + }, + }); + commentProductId = (result as any).data.createProduct.id; + }); + + it('creates a comment with correct fields', async () => { + const currentUser = await getCurrentUser(); + const content = `Test comment at ${new Date().toISOString()}`; + const input = { + productId: commentProductId, + authorId: currentUser.userId, + authorName: currentUser.signInDetails?.loginId || 'Test User', + content, + }; + + const result = await iam().graphql({ query: createComment, variables: { input } }); + const comment = (result as any).data.createComment; + commentId = comment.id; + + expect(typeof comment.id).toBe('string'); + expect(comment.id.length).toBeGreaterThan(0); + expect(comment.productId).toBe(commentProductId); + expect(comment.authorId).toBe(currentUser.userId); + expect(comment.content).toBe(content); + }); + + it('reads a comment by id', async () => { + const result = await iam().graphql({ query: getComment, variables: { id: commentId } }); + const comment = (result as any).data.getComment; + + expect(comment).not.toBeNull(); + expect(comment.id).toBe(commentId); + expect(comment.productId).toBe(commentProductId); + }); + + it('updates a comment and persists changes', async () => { + const updatedContent = `Updated comment at ${new Date().toISOString()}`; + await iam().graphql({ + query: updateComment, + variables: { input: { id: commentId, content: updatedContent } }, + }); + + const result = await iam().graphql({ query: getComment, variables: { id: commentId } }); + const comment = (result as any).data.getComment; + + expect(comment.content).toBe(updatedContent); + }); + + it('queries comments by productId', async () => { + const result = await iam().graphql({ + query: commentsByProductId, + variables: { productId: commentProductId }, + }); + const items = (result as any).data.commentsByProductId.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((c: any) => c.id === commentId); + expect(found).toBeDefined(); + expect(found.productId).toBe(commentProductId); + }); + + it('deletes a comment', async () => { + await iam().graphql({ query: deleteComment, variables: { input: { id: commentId } } }); + + const result = await iam().graphql({ query: getComment, variables: { id: commentId } }); + expect((result as any).data.getComment).toBeNull(); + }); + + it('lists comments', async () => { + const currentUser = await getCurrentUser(); + const createResult = await iam().graphql({ + query: createComment, + variables: { + input: { + productId: commentProductId, + authorId: currentUser.userId, + authorName: currentUser.signInDetails?.loginId || 'Test User', + content: `List comment ${Date.now()}`, + }, + }, + }); + const created = (createResult as any).data.createComment; + + const result = await iam().graphql({ query: listComments }); + const items = (result as any).data.listComments.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((c: any) => c.id === created.id); + expect(found).toBeDefined(); + }); + }); + + it('checkLowStock returns message and lowStockProducts', async () => { + const result = await iam().graphql({ query: checkLowStock }); + const data = (result as any).data.checkLowStock; + + expect(data).toBeDefined(); + expect(typeof data.message).toBe('string'); + expect(data.message.length).toBeGreaterThan(0); + expect(data.message).toContain('e2e-test-secret-value'); + expect(Array.isArray(data.lowStockProducts)).toBe(true); + }); +}); + +describe('userPool', () => { + it('can create and read own User record', async () => { + const currentUser = await getCurrentUser(); + const input = { + id: currentUser.userId, + email: `up-${Date.now()}@example.com`, + name: 'UserPool Test', + role: UserRole.VIEWER, + }; + + const result = await userPool().graphql({ query: createUser, variables: { input } }); + const user = (result as any).data.createUser; + expect(user.id).toBe(currentUser.userId); + + const getResult = await userPool().graphql({ query: getUser, variables: { id: currentUser.userId } }); + expect((getResult as any).data.getUser.name).toBe('UserPool Test'); + }); + + it('cannot list products', async () => { + await expect( + userPool().graphql({ query: listProducts }), + ).rejects.toBeDefined(); + }); +}); diff --git a/amplify-migration-apps/product-catalog/tests/jest.setup.ts b/amplify-migration-apps/product-catalog/tests/jest.setup.ts new file mode 100644 index 00000000000..bb0b4613b66 --- /dev/null +++ b/amplify-migration-apps/product-catalog/tests/jest.setup.ts @@ -0,0 +1 @@ +jest.retryTimes(3); diff --git a/amplify-migration-apps/product-catalog/tests/signup.ts b/amplify-migration-apps/product-catalog/tests/signup.ts new file mode 100644 index 00000000000..1c84498437d --- /dev/null +++ b/amplify-migration-apps/product-catalog/tests/signup.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Amplify } from 'aws-amplify'; +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, + AdminSetUserPasswordCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import * as fs from 'fs'; +import { randomBytes } from 'crypto'; + +import { webcrypto } from 'crypto'; +if (typeof globalThis.crypto === 'undefined') { + (globalThis as any).crypto = webcrypto; +} + +const CONFIG_PATH = process.env.APP_CONFIG_PATH; +if (!CONFIG_PATH) { + throw new Error('APP_CONFIG_PATH environment variable is required'); +} + +export const config = JSON.parse(fs.readFileSync(CONFIG_PATH, { encoding: 'utf-8' })); +Amplify.configure(config); + +export async function signUp(cfg: any): Promise<{ username: string; password: string }> { + const gen2Auth = (cfg as any)?.auth; + const userPoolId = cfg.aws_user_pools_id ?? gen2Auth?.user_pool_id; + const region = cfg.aws_cognito_region ?? gen2Auth?.aws_region; + + const uname = generateTestEmail(); + const pwd = generateTestPassword(); + + const cognitoClient = new CognitoIdentityProviderClient({ region }); + + await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: userPoolId, + Username: uname, + TemporaryPassword: pwd, + MessageAction: 'SUPPRESS', + UserAttributes: [ + { Name: 'email', Value: uname }, + { Name: 'email_verified', Value: 'true' }, + ], + })); + + await cognitoClient.send(new AdminSetUserPasswordCommand({ + UserPoolId: userPoolId, + Username: uname, + Password: pwd, + Permanent: true, + })); + + return { username: uname, password: pwd }; +} + +function generateTestPassword(): string { + return `Test${randomSuffix()}!Aa1`; +} + +function generateTestEmail(): string { + return `testuser-${randomSuffix()}@test.example.com`; +} + +function randomSuffix(): string { + return randomBytes(4).toString('hex'); +} diff --git a/amplify-migration-apps/product-catalog/tests/storage.test.ts b/amplify-migration-apps/product-catalog/tests/storage.test.ts new file mode 100644 index 00000000000..42db363eadb --- /dev/null +++ b/amplify-migration-apps/product-catalog/tests/storage.test.ts @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { generateClient } from 'aws-amplify/api'; +import { getCurrentUser, signIn, signOut } from 'aws-amplify/auth'; +import { uploadData, getUrl } from 'aws-amplify/storage'; +import { getProduct } from '../src/graphql/queries'; +import { createProduct } from '../src/graphql/mutations'; +import { signUp, config } from './signup'; + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('auth', () => { + it('uploads data with a key', async () => { + const testImageBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `test-image-${Date.now()}.png`; + + const result = await uploadData({ + key: fileName, + data: imageBuffer, + options: { contentType: 'image/png' }, + }).result; + + expect(typeof result.key).toBe('string'); + expect(result.key).toBe(fileName); + }); + + it('gets a signed URL for an uploaded key', async () => { + const testImageBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `test-url-${Date.now()}.png`; + + await uploadData({ + key: fileName, + data: imageBuffer, + options: { contentType: 'image/png' }, + }).result; + + const result = await getUrl({ + key: fileName, + options: { expiresIn: 3600 }, + }); + + expect(result.url).toBeDefined(); + const urlStr = result.url.toString(); + expect(urlStr.length).toBeGreaterThan(0); + expect(urlStr).toContain('http'); + }); + + it('S3 upload triggers imageUploadedAt update on the product', async () => { + const currentUser = await getCurrentUser(); + const auth = () => generateClient(); + + const createResult = await auth().graphql({ + query: createProduct, + variables: { + input: { + serialno: Math.floor(Math.random() * 10000), + engword: `S3 Trigger Test ${Date.now()}`, + price: 10, + category: 'Test', + stock: 1, + createdBy: currentUser.userId, + updatedBy: currentUser.userId, + }, + }, + }); + const product = (createResult as any).data.createProduct; + expect(product.imageUploadedAt).toBeNull(); + + const imageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ); + await uploadData({ + key: `images/${product.id}_test.png`, + data: imageBuffer, + options: { contentType: 'image/png' }, + }).result; + + // Poll until the S3 trigger updates imageUploadedAt (async) + let updatedProduct: any = null; + for (let attempt = 0; attempt < 15; attempt++) { + const getResult = await auth().graphql({ query: getProduct, variables: { id: product.id } }); + updatedProduct = (getResult as any).data.getProduct; + if (updatedProduct.imageUploadedAt) break; + await new Promise((r) => setTimeout(r, 2000)); + } + + expect(updatedProduct.imageUploadedAt).not.toBeNull(); + expect(typeof updatedProduct.imageUploadedAt).toBe('string'); + }, 45_000); +}); diff --git a/amplify-migration-apps/project-boards/README.md b/amplify-migration-apps/project-boards/README.md index 4ba6123d701..d7027de5769 100644 --- a/amplify-migration-apps/project-boards/README.md +++ b/amplify-migration-apps/project-boards/README.md @@ -214,25 +214,14 @@ git checkout -b gen2-main npx amplify gen2-migration generate ``` -**Edit in `./amplify/data/resource.ts`:** - -```diff -- branchName: "main" -+ branchName: "gen2-main" -``` - -**Edit in `./amplify/function/quotegenerator/index.js`:** - -```diff -- exports.handler = async (event) => { -+ export async function handler(event) { +```console +npm run post-generate ``` -**Edit in `./src/main.tsx`:** - -```diff -- import amplifyconfig from './amplifyconfiguration.json'; -+ import amplifyconfig from '../amplify_outputs.json'; +```console +rm -rf node_modules package-lock.json +npm install +npm install --package-lock-only ``` ```console diff --git a/amplify-migration-apps/project-boards/_snapshot.post.generate/package.json b/amplify-migration-apps/project-boards/_snapshot.post.generate/package.json index d38d71ff291..481fd3b4c00 100644 --- a/amplify-migration-apps/project-boards/_snapshot.post.generate/package.json +++ b/amplify-migration-apps/project-boards/_snapshot.post.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/project-boards", + "name": "@amplify-migration-apps/project-boards-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/project-boards/_snapshot.pre.generate/package.json b/amplify-migration-apps/project-boards/_snapshot.pre.generate/package.json index 422f461fd32..19c8717b90b 100644 --- a/amplify-migration-apps/project-boards/_snapshot.pre.generate/package.json +++ b/amplify-migration-apps/project-boards/_snapshot.pre.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/project-boards", + "name": "@amplify-migration-apps/project-boards-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/project-boards/backend/configure.sh b/amplify-migration-apps/project-boards/backend/configure.sh new file mode 100755 index 00000000000..247c8a57f27 --- /dev/null +++ b/amplify-migration-apps/project-boards/backend/configure.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -euxo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cp -f ${script_dir}/schema.graphql ${script_dir}/../amplify/backend/api/projectboards/schema.graphql +cp -f ${script_dir}/quotegenerator.js ${script_dir}/../amplify/backend/function/quotegenerator/src/index.js diff --git a/amplify-migration-apps/project-boards/quotegenerator.js b/amplify-migration-apps/project-boards/backend/quotegenerator.js similarity index 100% rename from amplify-migration-apps/project-boards/quotegenerator.js rename to amplify-migration-apps/project-boards/backend/quotegenerator.js diff --git a/amplify-migration-apps/project-boards/schema.graphql b/amplify-migration-apps/project-boards/backend/schema.graphql similarity index 100% rename from amplify-migration-apps/project-boards/schema.graphql rename to amplify-migration-apps/project-boards/backend/schema.graphql diff --git a/amplify-migration-apps/project-boards/configure.sh b/amplify-migration-apps/project-boards/configure.sh deleted file mode 100755 index 69de1891c08..00000000000 --- a/amplify-migration-apps/project-boards/configure.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -cp -f schema.graphql ./amplify/backend/api/projectboards/schema.graphql -cp -f quotegenerator.js ./amplify/backend/function/quotegenerator/src/index.js diff --git a/amplify-migration-apps/project-boards/gen-1-cleanup.sh b/amplify-migration-apps/project-boards/gen-1-cleanup.sh deleted file mode 100755 index 4dbe65cf025..00000000000 --- a/amplify-migration-apps/project-boards/gen-1-cleanup.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -rm -rf amplify/ -rm src/amplifyconfiguration.json -rm src/aws-exports.js - -exit 0 diff --git a/amplify-migration-apps/project-boards/gen1-test-script.ts b/amplify-migration-apps/project-boards/gen1-test-script.ts deleted file mode 100644 index 4144071dd1a..00000000000 --- a/amplify-migration-apps/project-boards/gen1-test-script.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Gen1 Test Script for Project Boards App - * - * This script tests all functionality for Amplify Gen1: - * 1. Public GraphQL Queries (no auth required) - * 2. Authenticated GraphQL Mutations (requires auth) - * 3. S3 Storage Operations (requires auth) - * - * Credentials are provisioned automatically via Cognito SignUp + AdminConfirmSignUp. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import amplifyconfig from './src/amplifyconfiguration.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Gen1 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. Public GraphQL Queries'); - console.log(' 2. Authenticated GraphQL Mutations'); - console.log(' 3. S3 Storage Operations'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - // Sign in from this module so the auth tokens are available to api/storage - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { runPublicQueryTests, runMutationTests, runStorageTests } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Public queries (no auth needed) - await runPublicQueryTests(); - - // Part 2: Mutations (already authenticated) - await runMutationTests(); - - // Part 3: Storage - await runStorageTests(); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/project-boards/gen2-test-script.ts b/amplify-migration-apps/project-boards/gen2-test-script.ts deleted file mode 100644 index 7478188c5ab..00000000000 --- a/amplify-migration-apps/project-boards/gen2-test-script.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Gen2 Test Script for Project Boards App - * - * This script tests all functionality for Amplify Gen2: - * 1. Public GraphQL Queries (no auth required) - * 2. Authenticated GraphQL Mutations (requires auth) - * 3. S3 Storage Operations (requires auth) - * - * Credentials are provisioned automatically via Cognito AdminCreateUser. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import amplifyconfig from './amplify_outputs.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify with Gen2 outputs -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Gen2 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. Public GraphQL Queries'); - console.log(' 2. Authenticated GraphQL Mutations'); - console.log(' 3. S3 Storage Operations'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { runPublicQueryTests, runMutationTests, runStorageTests } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Public queries (no auth needed) - await runPublicQueryTests(); - - // Part 2: Mutations (already authenticated) - await runMutationTests(); - - // Part 3: Storage - await runStorageTests(); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/project-boards/jest.config.js b/amplify-migration-apps/project-boards/jest.config.js new file mode 100644 index 00000000000..fb5a9b20fe0 --- /dev/null +++ b/amplify-migration-apps/project-boards/jest.config.js @@ -0,0 +1,23 @@ +/** @type {import('jest').Config} */ +export default { + testMatch: ['/tests/**/*.test.ts'], + modulePathIgnorePatterns: ['/_snapshot', '/amplify'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: { + target: 'ES2022', + module: 'CommonJS', + moduleResolution: 'node', + esModuleInterop: true, + allowJs: true, + noEmit: true, + strict: true, + skipLibCheck: true, + types: ['node', 'jest'], + }, + }], + }, + testTimeout: 30_000, + setupFilesAfterEnv: ['/tests/jest.setup.ts'], +}; diff --git a/amplify-migration-apps/project-boards/migration-config.json b/amplify-migration-apps/project-boards/migration-config.json deleted file mode 100644 index 54688b35a2a..00000000000 --- a/amplify-migration-apps/project-boards/migration-config.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "app": { - "name": "project-boards", - "description": "Project board app with authentication and file storage", - "framework": "react" - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["API_KEY"] - }, - "auth": { - "signInMethods": ["email"], - "socialProviders": [] - }, - "storage": { - "buckets": [ - { - "name": "images", - "access": ["auth", "guest"] - } - ] - }, - "function": { - "functions": [ - { - "name": "quotegenerator", - "runtime": "nodejs", - "template": "hello-world" - } - ] - }, - "hosting": { - "type": "amplify-console" - } - } -} diff --git a/amplify-migration-apps/project-boards/migration/post-generate.ts b/amplify-migration-apps/project-boards/migration/post-generate.ts new file mode 100644 index 00000000000..276265087cf --- /dev/null +++ b/amplify-migration-apps/project-boards/migration/post-generate.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env npx ts-node +/** + * Post-generate script for project-boards app. + * + * Applies manual edits required after `amplify gen2-migration generate`: + * 1. Update branchName in amplify/data/resource.ts to the value of AWS_BRANCH + * env var, or the current git branch if AWS_BRANCH is not set + * 2. Convert quotegenerator function from CommonJS to ESM + * 3. Update frontend import from amplifyconfiguration.json to amplify_outputs.json + * 4. Fix missing awsRegion in GraphQL API userPoolConfig + */ + +import { execSync } from 'child_process'; +import fs from 'fs/promises'; +import path from 'path'; + +function resolveTargetBranch(): string { + if (process.env.AWS_BRANCH) { + return process.env.AWS_BRANCH; + } + return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); +} + +async function updateBranchName(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); + + const content = await fs.readFile(resourcePath, 'utf-8'); + + const targetBranch = resolveTargetBranch(); + + const updated = content.replace( + /branchName:\s*['"]([^'"]+)['"]/, + `branchName: '${targetBranch}'`, + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function convertQuotegeneratorToESM(appPath: string): Promise { + // Gen2 migration puts functions in amplify/function/ (singular) + const handlerPath = path.join(appPath, 'amplify', 'function', 'quotegenerator', 'index.js'); + + const content = await fs.readFile(handlerPath, 'utf-8'); + + // Convert exports.handler = async (event) => { to export async function handler(event) { + let updated = content.replace( + /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + // Also handle module.exports pattern + updated = updated.replace( + /module\.exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + await fs.writeFile(handlerPath, updated, 'utf-8'); +} + +async function updateFrontendConfig(appPath: string): Promise { + const mainPath = path.join(appPath, 'src', 'main.tsx'); + + const content = await fs.readFile(mainPath, 'utf-8'); + + const updated = content.replace( + /from\s*["']\.\/amplifyconfiguration\.json["']/g, + "from '../amplify_outputs.json'", + ); + + await fs.writeFile(mainPath, updated, 'utf-8'); +} + +export async function postGenerate(appPath: string): Promise { + await updateBranchName(appPath); + await convertQuotegeneratorToESM(appPath); + await updateFrontendConfig(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postGenerate(appPath) +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/project-boards/migration/post-refactor.ts b/amplify-migration-apps/project-boards/migration/post-refactor.ts new file mode 100644 index 00000000000..39a65bbf1c9 --- /dev/null +++ b/amplify-migration-apps/project-boards/migration/post-refactor.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env npx ts-node +/** + * Post-refactor script for project-boards app. + * + * Applies manual edits required after `amplify gen2-migration refactor`: + * 1. Uncomment s3Bucket.bucketName in amplify/backend.ts to sync with deployed template + */ + +import fs from 'fs/promises'; +import path from 'path'; + +/** + * Uncomment the s3Bucket.bucketName line in backend.ts. + * + * The generate step produces a commented line like: + * // s3Bucket.bucketName = 'bucket-name-here'; + * + * After refactor, we need to uncomment it to sync with the deployed template. + */ +async function uncommentS3BucketName(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + const content = await fs.readFile(backendPath, 'utf-8'); + + // Match commented bucket name line: // s3Bucket.bucketName = '...'; + const updated = content.replace( + /\/\/\s*(s3Bucket\.bucketName\s*=\s*['"][^'"]+['"];?)/g, + '$1', + ); + + await fs.writeFile(backendPath, updated, 'utf-8'); +} + +export async function postRefactor(appPath: string): Promise { + await uncommentS3BucketName(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRefactor(appPath) +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/project-boards/package.json b/amplify-migration-apps/project-boards/package.json index ce7d25e2938..7e7a0a587e3 100644 --- a/amplify-migration-apps/project-boards/package.json +++ b/amplify-migration-apps/project-boards/package.json @@ -11,9 +11,18 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "configure": "./configure.sh", + "configure": "./backend/configure.sh", "sanitize": "tsx ../sanitize.ts", - "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit" + "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit", + "test:gen1": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-src/amplifyconfiguration.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:gen2": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-amplify_outputs.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:e2e": "cd ../../packages/amplify-gen2-migration-e2e-system && npx tsx src/cli.ts --app project-boards --profile ${AWS_PROFILE:-default}", + "pre-push": "true", + "post-generate": "npx tsx migration/post-generate.ts", + "post-refactor": "npx tsx migration/post-refactor.ts", + "post-sandbox": "true", + "pre-sandbox": "true", + "post-push": "true" }, "dependencies": { "@aws-amplify/ui-react": "^6.13.0", @@ -23,7 +32,9 @@ "react-dom": "^19.1.1" }, "devDependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.936.0", "@eslint/js": "^9.36.0", + "@types/jest": "^29.5.14", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", @@ -32,6 +43,8 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "jest": "^29.7.0", + "ts-jest": "^29.3.4", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" diff --git a/amplify-migration-apps/project-boards/post-generate.ts b/amplify-migration-apps/project-boards/post-generate.ts deleted file mode 100644 index bea0aab23ab..00000000000 --- a/amplify-migration-apps/project-boards/post-generate.ts +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env npx ts-node -/** - * Post-generate script for project-boards app. - * - * Applies manual edits required after `amplify gen2-migration generate`: - * 1. Update branchName in amplify/data/resource.ts to "sandbox" (Gen2 hardcodes - * sandbox deployments to look for branchName='sandbox' in the mappings) - * 2. Convert quotegenerator function from CommonJS to ESM - * 3. Update frontend import from amplifyconfiguration.json to amplify_outputs.json - * 4. Fix missing awsRegion in GraphQL API userPoolConfig - */ - -import fs from 'fs/promises'; -import path from 'path'; - -interface PostGenerateOptions { - appPath: string; - envName?: string; -} - -async function updateBranchName(appPath: string): Promise { - const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); - - console.log(`Updating branchName in ${resourcePath}...`); - - const content = await fs.readFile(resourcePath, 'utf-8'); - - // For sandbox deployments, Gen2 hardcodes the branch lookup to 'sandbox' - // See: https://github.com/aws-amplify/amplify-backend/blob/main/packages/backend-data/src/factory.ts - // The code does: isSandboxDeployment ? 'sandbox' : scope.node.tryGetContext(CDKContextKey.BACKEND_NAME) - // So we must use 'sandbox' as the branchName in migratedAmplifyGen1DynamoDbTableMappings - const targetBranch = 'sandbox'; - - // Debug: Show what we're looking for - const branchNameMatch = content.match(/branchName:\s*['"]([^'"]+)['"]/); - if (branchNameMatch) { - console.log(` Found branchName: '${branchNameMatch[1]}'`); - } else { - console.log(' WARNING: No branchName property found in migratedAmplifyGen1DynamoDbTableMappings'); - } - - // The generated code has branchName set to the env name (e.g., 'ippj') - // We need to change it to 'sandbox' for table reuse in sandbox deployments - const updated = content.replace( - /branchName:\s*['"]([^'"]+)['"]/, - `branchName: '${targetBranch}'`, - ); - - if (updated === content) { - console.log(' No branchName found to update, skipping'); - return; - } - - await fs.writeFile(resourcePath, updated, 'utf-8'); - console.log(` Updated branchName to "${targetBranch}"`); - - // Verify the update - const verifyContent = await fs.readFile(resourcePath, 'utf-8'); - const verifyMatch = verifyContent.match(/branchName:\s*['"]([^'"]+)['"]/); - if (verifyMatch) { - console.log(` Verified branchName is now: '${verifyMatch[1]}'`); - } -} - -async function convertQuotegeneratorToESM(appPath: string): Promise { - // Gen2 migration puts functions in amplify/function/ (singular) - const handlerPath = path.join(appPath, 'amplify', 'function', 'quotegenerator', 'index.js'); - - console.log(`Converting quotegenerator to ESM in ${handlerPath}...`); - - let content: string; - try { - content = await fs.readFile(handlerPath, 'utf-8'); - } catch { - console.log(' index.js not found, skipping'); - return; - } - - // Convert exports.handler = async (event) => { to export async function handler(event) { - let updated = content.replace( - /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, - 'export async function handler($1) {', - ); - - // Also handle module.exports pattern - updated = updated.replace( - /module\.exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, - 'export async function handler($1) {', - ); - - if (updated === content) { - console.log(' No CommonJS exports found, skipping'); - return; - } - - await fs.writeFile(handlerPath, updated, 'utf-8'); - console.log(' Converted to ESM syntax'); -} - -async function updateFrontendConfig(appPath: string): Promise { - const mainPath = path.join(appPath, 'src', 'main.tsx'); - - console.log(`Updating frontend config import in ${mainPath}...`); - - let content: string; - try { - content = await fs.readFile(mainPath, 'utf-8'); - } catch { - console.log(' main.tsx not found, skipping'); - return; - } - - const updated = content.replace( - /from\s*["']\.\/amplifyconfiguration\.json["']/g, - "from '../amplify_outputs.json'", - ); - - if (updated === content) { - console.log(' No amplifyconfiguration.json import found, skipping'); - return; - } - - await fs.writeFile(mainPath, updated, 'utf-8'); - console.log(' Updated import to amplify_outputs.json'); -} - -async function fixUserPoolRegionInGraphqlApi(appPath: string): Promise { - const backendPath = path.join(appPath, 'amplify', 'backend.ts'); - - console.log(`Fixing user pool region in GraphQL API config in ${backendPath}...`); - - let content: string; - try { - content = await fs.readFile(backendPath, 'utf-8'); - } catch { - console.log(' backend.ts not found, skipping'); - return; - } - - // The generated code sets additionalAuthenticationProviders with userPoolConfig - // but is missing the awsRegion property. We need to add it. - // Pattern: userPoolConfig: { userPoolId: backend.auth.resources.userPool.userPoolId, } - const updated = content.replace( - /userPoolConfig:\s*\{\s*userPoolId:\s*backend\.auth\.resources\.userPool\.userPoolId,?\s*\}/g, - `userPoolConfig: { - userPoolId: backend.auth.resources.userPool.userPoolId, - awsRegion: backend.auth.stack.region, - }`, - ); - - if (updated === content) { - console.log(' No userPoolConfig found to fix, skipping'); - return; - } - - await fs.writeFile(backendPath, updated, 'utf-8'); - console.log(' Added awsRegion to userPoolConfig'); -} - -export async function postGenerate(options: PostGenerateOptions): Promise { - const { appPath } = options; - - console.log(`Running post-generate for project-boards at ${appPath}`); - console.log(''); - - await updateBranchName(appPath); - await convertQuotegeneratorToESM(appPath); - await updateFrontendConfig(appPath); - await fixUserPoolRegionInGraphqlApi(appPath); - - console.log(''); - console.log('Post-generate completed'); -} - -// CLI entry point - use import.meta.url for ESM compatibility -const isMainModule = import.meta.url === `file://${process.argv[1]}`; -if (isMainModule) { - const appPath = process.argv[2] || process.cwd(); - const envName = process.argv[3] || 'main'; - - postGenerate({ appPath, envName }).catch((error) => { - console.error('Post-generate failed:', error); - process.exit(1); - }); -} diff --git a/amplify-migration-apps/project-boards/post-refactor.ts b/amplify-migration-apps/project-boards/post-refactor.ts deleted file mode 100644 index 253300cbc60..00000000000 --- a/amplify-migration-apps/project-boards/post-refactor.ts +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env npx ts-node -/** - * Post-refactor script for project-boards app. - * - * Applies manual edits required after `amplify gen2-migration refactor`: - * 1. Uncomment s3Bucket.bucketName in amplify/backend.ts to sync with deployed template - */ - -import fs from 'fs/promises'; -import path from 'path'; - -interface PostRefactorOptions { - appPath: string; - envName?: string; -} - -/** - * Uncomment the s3Bucket.bucketName line in backend.ts. - * - * The generate step produces a commented line like: - * // s3Bucket.bucketName = 'bucket-name-here'; - * - * After refactor, we need to uncomment it to sync with the deployed template. - */ -async function uncommentS3BucketName(appPath: string): Promise { - const backendPath = path.join(appPath, 'amplify', 'backend.ts'); - - console.log(`Uncommenting s3Bucket.bucketName in ${backendPath}...`); - - let content: string; - try { - content = await fs.readFile(backendPath, 'utf-8'); - } catch { - console.log(' backend.ts not found, skipping'); - return; - } - - // Match commented bucket name line: // s3Bucket.bucketName = '...'; - const updated = content.replace( - /\/\/\s*(s3Bucket\.bucketName\s*=\s*['"][^'"]+['"];?)/g, - '$1', - ); - - if (updated === content) { - console.log(' No commented s3Bucket.bucketName found, skipping'); - return; - } - - await fs.writeFile(backendPath, updated, 'utf-8'); - console.log(' Uncommented s3Bucket.bucketName'); -} - -export async function postRefactor(options: PostRefactorOptions): Promise { - const { appPath } = options; - - console.log(`Running post-refactor for project-boards at ${appPath}`); - console.log(''); - - await uncommentS3BucketName(appPath); - - console.log(''); - console.log('Post-refactor completed'); -} - -// CLI entry point -const isMainModule = import.meta.url === `file://${process.argv[1]}`; -if (isMainModule) { - const appPath = process.argv[2] || process.cwd(); - const envName = process.argv[3] || 'main'; - - postRefactor({ appPath, envName }).catch((error) => { - console.error('Post-refactor failed:', error); - process.exit(1); - }); -} diff --git a/amplify-migration-apps/project-boards/test-utils.ts b/amplify-migration-apps/project-boards/test-utils.ts deleted file mode 100644 index 50239215f52..00000000000 --- a/amplify-migration-apps/project-boards/test-utils.ts +++ /dev/null @@ -1,382 +0,0 @@ -// test-utils.ts - -import { generateClient } from 'aws-amplify/api'; -import { uploadData, getUrl, downloadData, getProperties } from 'aws-amplify/storage'; -import * as fs from 'fs'; -import { getProject, getTodo, listProjects, listTodos } from './src/graphql/queries'; -import { createProject, updateProject, deleteProject, createTodo, updateTodo, deleteTodo } from './src/graphql/mutations'; -import { ProjectStatus } from './src/API'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; - -// NOTE: Amplify.configure() must be called by the importing script (gen1 or gen2) -// before any test functions are invoked. The gen1 script configures with -// amplifyconfiguration.json, the gen2 script with amplify_outputs.json. - -// Custom query for getRandomQuote (not in generated files) -const getRandomQuote = /* GraphQL */ ` - query GetRandomQuote { - getRandomQuote { - message - quote - author - timestamp - totalQuotes - } - } -`; - -// ============================================================ -// Shared Test Functions Factory -// ============================================================ - -export function createTestFunctions() { - const publicClient = generateClient({ authMode: 'apiKey' }); - - // ============================================================ - // Public Query Test Functions - // ============================================================ - - async function testGetRandomQuote(): Promise { - console.log('\n📝 Testing getRandomQuote...'); - const result = await publicClient.graphql({ query: getRandomQuote }); - console.log('✅ Success:', (result as any).data.getRandomQuote); - } - - async function testListProjects(): Promise { - console.log('\n📋 Testing listProjects...'); - const result = await publicClient.graphql({ query: listProjects }); - const projects = (result as any).data.listProjects.items; - console.log(`✅ Found ${projects.length} projects:`); - projects.forEach((p: any) => console.log(` - [${p.id}] ${p.title} (${p.status})`)); - return projects.length > 0 ? projects[0].id : null; - } - - async function testListTodos(): Promise { - console.log('\n✅ Testing listTodos...'); - const result = await publicClient.graphql({ query: listTodos }); - const todos = (result as any).data.listTodos.items; - console.log(`✅ Found ${todos.length} todos:`); - todos.forEach((t: any) => console.log(` - [${t.id}] ${t.name}: ${t.description || '(no description)'}`)); - return todos.length > 0 ? todos[0].id : null; - } - - async function testGetProject(id: string): Promise { - console.log(`\n🔍 Testing getProject (id: ${id})...`); - const result = await publicClient.graphql({ - query: getProject, - variables: { id }, - }); - console.log('✅ Project:', (result as any).data.getProject); - } - - async function testGetTodo(id: string): Promise { - console.log(`\n🔍 Testing getTodo (id: ${id})...`); - const result = await publicClient.graphql({ - query: getTodo, - variables: { id }, - }); - console.log('✅ Todo:', (result as any).data.getTodo); - } - - // ============================================================ - // Mutation Test Functions - // ============================================================ - - async function testCreateProject(): Promise { - console.log('\n🆕 Testing createProject...'); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: createProject, - variables: { - input: { - title: `Test Project ${Date.now()}`, - status: ProjectStatus.ACTIVE, - description: 'This is a test project created by the test script', - color: '#007bff', - }, - }, - }); - - const project = (result as any).data.createProject; - console.log('✅ Created project:', { - id: project.id, - title: project.title, - status: project.status, - owner: project.owner, - }); - return project.id; - } - - async function testUpdateProject(projectId: string): Promise { - console.log(`\n✏️ Testing updateProject (id: ${projectId})...`); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: updateProject, - variables: { - input: { - id: projectId, - title: 'Updated Test Project', - description: 'This project was updated by the test script', - status: ProjectStatus.ON_HOLD, - color: '#28a745', - }, - }, - }); - - const project = (result as any).data.updateProject; - console.log('✅ Updated project:', { - id: project.id, - title: project.title, - status: project.status, - color: project.color, - }); - } - - async function testDeleteProject(projectId: string): Promise { - console.log(`\n🗑️ Testing deleteProject (id: ${projectId})...`); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: deleteProject, - variables: { input: { id: projectId } }, - }); - const deleted = (result as any).data.deleteProject; - console.log('✅ Deleted project:', deleted.title); - } - - async function testCreateTodo(projectId?: string, images?: string[]): Promise { - console.log('\n🆕 Testing createTodo...'); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: createTodo, - variables: { - input: { - name: `Test Todo ${Date.now()}`, - description: 'This is a test todo created by the test script', - projectID: projectId || null, - images: images || [], - }, - }, - }); - - const todo = (result as any).data.createTodo; - console.log('✅ Created todo:', { - id: todo.id, - name: todo.name, - projectID: todo.projectID || 'unassigned', - images: todo.images?.length || 0, - owner: todo.owner, - }); - return todo.id; - } - - async function testUpdateTodo(todoId: string, newProjectId?: string): Promise { - console.log(`\n✏️ Testing updateTodo (id: ${todoId})...`); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: updateTodo, - variables: { - input: { - id: todoId, - name: 'Updated Test Todo', - description: 'This todo was updated by the test script', - projectID: newProjectId || null, - }, - }, - }); - - const todo = (result as any).data.updateTodo; - console.log('✅ Updated todo:', { - id: todo.id, - name: todo.name, - projectID: todo.projectID || 'unassigned', - }); - } - - async function testDeleteTodo(todoId: string): Promise { - console.log(`\n🗑️ Testing deleteTodo (id: ${todoId})...`); - const authClient = generateClient({ authMode: 'userPool' }); - - const result = await authClient.graphql({ - query: deleteTodo, - variables: { input: { id: todoId } }, - }); - const deleted = (result as any).data.deleteTodo; - console.log('✅ Deleted todo:', deleted.name); - } - - // ============================================================ - // Storage Test Functions - // ============================================================ - - async function testUploadImage(): Promise { - console.log('\n📤 Testing uploadData (S3)...'); - - // Try to use local image file, fallback to generated image - const localImagePath = 'ADD_TEST_IMAGE_HERE'; - let imageBuffer: Buffer; - let contentType: string; - let fileExt: string; - - if (fs.existsSync(localImagePath)) { - imageBuffer = fs.readFileSync(localImagePath); - contentType = 'image/jpeg'; - fileExt = 'jpg'; - console.log(' Using local image file'); - } else { - // Fallback: create a simple test image (100x100 gray square) - const testImageBase64 = - 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAA3klEQVR42u3QMQEAAAgDILV/51nBzwci0JlYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqz8WgGPGAGBPQqrHAAAAABJRU5ErkJggg=='; - imageBuffer = Buffer.from(testImageBase64, 'base64'); - contentType = 'image/png'; - fileExt = 'png'; - console.log(' Using generated test image'); - } - - const fileName = `test-image-${Date.now()}.${fileExt}`; - const s3Path = `public/images/${fileName}`; - - console.log(` Uploading to: ${s3Path}`); - console.log(` File size: ${imageBuffer.length} bytes`); - - const result = await uploadData({ - path: s3Path, - data: imageBuffer, - options: { contentType }, - }).result; - - console.log('✅ Upload successful!'); - console.log(' Path:', result.path); - return result.path; - } - - async function testGetUrl(filePath: string): Promise { - console.log('\n🔗 Testing getUrl (S3)...'); - console.log(` File path: ${filePath}`); - - const result = await getUrl({ - path: filePath, - options: { expiresIn: 3600 }, - }); - - console.log('✅ Got signed URL!'); - console.log(' URL:', result.url.toString().substring(0, 100) + '...'); - console.log(' Expires at:', result.expiresAt); - return result.url.toString(); - } - - async function testGetProperties(filePath: string): Promise { - console.log('\n📋 Testing getProperties (S3)...'); - console.log(` File path: ${filePath}`); - - const properties = await getProperties({ path: filePath }); - - console.log('✅ Got file properties!'); - if ('contentType' in properties) console.log(' Content Type:', (properties as any).contentType); - if ('size' in properties) console.log(' Size:', (properties as any).size, 'bytes'); - if ('eTag' in properties) console.log(' ETag:', (properties as any).eTag); - if ('lastModified' in properties) console.log(' Last Modified:', (properties as any).lastModified); - } - - async function testDownloadData(filePath: string): Promise { - console.log('\n📥 Testing downloadData (S3)...'); - console.log(` File path: ${filePath}`); - - const downloadResult = await downloadData({ path: filePath }).result; - const blob = await downloadResult.body.blob(); - const arrayBuffer = await blob.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - console.log('✅ Download successful!'); - console.log(' Downloaded size:', buffer.length, 'bytes'); - console.log(' Content type:', blob.type); - - const localPath = `./downloaded-test-image-${Date.now()}.png`; - fs.writeFileSync(localPath, buffer); - console.log(' Saved to:', localPath); - } - - return { - testGetRandomQuote, - testListProjects, - testListTodos, - testGetProject, - testGetTodo, - testCreateProject, - testUpdateProject, - testDeleteProject, - testCreateTodo, - testUpdateTodo, - testDeleteTodo, - testUploadImage, - testGetUrl, - testGetProperties, - testDownloadData, - }; -} - -// ============================================================ -// Shared Test Orchestration Functions -// ============================================================ - -export function createTestOrchestrator(testFunctions: ReturnType, runner: TestRunner) { - async function runPublicQueryTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('📖 PART 1: Public GraphQL Queries (No Auth)'); - console.log('='.repeat(50)); - - await runner.runTest('getRandomQuote', testFunctions.testGetRandomQuote); - const projectId = await runner.runTest('listProjects', testFunctions.testListProjects); - const todoId = await runner.runTest('listTodos', testFunctions.testListTodos); - - if (projectId) await runner.runTest('getProject', () => testFunctions.testGetProject(projectId)); - if (todoId) await runner.runTest('getTodo', () => testFunctions.testGetTodo(todoId)); - } - - async function runMutationTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('✏️ PART 2: Authenticated GraphQL Mutations'); - console.log('='.repeat(50)); - - // Create project and todo - const projectId = await runner.runTest('createProject', testFunctions.testCreateProject); - const todoId = await runner.runTest('createTodo', () => testFunctions.testCreateTodo(projectId || undefined)); - - // Update project and todo - if (projectId) await runner.runTest('updateProject', () => testFunctions.testUpdateProject(projectId)); - if (todoId) await runner.runTest('updateTodo', () => testFunctions.testUpdateTodo(todoId, projectId || undefined)); - - // Cleanup: delete todo and project - if (todoId) await runner.runTest('deleteTodo', () => testFunctions.testDeleteTodo(todoId)); - if (projectId) await runner.runTest('deleteProject', () => testFunctions.testDeleteProject(projectId)); - } - - async function runStorageTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('📦 PART 3: S3 Storage Operations'); - console.log('='.repeat(50)); - - const uploadedPath = await runner.runTest('uploadImage', testFunctions.testUploadImage); - - if (uploadedPath) { - await runner.runTest('getUrl', () => testFunctions.testGetUrl(uploadedPath)); - await runner.runTest('getProperties', () => testFunctions.testGetProperties(uploadedPath)); - await runner.runTest('downloadData', () => testFunctions.testDownloadData(uploadedPath)); - - // Create a todo with the uploaded image - console.log('\n📝 Creating todo with uploaded image...'); - await runner.runTest('createTodoWithImage', () => testFunctions.testCreateTodo(undefined, [uploadedPath])); - console.log('🎉 Check your browser - the todo should appear with the image!'); - } - } - - return { - runPublicQueryTests, - runMutationTests, - runStorageTests, - }; -} diff --git a/amplify-migration-apps/project-boards/tests/api.test.ts b/amplify-migration-apps/project-boards/tests/api.test.ts new file mode 100644 index 00000000000..46b46f6b869 --- /dev/null +++ b/amplify-migration-apps/project-boards/tests/api.test.ts @@ -0,0 +1,332 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { generateClient } from 'aws-amplify/api'; +import { signIn, signOut } from 'aws-amplify/auth'; +import { uploadData } from 'aws-amplify/storage'; +import { getProject, getTodo, listProjects, listTodos } from '../src/graphql/queries'; +import { + createProject, updateProject, deleteProject, + createTodo, updateTodo, deleteTodo, +} from '../src/graphql/mutations'; +import { ProjectStatus } from '../src/API'; +import { signUp, config } from './signup'; + +const getRandomQuote = /* GraphQL */ ` + query GetRandomQuote { + getRandomQuote { + message + quote + author + timestamp + totalQuotes + } + } +`; + +const guest = () => generateClient({ authMode: 'apiKey' }); +const auth = () => generateClient({ authMode: 'userPool' }); + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('guest', () => { + it('getRandomQuote returns a quote with all expected fields', async () => { + const result = await guest().graphql({ query: getRandomQuote }); + const quote = (result as any).data.getRandomQuote; + + expect(quote).toBeDefined(); + expect(typeof quote.message).toBe('string'); + expect(quote.message.length).toBeGreaterThan(0); + expect(typeof quote.quote).toBe('string'); + expect(quote.quote.length).toBeGreaterThan(0); + expect(typeof quote.author).toBe('string'); + expect(typeof quote.timestamp).toBe('string'); + expect(typeof quote.totalQuotes).toBe('number'); + expect(quote.totalQuotes).toBeGreaterThan(0); + }); + + it('lists projects', async () => { + const result = await guest().graphql({ query: listProjects }); + const items = (result as any).data.listProjects.items; + + expect(Array.isArray(items)).toBe(true); + }); + + it('reads a project by id', async () => { + const listResult = await guest().graphql({ query: listProjects }); + const items = (listResult as any).data.listProjects.items; + if (items.length === 0) return; + + const result = await guest().graphql({ query: getProject, variables: { id: items[0].id } }); + const project = (result as any).data.getProject; + + expect(project).not.toBeNull(); + expect(project.id).toBe(items[0].id); + expect(project.title).toBeDefined(); + expect(project.status).toBeDefined(); + }); + + it('lists todos', async () => { + const result = await guest().graphql({ query: listTodos }); + const items = (result as any).data.listTodos.items; + + expect(Array.isArray(items)).toBe(true); + }); + + it('reads a todo by id', async () => { + const listResult = await guest().graphql({ query: listTodos }); + const items = (listResult as any).data.listTodos.items; + if (items.length === 0) return; + + const result = await guest().graphql({ query: getTodo, variables: { id: items[0].id } }); + const todo = (result as any).data.getTodo; + + expect(todo).not.toBeNull(); + expect(todo.id).toBe(items[0].id); + expect(todo.name).toBeDefined(); + }); + + it('cannot create a project', async () => { + await expect( + guest().graphql({ + query: createProject, + variables: { input: { title: `Unauthorized ${Date.now()}`, status: ProjectStatus.ACTIVE } }, + }), + ).rejects.toBeDefined(); + }); + + it('cannot create a todo', async () => { + await expect( + guest().graphql({ + query: createTodo, + variables: { input: { name: `Unauthorized ${Date.now()}` } }, + }), + ).rejects.toBeDefined(); + }); +}); + + +describe('auth', () => { + describe('Project', () => { + it('creates a project with correct fields', async () => { + const deadline = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + const input = { + title: `Test Project ${Date.now()}`, + status: ProjectStatus.ACTIVE, + description: 'Created by jest', + deadline, + color: '#007bff', + }; + + const result = await auth().graphql({ query: createProject, variables: { input } }); + const project = (result as any).data.createProject; + + expect(typeof project.id).toBe('string'); + expect(project.id.length).toBeGreaterThan(0); + expect(project.title).toBe(input.title); + expect(project.status).toBe(ProjectStatus.ACTIVE); + expect(project.description).toBe('Created by jest'); + expect(project.deadline).toBe(deadline); + expect(project.color).toBe('#007bff'); + expect(project.createdAt).toBeDefined(); + expect(project.updatedAt).toBeDefined(); + expect(project.owner).toBeDefined(); + }); + + it('reads a project by id', async () => { + const createResult = await auth().graphql({ + query: createProject, + variables: { input: { title: `Read Test ${Date.now()}`, status: ProjectStatus.COMPLETED, description: 'For read test' } }, + }); + const created = (createResult as any).data.createProject; + + const getResult = await auth().graphql({ query: getProject, variables: { id: created.id } }); + const fetched = (getResult as any).data.getProject; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.title).toBe(created.title); + expect(fetched.status).toBe(ProjectStatus.COMPLETED); + expect(fetched.description).toBe('For read test'); + }); + + it('updates a project and persists changes', async () => { + const createResult = await auth().graphql({ + query: createProject, + variables: { input: { title: `Update Test ${Date.now()}`, status: ProjectStatus.ACTIVE, color: '#000000' } }, + }); + const created = (createResult as any).data.createProject; + + await auth().graphql({ + query: updateProject, + variables: { input: { id: created.id, title: 'Updated Title', status: ProjectStatus.ON_HOLD, color: '#28a745', description: 'Now updated' } }, + }); + + const getResult = await auth().graphql({ query: getProject, variables: { id: created.id } }); + const fetched = (getResult as any).data.getProject; + + expect(fetched.title).toBe('Updated Title'); + expect(fetched.status).toBe(ProjectStatus.ON_HOLD); + expect(fetched.color).toBe('#28a745'); + expect(fetched.description).toBe('Now updated'); + }); + + it('deletes a project', async () => { + const createResult = await auth().graphql({ + query: createProject, + variables: { input: { title: `Delete Test ${Date.now()}`, status: ProjectStatus.ARCHIVED } }, + }); + const created = (createResult as any).data.createProject; + + await auth().graphql({ query: deleteProject, variables: { input: { id: created.id } } }); + + const getResult = await auth().graphql({ query: getProject, variables: { id: created.id } }); + expect((getResult as any).data.getProject).toBeNull(); + }); + + it('lists projects including a newly created one', async () => { + const title = `List Test ${Date.now()}`; + const createResult = await auth().graphql({ + query: createProject, + variables: { input: { title, status: ProjectStatus.ACTIVE } }, + }); + const created = (createResult as any).data.createProject; + + const listResult = await auth().graphql({ query: listProjects }); + const items = (listResult as any).data.listProjects.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((p: any) => p.id === created.id); + expect(found).toBeDefined(); + expect(found.title).toBe(title); + }); + }); + + describe('Todo', () => { + async function createParentProject(): Promise { + const result = await auth().graphql({ + query: createProject, + variables: { input: { title: `Todo Parent ${Date.now()}`, status: ProjectStatus.ACTIVE } }, + }); + return (result as any).data.createProject.id; + } + + it('creates a todo linked to a project', async () => { + const projectId = await createParentProject(); + const input = { name: `Test Todo ${Date.now()}`, description: 'Created by jest', projectID: projectId, images: [] as string[] }; + + const result = await auth().graphql({ query: createTodo, variables: { input } }); + const todo = (result as any).data.createTodo; + + expect(typeof todo.id).toBe('string'); + expect(todo.id.length).toBeGreaterThan(0); + expect(todo.name).toBe(input.name); + expect(todo.description).toBe('Created by jest'); + expect(todo.projectID).toBe(projectId); + expect(todo.images).toEqual([]); + expect(todo.createdAt).toBeDefined(); + expect(todo.owner).toBeDefined(); + }); + + it('reads a todo by id', async () => { + const projectId = await createParentProject(); + const createResult = await auth().graphql({ + query: createTodo, + variables: { input: { name: `Read Todo ${Date.now()}`, description: 'For read test', projectID: projectId } }, + }); + const created = (createResult as any).data.createTodo; + + const getResult = await auth().graphql({ query: getTodo, variables: { id: created.id } }); + const fetched = (getResult as any).data.getTodo; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(created.id); + expect(fetched.name).toBe(created.name); + expect(fetched.description).toBe('For read test'); + expect(fetched.projectID).toBe(projectId); + }); + + it('updates a todo and persists changes', async () => { + const projectId = await createParentProject(); + const createResult = await auth().graphql({ + query: createTodo, + variables: { input: { name: `Update Todo ${Date.now()}`, description: 'Original', projectID: projectId } }, + }); + const created = (createResult as any).data.createTodo; + + await auth().graphql({ + query: updateTodo, + variables: { input: { id: created.id, name: 'Updated Todo', description: 'Now updated', projectID: projectId } }, + }); + + const getResult = await auth().graphql({ query: getTodo, variables: { id: created.id } }); + const fetched = (getResult as any).data.getTodo; + + expect(fetched.name).toBe('Updated Todo'); + expect(fetched.description).toBe('Now updated'); + }); + + it('deletes a todo', async () => { + const projectId = await createParentProject(); + const createResult = await auth().graphql({ + query: createTodo, + variables: { input: { name: `Delete Todo ${Date.now()}`, projectID: projectId } }, + }); + const created = (createResult as any).data.createTodo; + + await auth().graphql({ query: deleteTodo, variables: { input: { id: created.id } } }); + + const getResult = await auth().graphql({ query: getTodo, variables: { id: created.id } }); + expect((getResult as any).data.getTodo).toBeNull(); + }); + + it('lists todos including a newly created one', async () => { + const projectId = await createParentProject(); + const name = `List Todo ${Date.now()}`; + const createResult = await auth().graphql({ + query: createTodo, + variables: { input: { name, projectID: projectId } }, + }); + const created = (createResult as any).data.createTodo; + + const listResult = await auth().graphql({ query: listTodos }); + const items = (listResult as any).data.listTodos.items; + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(1); + const found = items.find((t: any) => t.id === created.id); + expect(found).toBeDefined(); + expect(found.name).toBe(name); + }); + + it('creates a todo with an S3 image path', async () => { + const imageBuffer = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64'); + const fileName = `todo-image-${Date.now()}.png`; + const s3Path = `public/images/${fileName}`; + + const uploadResult = await uploadData({ + path: s3Path, + data: imageBuffer, + options: { contentType: 'image/png' }, + }).result; + + const result = await auth().graphql({ + query: createTodo, + variables: { input: { name: `Todo with image ${Date.now()}`, description: 'Has an image', images: [uploadResult.path] } }, + }); + const todo = (result as any).data.createTodo; + + expect(todo.images).toBeDefined(); + expect(Array.isArray(todo.images)).toBe(true); + expect(todo.images.length).toBe(1); + expect(todo.images[0]).toBe(uploadResult.path); + expect(todo.images[0]).toContain('public/images/'); + }); + }); +}); diff --git a/amplify-migration-apps/project-boards/tests/jest.setup.ts b/amplify-migration-apps/project-boards/tests/jest.setup.ts new file mode 100644 index 00000000000..bb0b4613b66 --- /dev/null +++ b/amplify-migration-apps/project-boards/tests/jest.setup.ts @@ -0,0 +1 @@ +jest.retryTimes(3); diff --git a/amplify-migration-apps/project-boards/tests/signup.ts b/amplify-migration-apps/project-boards/tests/signup.ts new file mode 100644 index 00000000000..1c84498437d --- /dev/null +++ b/amplify-migration-apps/project-boards/tests/signup.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Amplify } from 'aws-amplify'; +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, + AdminSetUserPasswordCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import * as fs from 'fs'; +import { randomBytes } from 'crypto'; + +import { webcrypto } from 'crypto'; +if (typeof globalThis.crypto === 'undefined') { + (globalThis as any).crypto = webcrypto; +} + +const CONFIG_PATH = process.env.APP_CONFIG_PATH; +if (!CONFIG_PATH) { + throw new Error('APP_CONFIG_PATH environment variable is required'); +} + +export const config = JSON.parse(fs.readFileSync(CONFIG_PATH, { encoding: 'utf-8' })); +Amplify.configure(config); + +export async function signUp(cfg: any): Promise<{ username: string; password: string }> { + const gen2Auth = (cfg as any)?.auth; + const userPoolId = cfg.aws_user_pools_id ?? gen2Auth?.user_pool_id; + const region = cfg.aws_cognito_region ?? gen2Auth?.aws_region; + + const uname = generateTestEmail(); + const pwd = generateTestPassword(); + + const cognitoClient = new CognitoIdentityProviderClient({ region }); + + await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: userPoolId, + Username: uname, + TemporaryPassword: pwd, + MessageAction: 'SUPPRESS', + UserAttributes: [ + { Name: 'email', Value: uname }, + { Name: 'email_verified', Value: 'true' }, + ], + })); + + await cognitoClient.send(new AdminSetUserPasswordCommand({ + UserPoolId: userPoolId, + Username: uname, + Password: pwd, + Permanent: true, + })); + + return { username: uname, password: pwd }; +} + +function generateTestPassword(): string { + return `Test${randomSuffix()}!Aa1`; +} + +function generateTestEmail(): string { + return `testuser-${randomSuffix()}@test.example.com`; +} + +function randomSuffix(): string { + return randomBytes(4).toString('hex'); +} diff --git a/amplify-migration-apps/project-boards/tests/storage.test.ts b/amplify-migration-apps/project-boards/tests/storage.test.ts new file mode 100644 index 00000000000..301752e2da9 --- /dev/null +++ b/amplify-migration-apps/project-boards/tests/storage.test.ts @@ -0,0 +1,99 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { signIn, signOut } from 'aws-amplify/auth'; +import { uploadData, getUrl, downloadData, getProperties, remove } from 'aws-amplify/storage'; +import { signUp, config } from './signup'; + +const testImageBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAA3klEQVR42u3QMQEAAAgDILV/51nBzwci0JlYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqxYsWLFihUrVqz8WgGPGAGBPQqrHAAAAABJRU5ErkJggg=='; + +function uploadTestImage(): Promise<{ path: string }> { + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const fileName = `test-image-${Date.now()}.png`; + const s3Path = `public/images/${fileName}`; + return uploadData({ path: s3Path, data: imageBuffer, options: { contentType: 'image/png' } }).result; +} + +beforeAll(async () => { + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('guest', () => { + it('can read a public file', async () => { + const { path } = await uploadTestImage(); + await signOut(); + + const downloadResult = await downloadData({ path }).result; + const blob = await downloadResult.body.blob(); + const buffer = Buffer.from(await blob.arrayBuffer()); + + expect(buffer.length).toBeGreaterThan(0); + + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); + }); + + it('cannot upload a file', async () => { + await signOut(); + + const imageBuffer = Buffer.from(testImageBase64, 'base64'); + const s3Path = `public/images/unauthorized-${Date.now()}.png`; + + await expect( + uploadData({ path: s3Path, data: imageBuffer, options: { contentType: 'image/png' } }).result, + ).rejects.toBeDefined(); + + const creds = await signUp(config); + await signIn({ username: creds.username, password: creds.password }); + }); +}); + +describe('auth', () => { + it('uploads a file', async () => { + const result = await uploadTestImage(); + + expect(result.path).toBeDefined(); + expect(typeof result.path).toBe('string'); + expect(result.path).toContain('public/images/'); + }); + + it('gets a signed URL', async () => { + const { path } = await uploadTestImage(); + const result = await getUrl({ path, options: { expiresIn: 3600 } }); + + expect(result.url).toBeDefined(); + expect(result.url.toString()).toContain('http'); + expect(result.expiresAt).toBeDefined(); + }); + + it('gets file properties', async () => { + const { path } = await uploadTestImage(); + const properties = await getProperties({ path }); + + expect(properties).toBeDefined(); + expect((properties as any).contentType).toBeDefined(); + expect((properties as any).size).toBeGreaterThan(0); + }); + + it('downloads a file', async () => { + const { path } = await uploadTestImage(); + const downloadResult = await downloadData({ path }).result; + const blob = await downloadResult.body.blob(); + const arrayBuffer = await blob.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + expect(buffer.length).toBeGreaterThan(0); + expect(blob.type).toBeDefined(); + }); + + it('deletes a file', async () => { + const { path } = await uploadTestImage(); + await remove({ path }); + + await expect(getProperties({ path })).rejects.toBeDefined(); + }); +}); diff --git a/amplify-migration-apps/store-locator/README.md b/amplify-migration-apps/store-locator/README.md index e2d944da3b1..3974989381e 100644 --- a/amplify-migration-apps/store-locator/README.md +++ b/amplify-migration-apps/store-locator/README.md @@ -218,11 +218,14 @@ git checkout -b gen2-main npx amplify gen2-migration generate ``` -**Edit in `./src/main.tsx`:** +```console +npm run post-generate +``` -```diff -- import amplifyconfig from './amplifyconfiguration.json'; -+ import amplifyconfig from '../amplify_outputs.json'; +```console +rm -rf node_modules package-lock.json +npm install +npm install --package-lock-only ``` ```console @@ -231,75 +234,6 @@ git commit -m "feat: migrate to gen2" git push origin gen2-main ``` -**Edit in `./amplify/auth/storelocatorcff4360fPostConfirmation/resource.ts`:** - -```diff -- memoryMB: 128, -- runtime: 22 -+ memoryMB: 512, -+ runtime: 22, -+ resourceGroupName: 'auth' -``` - -**Edit in `./amplify/auth/storelocatorcff4360fPostConfirmation/index.js`:** - -The Gen1 dynamic `require(`./${name}`)` doesn't work with esbuild bundling in the Amplify build pipeline (`Module not found in bundle: ./add-to-group`). Replace with a static import: - -```diff -- const moduleNames = process.env.MODULES.split(','); -- /** -- * The array of imported modules. -- */ -- const modules = moduleNames.map((name) => require(`./${name}`)); -+ import * as addToGroup from './add-to-group'; -+ -+ const modules = [addToGroup]; -``` - -```diff -- exports.handler = async (event, context) => { -+ export async function handler(event, context) { -``` - -**Edit in `./amplify/auth/storelocatorcff4360fPostConfirmation/add-to-group.js`:** - -```diff -- const { -- CognitoIdentityProviderClient, -- AdminAddUserToGroupCommand, -- GetGroupCommand, -- CreateGroupCommand, -- } = require('@aws-sdk/client-cognito-identity-provider'); -+ import { -+ CognitoIdentityProviderClient, -+ AdminAddUserToGroupCommand, -+ GetGroupCommand, -+ CreateGroupCommand, -+ } from '@aws-sdk/client-cognito-identity-provider'; -``` - -```diff -- exports.handler = async (event) => { -+ export const handler = async (event) => { -``` - -**Edit in `./amplify/auth/resource.ts`:** - -```diff -- triggers: { -- postConfirmation: storelocatorcff4360fPostConfirmation -- }, -+ triggers: { -+ postConfirmation: storelocatorcff4360fPostConfirmation -+ }, -+ access: (allow) => [ -+ allow.resource(storelocatorcff4360fPostConfirmation).to([ -+ "addUserToGroup", -+ "manageGroups", -+ ]), -+ ], -``` - Now connect the `gen2-main` branch to the hosting service: ![](./images/add-gen2-main-branch.png) diff --git a/amplify-migration-apps/store-locator/_snapshot.post.generate/package.json b/amplify-migration-apps/store-locator/_snapshot.post.generate/package.json index 60c4583620e..4f787b3b7ad 100644 --- a/amplify-migration-apps/store-locator/_snapshot.post.generate/package.json +++ b/amplify-migration-apps/store-locator/_snapshot.post.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/store-locator", + "name": "@amplify-migration-apps/store-locator-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/store-locator/_snapshot.pre.generate/package.json b/amplify-migration-apps/store-locator/_snapshot.pre.generate/package.json index 9abc47b5c30..be5af20d862 100644 --- a/amplify-migration-apps/store-locator/_snapshot.pre.generate/package.json +++ b/amplify-migration-apps/store-locator/_snapshot.pre.generate/package.json @@ -1,5 +1,5 @@ { - "name": "@amplify-migration-apps/store-locator", + "name": "@amplify-migration-apps/store-locator-snapshot", "private": true, "version": "0.0.0", "type": "module", diff --git a/amplify-migration-apps/store-locator/gen1-test-script.ts b/amplify-migration-apps/store-locator/gen1-test-script.ts deleted file mode 100644 index 0c9e021da11..00000000000 --- a/amplify-migration-apps/store-locator/gen1-test-script.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Gen1 Test Script for Store Locator App - * - * This script tests geo functionality: - * 1. Location Search (Place Index) -- searchByText, searchByCoordinates - * 2. Geofence Operations -- save, get, list, delete - * - * Credentials are provisioned automatically via Cognito - * AdminCreateUser + AdminSetUserPassword. Since AdminCreateUser - * does not trigger the PostConfirmation Lambda, the script - * manually adds the user to the storeLocatorAdmin group to - * grant geofence CRUD permissions. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import { - CognitoIdentityProviderClient, - AdminAddUserToGroupCommand, -} from '@aws-sdk/client-cognito-identity-provider'; -import amplifyconfig from './src/amplifyconfiguration.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Store Locator Gen1 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. Location Search (Place Index)'); - console.log(' 2. Geofence Operations (CRUD)'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const { signinValue, testUser } = await provisionTestUser(amplifyconfig); - - // AdminCreateUser does not fire the PostConfirmation trigger, so - // manually add the user to storeLocatorAdmin for geofence permissions. - try { - const cognitoClient = new CognitoIdentityProviderClient({ - region: (amplifyconfig as any).aws_cognito_region, - }); - await cognitoClient.send( - new AdminAddUserToGroupCommand({ - UserPoolId: (amplifyconfig as any).aws_user_pools_id, - Username: signinValue, - GroupName: 'storeLocatorAdmin', - }), - ); - console.log('✅ Added user to storeLocatorAdmin group'); - } catch (error: any) { - console.error('❌ Failed to add user to group:', error.message || error); - process.exit(1); - } - - // Sign in from this module so the auth tokens are available to Geo - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { runSearchTests, runGeofenceTests } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Location Search - await runSearchTests(); - - // Part 2: Geofence Operations - await runGeofenceTests(); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/store-locator/gen2-test-script.ts b/amplify-migration-apps/store-locator/gen2-test-script.ts deleted file mode 100644 index 5f5fb82facd..00000000000 --- a/amplify-migration-apps/store-locator/gen2-test-script.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Gen2 Test Script for Store Locator App - * - * This script tests geo functionality against the Gen2 backend: - * 1. Location Search (Place Index) -- searchByText, searchByCoordinates - * 2. Geofence Operations -- save, get, list, delete - * - * Credentials are provisioned automatically via Cognito - * AdminCreateUser + AdminSetUserPassword. Since AdminCreateUser - * does not trigger the PostConfirmation Lambda, the script - * manually adds the user to the storeLocatorAdmin group to - * grant geofence CRUD permissions. - * - * IMPORTANT: Place your Gen2 amplify_outputs.json in src/ before running. - */ - -// Polyfill crypto for Node.js environment (required for Amplify Auth) -import { webcrypto } from 'crypto'; -if (typeof globalThis.crypto === 'undefined') { - (globalThis as any).crypto = webcrypto; -} - -import { Amplify } from 'aws-amplify'; -import { signIn, signOut, getCurrentUser } from 'aws-amplify/auth'; -import { - CognitoIdentityProviderClient, - AdminAddUserToGroupCommand, -} from '@aws-sdk/client-cognito-identity-provider'; -import amplifyconfig from './src/amplify_outputs.json'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import { provisionTestUser } from '../_test-common/signup'; -import { createTestFunctions, createTestOrchestrator } from './test-utils'; - -// Configure Amplify with Gen2 configuration (overrides the Gen1 config in test-utils) -Amplify.configure(amplifyconfig); - -// ============================================================ -// Main Test Execution -// ============================================================ - -async function runAllTests(): Promise { - console.log('🚀 Starting Store Locator Gen2 Test Script\n'); - console.log('This script tests:'); - console.log(' 1. Location Search (Place Index)'); - console.log(' 2. Geofence Operations (CRUD)'); - - // Provision user via admin APIs, then sign in here so tokens stay in this module's Amplify scope - const gen2Auth = (amplifyconfig as any).auth; - const gen1Compat = { - aws_user_pools_id: gen2Auth.user_pool_id, - aws_user_pools_web_client_id: gen2Auth.user_pool_client_id, - aws_cognito_region: gen2Auth.aws_region, - aws_cognito_username_attributes: gen2Auth.username_attributes?.map((a: string) => a.toUpperCase()) ?? [], - aws_cognito_signup_attributes: gen2Auth.standard_required_attributes?.map((a: string) => a.toUpperCase()) ?? [], - }; - const { signinValue, testUser } = await provisionTestUser(gen1Compat); - - // AdminCreateUser does not fire the PostConfirmation trigger, so - // manually add the user to storeLocatorAdmin for geofence permissions. - try { - const cognitoClient = new CognitoIdentityProviderClient({ - region: gen2Auth.aws_region, - }); - await cognitoClient.send( - new AdminAddUserToGroupCommand({ - UserPoolId: gen2Auth.user_pool_id, - Username: signinValue, - GroupName: 'storeLocatorAdmin', - }), - ); - console.log('✅ Added user to storeLocatorAdmin group'); - } catch (error: any) { - console.error('❌ Failed to add user to group:', error.message || error); - process.exit(1); - } - - // Sign in from this module so the auth tokens are available to Geo - try { - await signIn({ username: signinValue, password: testUser.password }); - const currentUser = await getCurrentUser(); - console.log(`✅ Signed in as: ${currentUser.username}`); - } catch (error: any) { - console.error('❌ SignIn failed:', error.message || error); - process.exit(1); - } - - const runner = new TestRunner(); - const testFunctions = createTestFunctions(); - const { runSearchTests, runGeofenceTests } = createTestOrchestrator(testFunctions, runner); - - // Part 1: Location Search - await runSearchTests(); - - // Part 2: Geofence Operations - await runGeofenceTests(); - - // Sign out - try { - await signOut(); - console.log('✅ Signed out successfully'); - } catch (error: any) { - console.error('❌ Sign out error:', error.message || error); - } - - // Print summary and exit with appropriate code - runner.printSummary(); -} - -// Run all tests -void runAllTests(); diff --git a/amplify-migration-apps/store-locator/jest.config.js b/amplify-migration-apps/store-locator/jest.config.js new file mode 100644 index 00000000000..fb5a9b20fe0 --- /dev/null +++ b/amplify-migration-apps/store-locator/jest.config.js @@ -0,0 +1,23 @@ +/** @type {import('jest').Config} */ +export default { + testMatch: ['/tests/**/*.test.ts'], + modulePathIgnorePatterns: ['/_snapshot', '/amplify'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: { + target: 'ES2022', + module: 'CommonJS', + moduleResolution: 'node', + esModuleInterop: true, + allowJs: true, + noEmit: true, + strict: true, + skipLibCheck: true, + types: ['node', 'jest'], + }, + }], + }, + testTimeout: 30_000, + setupFilesAfterEnv: ['/tests/jest.setup.ts'], +}; diff --git a/amplify-migration-apps/store-locator/migration/config.json b/amplify-migration-apps/store-locator/migration/config.json new file mode 100644 index 00000000000..95d2cc9118d --- /dev/null +++ b/amplify-migration-apps/store-locator/migration/config.json @@ -0,0 +1,3 @@ +{ + "refactor": { "skipValidations": true } +} diff --git a/amplify-migration-apps/store-locator/migration/post-generate.ts b/amplify-migration-apps/store-locator/migration/post-generate.ts new file mode 100644 index 00000000000..c678267dc07 --- /dev/null +++ b/amplify-migration-apps/store-locator/migration/post-generate.ts @@ -0,0 +1,133 @@ +#!/usr/bin/env npx ts-node +/** + * Post-generate script for store-locator app. + * + * Applies manual edits required after `amplify gen2-migration generate`: + * 1. Update frontend import from amplifyconfiguration.json to amplify_outputs.json + * 2. Convert PostConfirmation trigger index.js from CommonJS to ESM + * 3. Convert PostConfirmation trigger add-to-group.js from CommonJS to ESM + * 4. Add auth resource access for the PostConfirmation trigger + * 5. Update PostConfirmation resource.ts (memoryMB, resourceGroupName) + */ + +import fs from 'fs/promises'; +import fsSync from 'fs'; +import path from 'path'; + +function findPostConfirmationDir(appPath: string): string { + const authDir = path.join(appPath, 'amplify', 'auth'); + const entries = fsSync.readdirSync(authDir); + const match = entries.find((e) => e.includes('PostConfirmation')); + if (!match) { + throw new Error('Could not find PostConfirmation directory under amplify/auth/'); + } + return match; +} + +async function updateFrontendConfig(appPath: string): Promise { + const mainPath = path.join(appPath, 'src', 'main.tsx'); + + const content = await fs.readFile(mainPath, 'utf-8'); + + const updated = content.replace( + /from\s*["']\.\/amplifyconfiguration\.json["']/g, + "from '../amplify_outputs.json'", + ); + + await fs.writeFile(mainPath, updated, 'utf-8'); +} + +async function convertIndexToESM(appPath: string, dirName: string): Promise { + const indexPath = path.join(appPath, 'amplify', 'auth', dirName, 'index.js'); + + const content = await fs.readFile(indexPath, 'utf-8'); + + // Replace the dynamic require with a static import and direct array + let updated = content.replace( + /const moduleNames = process\.env\.MODULES\.split\(','\);\n\/\*\*\n \* The array of imported modules\.\n \*\/\nconst modules = moduleNames\.map\(\(name\) => require\(`\.\/\$\{name\}`\)\);/, + "import * as addToGroup from './add-to-group';\n\nconst modules = [addToGroup];", + ); + + // Convert exports.handler to export async function handler + updated = updated.replace( + /exports\.handler\s*=\s*async\s*\((\w+),\s*(\w+)\)\s*=>\s*\{/g, + 'export async function handler($1, $2) {', + ); + + await fs.writeFile(indexPath, updated, 'utf-8'); +} + +async function convertAddToGroupToESM(appPath: string, dirName: string): Promise { + const filePath = path.join(appPath, 'amplify', 'auth', dirName, 'add-to-group.js'); + + const content = await fs.readFile(filePath, 'utf-8'); + + // Convert require to import + let updated = content.replace( + /const \{\n\s*CognitoIdentityProviderClient,\n\s*AdminAddUserToGroupCommand,\n\s*GetGroupCommand,\n\s*CreateGroupCommand,\n\} = require\('@aws-sdk\/client-cognito-identity-provider'\);/, + "import {\n CognitoIdentityProviderClient,\n AdminAddUserToGroupCommand,\n GetGroupCommand,\n CreateGroupCommand,\n} from '@aws-sdk/client-cognito-identity-provider';", + ); + + // Convert exports.handler to export const handler + updated = updated.replace( + /exports\.handler\s*=\s*async\s*\((\w+)\)\s*=>\s*\{/g, + 'export const handler = async ($1) => {', + ); + + await fs.writeFile(filePath, updated, 'utf-8'); +} + +async function addAuthResourceAccess(appPath: string, dirName: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'auth', 'resource.ts'); + + const content = await fs.readFile(resourcePath, 'utf-8'); + + // Find the variable name from the import statement + const importMatch = content.match(/import\s*\{\s*(\w+)\s*\}\s*from\s*['"]\.\//); + const fnName = importMatch ? importMatch[1] : dirName; + + // Add access block after the triggers block + const updated = content.replace( + /(triggers:\s*\{[^}]*\},?)/, + `$1\n access: (allow) => [\n allow.resource(${fnName}).to([\n "addUserToGroup",\n "manageGroups",\n ]),\n ],`, + ); + + await fs.writeFile(resourcePath, updated, 'utf-8'); +} + +async function updatePostConfirmationResource(appPath: string, dirName: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'auth', dirName, 'resource.ts'); + let content = await fs.readFile(resourcePath, 'utf-8'); + + // Update memoryMB from 128 to 512 + content = content.replace(/memoryMB:\s*128/, 'memoryMB: 512'); + + // Add resourceGroupName if not present + if (!content.includes('resourceGroupName')) { + content = content.replace( + /(runtime:\s*\d+),?/, + "$1,\n resourceGroupName: 'auth',", + ); + } + + await fs.writeFile(resourcePath, content, 'utf-8'); +} + +export async function postGenerate(appPath: string): Promise { + const dirName = findPostConfirmationDir(appPath); + await updateFrontendConfig(appPath); + await convertIndexToESM(appPath, dirName); + await convertAddToGroupToESM(appPath, dirName); + await addAuthResourceAccess(appPath, dirName); + await updatePostConfirmationResource(appPath, dirName); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postGenerate(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/store-locator/package.json b/amplify-migration-apps/store-locator/package.json index 9abc47b5c30..4e80af2accd 100644 --- a/amplify-migration-apps/store-locator/package.json +++ b/amplify-migration-apps/store-locator/package.json @@ -12,13 +12,22 @@ "lint": "eslint .", "preview": "vite preview", "sanitize": "tsx ../sanitize.ts", - "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit" + "typecheck": "cd _snapshot.post.generate/amplify && npx tsc --noEmit", + "test:gen1": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-src/amplifyconfiguration.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:gen2": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-amplify_outputs.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", + "test:e2e": "cd ../../packages/amplify-gen2-migration-e2e-system && npx tsx src/cli.ts --app store-locator --profile ${AWS_PROFILE:-default}", + "pre-push": "true", + "post-generate": "npx tsx migration/post-generate.ts", + "post-refactor": "true", + "post-sandbox": "true", + "pre-sandbox": "true", + "post-push": "true" }, "dependencies": { - "@aws-sdk/client-cognito-identity-provider": "^3.1016.0", "@aws-amplify/geo": "^3.0.92", "@aws-amplify/ui-react": "^6.13.2", "@aws-amplify/ui-react-geo": "^2.2.13", + "@aws-sdk/client-cognito-identity-provider": "^3.1016.0", "aws-amplify": "^6.16.0", "maplibre-gl": "^2.4.0", "maplibre-gl-js-amplify": "^4.0.2", @@ -26,7 +35,9 @@ "react-dom": "^19.2.0" }, "devDependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.936.0", "@eslint/js": "^9.39.1", + "@types/jest": "^29.5.14", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -35,6 +46,8 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jest": "^29.7.0", + "ts-jest": "^29.3.4", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" diff --git a/amplify-migration-apps/store-locator/test-utils.ts b/amplify-migration-apps/store-locator/test-utils.ts deleted file mode 100644 index ba44baf5668..00000000000 --- a/amplify-migration-apps/store-locator/test-utils.ts +++ /dev/null @@ -1,175 +0,0 @@ -// test-utils.ts -/** - * Shared test utilities for Store Locator Gen1 and Gen2 test scripts. - */ - -import { Amplify } from 'aws-amplify'; -import { Geo } from '@aws-amplify/geo'; -import { TestRunner } from '../_test-common/test-apps-test-utils'; -import amplifyconfig from './src/amplifyconfiguration.json'; - -// Configure Amplify in this module to ensure geo singletons see the config -Amplify.configure(amplifyconfig); - -// Midtown Manhattan store coordinates from App.tsx -const MIDTOWN_COORDINATES: [number, number] = [-73.9857, 40.7484]; - -// Counter-clockwise rectangle around Midtown Manhattan (~0.01 deg offset) -const TEST_GEOFENCE_POLYGON: [number, number][] = [ - [-73.995, 40.745], - [-73.975, 40.745], - [-73.975, 40.755], - [-73.995, 40.755], - [-73.995, 40.745], -]; - -// ============================================================ -// Shared Test Functions Factory -// ============================================================ - -export function createTestFunctions() { - let testGeofenceId = `test-geofence-${Date.now()}`; - - // ============================================================ - // Location Search Test Functions (Place Index) - // ============================================================ - - async function testSearchByText(): Promise { - console.log('\n🔍 Testing Geo.searchByText...'); - const results = await Geo.searchByText('New York', { - maxResults: 5, - }); - if (!results || results.length === 0) { - throw new Error('searchByText returned no results'); - } - console.log(`✅ Found ${results.length} results:`); - results.forEach((r: any) => { - const label = r.label || '(no label)'; - const point = r.geometry?.point; - console.log(` - ${label}${point ? ` [${point[0].toFixed(4)}, ${point[1].toFixed(4)}]` : ''}`); - }); - } - - async function testSearchByCoordinates(): Promise { - console.log('\n📍 Testing Geo.searchByCoordinates...'); - console.log(` Coordinates: [${MIDTOWN_COORDINATES[0]}, ${MIDTOWN_COORDINATES[1]}]`); - const result = await Geo.searchByCoordinates(MIDTOWN_COORDINATES); - if (!result) { - throw new Error('searchByCoordinates returned no result'); - } - const label = (result as any).label || '(no label)'; - console.log(`✅ Reverse geocode result: ${label}`); - } - - // ============================================================ - // Geofence Test Functions (Geofence Collection) - // ============================================================ - - async function testSaveGeofences(): Promise { - console.log('\n📐 Testing Geo.saveGeofences...'); - console.log(` Geofence ID: ${testGeofenceId}`); - const result = await Geo.saveGeofences([ - { - geofenceId: testGeofenceId, - geometry: { - polygon: [TEST_GEOFENCE_POLYGON], - }, - }, - ]); - const successes = (result as any).successes || []; - const errors = (result as any).errors || []; - if (errors.length > 0) { - throw new Error(`saveGeofences had errors: ${JSON.stringify(errors)}`); - } - if (successes.length === 0) { - throw new Error('saveGeofences returned no successes'); - } - console.log('✅ Geofence saved:', { - geofenceId: successes[0].geofenceId, - createTime: successes[0].createTime, - }); - return testGeofenceId; - } - - async function testGetGeofence(): Promise { - console.log(`\n🔎 Testing Geo.getGeofence (id: ${testGeofenceId})...`); - const geofence = await Geo.getGeofence(testGeofenceId); - if (!geofence) { - throw new Error('getGeofence returned no result'); - } - const gf = geofence as any; - console.log('✅ Geofence retrieved:', { - geofenceId: gf.geofenceId, - createTime: gf.createTime, - updateTime: gf.updateTime, - vertices: gf.geometry?.polygon?.[0]?.length || 0, - }); - } - - async function testListGeofences(): Promise { - console.log('\n📋 Testing Geo.listGeofences...'); - const result = await Geo.listGeofences(); - const entries = (result as any).entries || []; - console.log(`✅ Found ${entries.length} geofence(s):`); - entries.forEach((g: any) => { - console.log(` - ${g.geofenceId} (created: ${g.createTime})`); - }); - const found = entries.some((g: any) => g.geofenceId === testGeofenceId); - if (!found) { - throw new Error(`Test geofence ${testGeofenceId} not found in list`); - } - console.log(` ✅ Test geofence ${testGeofenceId} found in list`); - } - - async function testDeleteGeofences(): Promise { - console.log(`\n🗑️ Testing Geo.deleteGeofences (id: ${testGeofenceId})...`); - const result = await Geo.deleteGeofences([testGeofenceId]); - const errors = (result as any).errors || []; - if (errors.length > 0) { - throw new Error(`deleteGeofences had errors: ${JSON.stringify(errors)}`); - } - console.log('✅ Geofence deleted successfully'); - } - - return { - testSearchByText, - testSearchByCoordinates, - testSaveGeofences, - testGetGeofence, - testListGeofences, - testDeleteGeofences, - }; -} - -// ============================================================ -// Shared Test Orchestration Functions -// ============================================================ - -export function createTestOrchestrator(testFunctions: ReturnType, runner: TestRunner) { - async function runSearchTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('🔍 PART 1: Location Search (Place Index)'); - console.log('='.repeat(50)); - - await runner.runTest('searchByText', testFunctions.testSearchByText); - await runner.runTest('searchByCoordinates', testFunctions.testSearchByCoordinates); - } - - async function runGeofenceTests(): Promise { - console.log('\n' + '='.repeat(50)); - console.log('📐 PART 2: Geofence Operations'); - console.log('='.repeat(50)); - - const geofenceId = await runner.runTest('saveGeofences', testFunctions.testSaveGeofences); - if (geofenceId) { - await runner.runTest('getGeofence', testFunctions.testGetGeofence); - await runner.runTest('listGeofences', testFunctions.testListGeofences); - await runner.runTest('deleteGeofences', testFunctions.testDeleteGeofences); - } - } - - return { - runSearchTests, - runGeofenceTests, - }; -} diff --git a/amplify-migration-apps/store-locator/tests/geo.test.ts b/amplify-migration-apps/store-locator/tests/geo.test.ts new file mode 100644 index 00000000000..6f01ef8ab9b --- /dev/null +++ b/amplify-migration-apps/store-locator/tests/geo.test.ts @@ -0,0 +1,128 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Geo } from '@aws-amplify/geo'; +import { signIn, signOut } from 'aws-amplify/auth'; +import { signUp, addToAdminGroup, config } from './signup'; + +const MIDTOWN_COORDINATES: [number, number] = [-73.9857, 40.7484]; +const TEST_GEOFENCE_POLYGON: [number, number][] = [ + [-73.995, 40.745], + [-73.975, 40.745], + [-73.975, 40.755], + [-73.995, 40.755], + [-73.995, 40.745], +]; + +let username: string; +let password: string; + +beforeAll(async () => { + const creds = await signUp(config); + username = creds.username; + password = creds.password; + + await addToAdminGroup(username, config); + await signIn({ username, password }); +}, 60_000); + +afterAll(async () => { + await signOut(); +}); + +describe('guest', () => { + it('searches by text as guest', async () => { + await signOut(); + + const results = await Geo.searchByText('New York', { maxResults: 5 }); + + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBeGreaterThan(0); + + await signIn({ username, password }); + }); + + it('reverse geocodes coordinates as guest', async () => { + await signOut(); + + const result = await Geo.searchByCoordinates(MIDTOWN_COORDINATES); + + expect(result).not.toBeNull(); + expect(typeof (result as any).label).toBe('string'); + + await signIn({ username, password }); + }); + + it('cannot save geofences as guest', async () => { + await signOut(); + + try { + const result = await Geo.saveGeofences([ + { + geofenceId: `guest-geofence-${Date.now()}`, + geometry: { polygon: [TEST_GEOFENCE_POLYGON] }, + }, + ]); + const errors = (result as any).errors || []; + expect(errors.length).toBeGreaterThan(0); + } catch { + expect(true).toBe(true); + } + + await signIn({ username, password }); + }); +}); + +describe('auth', () => { + const geofenceId = `test-geofence-${Date.now()}`; + + it('searches by text', async () => { + const results = await Geo.searchByText('New York', { maxResults: 5 }); + + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBeGreaterThan(0); + }); + + it('reverse geocodes coordinates', async () => { + const result = await Geo.searchByCoordinates(MIDTOWN_COORDINATES); + + expect(result).not.toBeNull(); + expect((result as any).label).toBeDefined(); + expect(typeof (result as any).label).toBe('string'); + }); + + it('saves a geofence with no errors', async () => { + const result = await Geo.saveGeofences([ + { + geofenceId, + geometry: { polygon: [TEST_GEOFENCE_POLYGON] }, + }, + ]); + const successes = (result as any).successes || []; + const errors = (result as any).errors || []; + + expect(errors.length).toBe(0); + expect(successes.length).toBeGreaterThan(0); + }); + + it('gets the saved geofence by id', async () => { + const geofence = await Geo.getGeofence(geofenceId); + + expect(geofence).toBeDefined(); + expect((geofence as any).geofenceId).toBe(geofenceId); + }); + + it('lists geofences including the saved one', async () => { + const result = await Geo.listGeofences(); + const entries = (result as any).entries || []; + + expect(Array.isArray(entries)).toBe(true); + const found = entries.some((g: any) => g.geofenceId === geofenceId); + expect(found).toBe(true); + }); + + it('deletes the geofence with no errors', async () => { + const result = await Geo.deleteGeofences([geofenceId]); + const errors = (result as any).errors || []; + + expect(errors.length).toBe(0); + }); +}); diff --git a/amplify-migration-apps/store-locator/tests/jest.setup.ts b/amplify-migration-apps/store-locator/tests/jest.setup.ts new file mode 100644 index 00000000000..bb0b4613b66 --- /dev/null +++ b/amplify-migration-apps/store-locator/tests/jest.setup.ts @@ -0,0 +1 @@ +jest.retryTimes(3); diff --git a/amplify-migration-apps/store-locator/tests/signup.ts b/amplify-migration-apps/store-locator/tests/signup.ts new file mode 100644 index 00000000000..5b08cf5e199 --- /dev/null +++ b/amplify-migration-apps/store-locator/tests/signup.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Amplify } from 'aws-amplify'; +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, + AdminSetUserPasswordCommand, + AdminAddUserToGroupCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import * as fs from 'fs'; +import { randomBytes } from 'crypto'; + +import { webcrypto } from 'crypto'; +if (typeof globalThis.crypto === 'undefined') { + (globalThis as any).crypto = webcrypto; +} + +const CONFIG_PATH = process.env.APP_CONFIG_PATH; +if (!CONFIG_PATH) { + throw new Error('APP_CONFIG_PATH environment variable is required'); +} + +export const config = JSON.parse(fs.readFileSync(CONFIG_PATH, { encoding: 'utf-8' })); +Amplify.configure(config); + +export async function signUp(cfg: any): Promise<{ username: string; password: string }> { + const gen2Auth = (cfg as any)?.auth; + const userPoolId = cfg.aws_user_pools_id ?? gen2Auth?.user_pool_id; + const region = cfg.aws_cognito_region ?? gen2Auth?.aws_region; + + const uname = generateTestEmail(); + const pwd = generateTestPassword(); + + const cognitoClient = new CognitoIdentityProviderClient({ region }); + + await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: userPoolId, + Username: uname, + TemporaryPassword: pwd, + MessageAction: 'SUPPRESS', + UserAttributes: [ + { Name: 'email', Value: uname }, + { Name: 'email_verified', Value: 'true' }, + ], + })); + + await cognitoClient.send(new AdminSetUserPasswordCommand({ + UserPoolId: userPoolId, + Username: uname, + Password: pwd, + Permanent: true, + })); + + return { username: uname, password: pwd }; +} + +export async function addToAdminGroup(username: string, cfg: any): Promise { + const gen2Auth = (cfg as any)?.auth; + const userPoolId = cfg.aws_user_pools_id ?? gen2Auth?.user_pool_id; + const region = cfg.aws_cognito_region ?? gen2Auth?.aws_region; + + const cognitoClient = new CognitoIdentityProviderClient({ region }); + await cognitoClient.send(new AdminAddUserToGroupCommand({ + UserPoolId: userPoolId, + Username: username, + GroupName: 'storeLocatorAdmin', + })); +} + +function generateTestPassword(): string { + return `Test${randomSuffix()}!Aa1`; +} + +function generateTestEmail(): string { + return `testuser-${randomSuffix()}@test.example.com`; +} + +function randomSuffix(): string { + return randomBytes(4).toString('hex'); +} diff --git a/package.json b/package.json index 5a639d01bb8..cc9ca81ee1f 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,16 @@ }, "workspaces": [ "packages/*", - "amplify-migration-apps/**/_snapshot.post.generate" + "amplify-migration-apps/**/_snapshot.post.generate", + "amplify-migration-apps/backend-only", + "amplify-migration-apps/discussions", + "amplify-migration-apps/fitness-tracker", + "amplify-migration-apps/imported-resources", + "amplify-migration-apps/media-vault", + "amplify-migration-apps/mood-board", + "amplify-migration-apps/product-catalog", + "amplify-migration-apps/project-boards", + "amplify-migration-apps/store-locator" ], "devDependencies": { "@babel/cli": "^7.27.0", diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/cloudformation.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/cloudformation.ts index 3f953fe3322..c667fc2e937 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/cloudformation.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/cloudformation.ts @@ -36,6 +36,7 @@ export class CloudFormationMock { private readonly _stackNameForResource: Map = new Map(); private readonly _templateForStack: Map = new Map(); + private readonly _pendingChangeSets: Map = new Map(); constructor(private readonly app: MigrationApp) { this.mock = mockClient(cloudformation.CloudFormationClient); @@ -53,6 +54,7 @@ export class CloudFormationMock { this.mockDescribeStackRefactor(); this.mockCreateChangeSet(); this.mockDescribeChangeSet(); + this.mockExecuteChangeSet(); this.mockUpdateStack(); } @@ -173,21 +175,42 @@ export class CloudFormationMock { } private mockCreateChangeSet() { - this.mock.on(cloudformation.CreateChangeSetCommand).callsFake( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async (_input: cloudformation.CreateChangeSetCommandInput): Promise => { + this.mock + .on(cloudformation.CreateChangeSetCommand) + .callsFake(async (input: cloudformation.CreateChangeSetCommandInput): Promise => { + if (input.StackName) { + this._pendingChangeSets.set(input.StackName, input); + } return { $metadata: {} }; - }, - ); + }); } private mockDescribeChangeSet() { - this.mock.on(cloudformation.DescribeChangeSetCommand).callsFake( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async (_input: cloudformation.DescribeChangeSetCommandInput): Promise => { - return { Status: 'CREATE_COMPLETE', Changes: [], $metadata: {} }; - }, - ); + this.mock + .on(cloudformation.DescribeChangeSetCommand) + .callsFake(async (input: cloudformation.DescribeChangeSetCommandInput): Promise => { + const pending = this._pendingChangeSets.get(input.StackName!); + return { + Status: 'CREATE_COMPLETE', + StackName: input.StackName, + Parameters: pending?.Parameters as cloudformation.Parameter[], + Changes: [], + $metadata: {}, + }; + }); + } + + private mockExecuteChangeSet() { + this.mock + .on(cloudformation.ExecuteChangeSetCommand) + .callsFake(async (input: cloudformation.ExecuteChangeSetCommandInput): Promise => { + const pending = this._pendingChangeSets.get(input.StackName!); + if (pending?.TemplateBody) { + this._setTemplate(input.StackName!, pending.TemplateBody); + } + this._pendingChangeSets.delete(input.StackName!); + return { $metadata: {} }; + }); } private mockUpdateStack() { diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/__snapshots__/assessment.test.ts.snap b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/__snapshots__/assessment.test.ts.snap index 0d417e1d681..a1dd1bf1fff 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/__snapshots__/assessment.test.ts.snap +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/__snapshots__/assessment.test.ts.snap @@ -12,7 +12,8 @@ Resources │ auth │ Cognito │ pool │ ✔ │ ✔ │ ├──────────┼─────────┼──────────┼──────────┼──────────┤ │ storage │ S3 │ bucket │ ✔ │ ✔ │ -└──────────┴─────────┴──────────┴──────────┴──────────┘" +└──────────┴─────────┴──────────┴──────────┴──────────┘ +" `; exports[`Assessment render() renders an app blocked by unsupported refactor 1`] = ` @@ -27,5 +28,6 @@ Resources │ auth │ Cognito │ pool │ ✔ │ ✔ │ ├──────────┼──────────┼──────────┼──────────┼──────────┤ │ geo │ Location │ map │ ✘ Any │ ✘ Any │ -└──────────┴──────────┴──────────┴──────────┴──────────┘" +└──────────┴──────────┴──────────┴──────────┴──────────┘ +" `; diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts index 298c8233381..622d198b8e0 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts @@ -57,6 +57,8 @@ describe('AmplifyMigrationLockStep', () => { rootStackName: 'test-root-stack', region: 'us-east-1', envName: 'testEnv', + discover: () => [{ category: 'api', service: 'AppSync', resourceName: 'testApp' }], + metaOutput: () => 'test-api-id', clients: { cloudFormation: { send: mockCfnSend }, amplify: { send: mockAmplifySend }, diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts index a43319621e2..9bcc6dccbe7 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts @@ -13,6 +13,7 @@ import { ResourceStatus, CreateChangeSetCommand, DescribeChangeSetCommand, + ExecuteChangeSetCommand, DeleteChangeSetCommand, ResourceMapping, } from '@aws-sdk/client-cloudformation'; @@ -86,7 +87,8 @@ function setupMocks(cfnMock: ReturnType) { cfnMock.on(GetTemplateCommand, { StackName: 'gen2-auth-stack' }).resolves({ TemplateBody: JSON.stringify(gen2AuthTemplate) }); cfnMock.on(CreateChangeSetCommand).resolves({}); - cfnMock.on(DescribeChangeSetCommand).resolves({ Status: 'CREATE_COMPLETE', Changes: [] }); + cfnMock.on(DescribeChangeSetCommand).callsFake((input) => ({ Status: 'CREATE_COMPLETE', StackName: input.StackName, Changes: [] })); + cfnMock.on(ExecuteChangeSetCommand).resolves({}); cfnMock.on(DeleteChangeSetCommand).resolves({}); } @@ -190,7 +192,8 @@ describe('AuthCognitoForwardRefactorer.plan() — operation sequence', () => { cfnMock.on(GetTemplateCommand, { StackName: 'gen1-auth-stack' }).resolves({ TemplateBody: JSON.stringify(oauthGen1Template) }); cfnMock.on(GetTemplateCommand, { StackName: 'gen2-auth-stack' }).resolves({ TemplateBody: JSON.stringify(gen2AuthTemplate) }); cfnMock.on(CreateChangeSetCommand).resolves({}); - cfnMock.on(DescribeChangeSetCommand).resolves({ Status: 'CREATE_COMPLETE', Changes: [] }); + cfnMock.on(DescribeChangeSetCommand).callsFake((input) => ({ Status: 'CREATE_COMPLETE', StackName: input.StackName, Changes: [] })); + cfnMock.on(ExecuteChangeSetCommand).resolves({}); cfnMock.on(DeleteChangeSetCommand).resolves({}); const cognitoMock = mockClient(CognitoIdentityProviderClient); @@ -218,17 +221,16 @@ describe('AuthCognitoForwardRefactorer.plan() — operation sequence', () => { expect(cognitoMock.commandCalls(DescribeIdentityProviderCommand)).toHaveLength(1); expect(ops.length).toBeGreaterThanOrEqual(4); - const { UpdateStackCommand } = await import('@aws-sdk/client-cloudformation'); + const { CreateChangeSetCommand: CreateCS } = await import('@aws-sdk/client-cloudformation'); cfnMock.on(DescribeStacksCommand).resolves({ Stacks: [{ StackName: 'gen1-auth-stack', StackStatus: 'UPDATE_COMPLETE', CreationTime: ts }], }); - cfnMock.on(UpdateStackCommand).resolves({}); // ops[0] and ops[1] are stack status validations; ops[2] is updateSource await ops[2].execute(); - const updateCalls = cfnMock.commandCalls(UpdateStackCommand); - expect(updateCalls).toHaveLength(1); - const credsParam = updateCalls[0].args[0].input.Parameters?.find( + const createCsCalls = cfnMock.commandCalls(CreateCS); + expect(createCsCalls.length).toBeGreaterThanOrEqual(1); + const credsParam = createCsCalls[0].args[0].input.Parameters?.find( (p: { ParameterKey?: string }) => p.ParameterKey === 'hostedUIProviderCreds', ); expect(credsParam?.ParameterValue).toContain('google-id'); diff --git a/packages/amplify-cli/src/commands/gen2-migration/_infra/plan.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/plan.ts index 1a5bbb0740e..f090331270a 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/_infra/plan.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_infra/plan.ts @@ -76,10 +76,10 @@ export class Plan { if (grouped.size > 0) { printer.info(chalk.bold(chalk.underline('Operations Summary'))); - printer.blankLine(); for (const [label, descriptions] of grouped) { - printer.info(chalk.bold(label)); + printer.blankLine(); + printer.info(chalk.green(chalk.bold(label))); printer.blankLine(); let step = 1; for (const description of descriptions) { @@ -90,7 +90,8 @@ export class Plan { } if (this.implications.length > 0) { - printer.info(chalk.bold(chalk.underline('Implications'))); + printer.blankLine(); + printer.info(chalk.bold(chalk.underline(chalk.yellow('Implications')))); printer.blankLine(); for (const implication of this.implications) { printer.info(`• ${implication}`); diff --git a/packages/amplify-cli/src/commands/gen2-migration/_infra/spinning-logger.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/spinning-logger.ts index bc799a4ad00..1e0d80145ab 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/_infra/spinning-logger.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_infra/spinning-logger.ts @@ -16,6 +16,15 @@ export class SpinningLogger { constructor(private readonly prefix: string, options?: { readonly debug?: boolean }) { this.debugMode = options?.debug ?? globalIsDebug; this.spinner = new AmplifySpinner(); + + // Restore the cursor if the process is interrupted while the spinner is active + process.on('SIGINT', () => { + if (this.spinnerActive) { + this.spinner.stop(); + } + // Registering a SIGINT handler disables the default exit behavior, so we must exit explicitly. + process.exit(130); + }); } /** diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts index d805d088abe..93f432beb45 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts @@ -131,13 +131,14 @@ export class Assessment { lines.push(chalk.bold('Resources')); lines.push(''); lines.push(this.renderResourceTable()); + lines.push(''); } if (this._features.length > 0) { - lines.push(''); lines.push(chalk.bold('Features')); lines.push(''); lines.push(this.renderFeatureTable()); + lines.push(''); } return lines.join('\n'); diff --git a/packages/amplify-cli/src/commands/gen2-migration/lock.ts b/packages/amplify-cli/src/commands/gen2-migration/lock.ts index 3de5b6141a7..4612f8c3c81 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/lock.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/lock.ts @@ -194,19 +194,13 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { } } - private async fetchGraphQLApiId(): Promise { - const apis = []; - for await (const page of paginateListGraphqlApis({ client: this.gen1App.clients.appSync }, {})) { - for (const api of page.graphqlApis ?? []) { - if (api.name === `${this.gen1App.appName}-${this.gen1App.envName}`) { - apis.push(api.apiId); - } - } + private async findGraphQLApiId(): Promise { + const graphQL = this.gen1App.discover().find((r) => r.category === 'api' && r.service === 'AppSync'); + if (!graphQL) { + // project doesn't have a GraphQL API + return undefined; } - if (apis.length > 1) { - throw new AmplifyError('MigrationError', { message: 'Unexpected count of GraphQL APIs' }); - } - return apis[0]; + return this.gen1App.metaOutput(graphQL.category, graphQL.resourceName, 'GraphQLAPIIdOutput'); } private async fetchGraphQLModelTables(graphQLApiId: string): Promise { @@ -223,8 +217,13 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { private async dynamoTableNames(): Promise { if (!this._dynamoTableNames) { - const graphQLApiId = await this.fetchGraphQLApiId(); - this._dynamoTableNames = await this.fetchGraphQLModelTables(graphQLApiId); + const graphQLApiId = await this.findGraphQLApiId(); + if (!graphQLApiId) { + // not all apps have a graphql server + this._dynamoTableNames = []; + } else { + this._dynamoTableNames = await this.fetchGraphQLModelTables(graphQLApiId); + } } return this._dynamoTableNames; } diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts index 9220f64949e..613e5e8c855 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts @@ -9,6 +9,7 @@ import { DescribeChangeSetCommand, DescribeChangeSetOutput, DescribeStacksCommand, + ExecuteChangeSetCommand, ExecuteStackRefactorCommand, GetTemplateCommand, Parameter, @@ -97,9 +98,8 @@ export class Cfn { Parameters: parameters, StackName: stackName, Capabilities: [CFN_IAM_CAPABILITY], - Tags: [], }; - writeUpdateSnapshot(input); + writeUpdateSnapshot({ stackName, templateBody: input.TemplateBody, parameters }); this.info(`Updating stack: ${extractStackNameFromId(stackName)}`, resource); await this.client.send(new UpdateStackCommand(input)); } catch (e) { @@ -144,6 +144,12 @@ export class Cfn { const targetTemplate = targetStack ? await this.fetchTemplate(targetStackId) : JSON.parse(JSON.stringify(EMPTY_HOLDING_TEMPLATE)); for (const mapping of resourceMappings) { + if (mapping.Destination.LogicalResourceId in targetTemplate.Resources) { + // our refactoring is expected to move resources into vacancies, not override + throw new AmplifyError('MigrationError', { + message: `Unable to create stack refactor. Resource ${mapping.Destination.LogicalResourceId} already exists in stack ${targetStackName}`, + }); + } targetTemplate.Resources[mapping.Destination.LogicalResourceId] = sourceTemplate.Resources[mapping.Source.LogicalResourceId]; delete sourceTemplate.Resources[mapping.Source.LogicalResourceId]; } @@ -217,7 +223,7 @@ export class Cfn { readonly templateBody: CFNTemplate; }): Promise { const { stackName, parameters, templateBody } = params; - const changeSetName = `migration-preview-${Date.now()}`; + const changeSetName = `gen2-migration-${Date.now()}`; await this.client.send( new CreateChangeSetCommand({ @@ -230,25 +236,54 @@ export class Cfn { ); try { - try { - await waitUntilChangeSetCreateComplete( - { client: this.client, maxWaitTime: 120 }, - { StackName: stackName, ChangeSetName: changeSetName }, - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - if (e.message?.includes(`The submitted information didn't contain changes`)) { - return undefined; - } - throw e; - } - - return await this.client.send( - new DescribeChangeSetCommand({ StackName: stackName, ChangeSetName: changeSetName, IncludePropertyValues: true }), + await waitUntilChangeSetCreateComplete( + { client: this.client, maxWaitTime: 120 }, + { StackName: stackName, ChangeSetName: changeSetName }, ); - } finally { - await this.client.send(new DeleteChangeSetCommand({ StackName: stackName, ChangeSetName: changeSetName })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + if (e.message?.includes(`The submitted information didn't contain changes`)) { + await this.client.send(new DeleteChangeSetCommand({ StackName: stackName, ChangeSetName: changeSetName })); + return undefined; + } + throw e; } + + return await this.client.send( + new DescribeChangeSetCommand({ StackName: stackName, ChangeSetName: changeSetName, IncludePropertyValues: true }), + ); + } + + /** + * Executes a previously created change set and waits for the stack update to complete. + * Returns the described change set, or undefined if no changes were detected. + */ + public async executeChangeSet(params: { + readonly changeSet: DescribeChangeSetOutput; + readonly templateBody: CFNTemplate; + readonly resource?: DiscoveredResource; + }): Promise { + const { changeSet, templateBody, resource } = params; + const displayName = extractStackNameFromId(changeSet.StackName); + + writeUpdateSnapshot({ + stackName: changeSet.StackName, + templateBody: JSON.stringify(templateBody), + parameters: changeSet.Parameters ?? [], + }); + + this.info(`Executing change set for stack: ${displayName}`, resource); + await this.client.send(new ExecuteChangeSetCommand({ StackName: changeSet.StackName, ChangeSetName: changeSet.ChangeSetName })); + + this.info(`Waiting for stack update to complete: ${displayName}`, resource); + await waitUntilStackUpdateComplete({ client: this.client, maxWaitTime: MAX_WAIT_TIME_SECONDS }, { StackName: changeSet.StackName }); + } + + /** + * Deletes a change set without executing it. + */ + public async deleteChangeSet(changeSet: DescribeChangeSetOutput): Promise { + await this.client.send(new DeleteChangeSetCommand({ StackName: changeSet.StackName, ChangeSetName: changeSet.ChangeSetName })); } /** @@ -390,10 +425,16 @@ function formatTemplateBody(templateBody: string): string { return JSON.stringify(JSON.parse(templateBody), null, 2); } -function writeUpdateSnapshot(input: UpdateStackCommandInput): void { - const stackName = extractStackNameFromId(input.StackName); - fs.writeFileSync(path.join(OUTPUT_DIRECTORY, `update.${stackName}.template.json`), formatTemplateBody(input.TemplateBody)); - fs.writeFileSync(path.join(OUTPUT_DIRECTORY, `update.${stackName}.parameters.json`), JSON.stringify(input.Parameters ?? [], null, 2)); +interface WriteUpdateSnapshotInput { + readonly stackName: string; + readonly templateBody: string; + readonly parameters: Parameter[]; +} + +function writeUpdateSnapshot(input: WriteUpdateSnapshotInput): void { + const stackName = extractStackNameFromId(input.stackName); + fs.writeFileSync(path.join(OUTPUT_DIRECTORY, `update.${stackName}.template.json`), formatTemplateBody(input.templateBody)); + fs.writeFileSync(path.join(OUTPUT_DIRECTORY, `update.${stackName}.parameters.json`), JSON.stringify(input.parameters, null, 2)); } function writeRefactorSnapshot(input: CreateStackRefactorCommandInput): void { diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/category-refactorer.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/category-refactorer.ts index 58936267567..0a3cd165f87 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/category-refactorer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/category-refactorer.ts @@ -1,4 +1,4 @@ -import { Parameter, ResourceMapping } from '@aws-sdk/client-cloudformation'; +import { DescribeChangeSetOutput, Parameter, ResourceMapping } from '@aws-sdk/client-cloudformation'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; import { CFNResource, CFNTemplate } from '../../_infra/cfn-template'; import { Planner } from '../../_infra/planner'; @@ -153,25 +153,22 @@ export abstract class CategoryRefactorer implements Planner { this.cfn.claimUpdate(source.stackId); const sourceStackName = extractStackNameFromId(source.stackId); - const report = await this.createChangeSetReport(source); + const change = await this.createChangeSetReport(source); return [ { resource: this.resource, validate: () => ({ description: `Ensure no unexpected changes to ${sourceStackName}`, - run: async () => ({ valid: report === undefined, report }), + run: async () => ({ valid: change?.report === undefined, report: change?.report }), }), describe: async () => { + if (!change) return []; const header = `Update source stack '${sourceStackName}' with resolved references`; - return [report ? `${header}\n\n${report.trimStart()}` : `${header} (empty change-set)`]; + return [change.report ? `${header}\n\n${change.report.trimStart()}` : `${header} (empty change-set)`]; }, execute: async () => { - await this.cfn.update({ - stackName: source.stackId, - parameters: source.parameters, - templateBody: source.resolvedTemplate, - resource: this.resource, - }); + if (!change) return; + await this.cfn.executeChangeSet({ changeSet: change.changeSet, templateBody: source.resolvedTemplate, resource: this.resource }); }, }, ]; @@ -186,34 +183,33 @@ export abstract class CategoryRefactorer implements Planner { this.cfn.claimUpdate(target.stackId); const targetStackName = extractStackNameFromId(target.stackId); - const report = await this.createChangeSetReport(target); + const change = await this.createChangeSetReport(target); return [ { resource: this.resource, validate: () => ({ description: `Ensure no unexpected changes to ${targetStackName}`, - run: async () => ({ valid: report === undefined, report }), + run: async () => ({ valid: change?.report === undefined, report: change?.report }), }), describe: async () => { + if (!change) return []; const header = `Update target stack '${targetStackName}' with resolved references`; - return [report ? `${header}\n\n${report.trimStart()}` : `${header} (empty change-set)`]; + return [change.report ? `${header}\n\n${change.report.trimStart()}` : `${header} (empty change-set)`]; }, execute: async () => { - await this.cfn.update({ - stackName: target.stackId, - parameters: target.parameters, - templateBody: target.resolvedTemplate, - resource: this.resource, - }); + if (!change) return; + await this.cfn.executeChangeSet({ changeSet: change.changeSet, templateBody: target.resolvedTemplate, resource: this.resource }); }, }, ]; } /** - * Creates a changeset for the given stack and returns a formatted report. + * Creates a change set for the given stack and returns the described change set with a formatted report. */ - protected async createChangeSetReport(stack: ResolvedStack): Promise { + protected async createChangeSetReport( + stack: ResolvedStack, + ): Promise<{ readonly report: string | undefined; readonly changeSet: DescribeChangeSetOutput } | undefined> { const stackName = extractStackNameFromId(stack.stackId); this.logger.push(stackName); try { @@ -222,7 +218,9 @@ export abstract class CategoryRefactorer implements Planner { parameters: stack.parameters, templateBody: stack.resolvedTemplate, }); - return changeSet ? this.cfn.renderChangeSet(changeSet) : undefined; + if (!changeSet) return undefined; + const report = this.cfn.renderChangeSet(changeSet); + return { report, changeSet }; } finally { this.logger.pop(); } @@ -295,7 +293,7 @@ export abstract class CategoryRefactorer implements Planner { for (const m of mappings) { table.push([m.Source.LogicalResourceId, m.Destination.LogicalResourceId]); } - return `${table.toString()}\n`; + return `${table.toString()}`; } protected info(message: string) { diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts index dff3aed7878..b3574c08dc8 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts @@ -130,15 +130,8 @@ export abstract class RollbackCategoryRefactorer extends CategoryRefactorer { const resources = this.filterResourcesByType(holdingStackTemplate); this.debug(`Found ${resources.size} resources to move from stack: ${holdingStackName}`); - const gen2Template = await this.gen2Branch.fetchTemplate(gen2StackName); - const resourceMappings: ResourceMapping[] = []; for (const logicalId of resources.keys()) { - if (logicalId in gen2Template.Resources) { - throw new AmplifyError('MigrationError', { - message: `Resource '${logicalId}' already exists in Gen2 stack '${gen2StackName}' — the Gen2 → Gen1 move should have removed it`, - }); - } this.debug(`Registering ${logicalId} to move from ${holdingStackName} to ${gen2StackName}`); resourceMappings.push({ Source: { StackName: holdingStackName, LogicalResourceId: logicalId }, diff --git a/packages/amplify-gen2-migration-e2e-system/AGENTS.md b/packages/amplify-gen2-migration-e2e-system/AGENTS.md deleted file mode 100644 index 290c8e727d3..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/AGENTS.md +++ /dev/null @@ -1,110 +0,0 @@ -# Rules for AI Assistants - -**IF YOU ARE AN AI ASSISTANT YOU MUST FOLLOW THESE RULES** - -## Package Context - -This is the `amplify-gen2-migration-e2e-system` package - an automation system for migrating AWS Amplify Gen1 applications to Gen2. It supports multiple apps and all Amplify categories (API, Auth, Storage, Function, Hosting). - -## Key Documentation - -**Before changing code, reference these files:** -- `README.md` - Package overview, installation, usage, and architecture -- `MIGRATION_CONFIG.md` - Complete API documentation for `migration-config.json` files - -## Architecture Overview - -``` -src/ -├── cli.ts # CLI entry point (yargs-based) -├── core/ # Core business logic -│ ├── amplify-initializer.ts # Amplify project initialization -│ ├── app-selector.ts # App discovery and selection -│ ├── category-initializer.ts # Category-specific initialization -│ ├── cdk-atmosphere-integration.ts # Atmosphere environment support -│ ├── configuration-loader.ts # JSON config loading/validation -│ └── environment-detector.ts # Local vs Atmosphere detection -├── interfaces/ # TypeScript interfaces -├── types/ # TypeScript type definitions -└── utils/ # Utility modules - ├── aws-profile-manager.ts # AWS credential management - ├── directory-manager.ts # Directory operations - ├── file-manager.ts # File operations - └── logger.ts # Logging with file output -``` - -## Development Workflow - -### Implementation Rules - -1. **NO 'any' types allowed.** TypeScript strict mode is enforced. - -2. Use relative imports within this package. Use `@aws-amplify/amplify-e2e-core` for cross-package imports. - -3. Configuration files live in `amplify-migration-apps//migration-config.json`. Follow the schema in `MIGRATION_CONFIG.md`. - -4. Environment detection: - - **Atmosphere**: Requires `ATMOSPHERE_ENDPOINT` and `DEFAULT_POOL` env vars - - **Local**: Uses AWS profiles from `~/.aws/` - -### Building and Testing - -```bash -# Build (from package root) -yarn build - -# Unit tests -yarn test - -# Integration tests (requires Atmosphere setup) -yarn test:integ - -# E2E tests (deploys real Amplify apps) -yarn test:e2e -``` - -### Running the Migration CLI - -The primary test app is `project-boards`. Use this app when testing changes to the migration system. - -```bash -# Set AMPLIFY_PATH to your development Amplify CLI -# If you do not set this, the CLI will use the global installation of amplify -export AMPLIFY_PATH={YOUR_WORKPLACE}/amplify-cli/.bin/amplify-dev - -# Migrate an app (Project Boards) using the default profile -npx tsx src/cli.ts --app project-boards --profile default - -# Dry run (show what would be done, don't deploy any resources) -npx tsx src/cli.ts --dry-run --app discussions --profile default -``` - -### Test File Naming - -- Unit tests: `*.test.ts` -- Integration tests: `*integration*.test.ts` -- E2E tests: `*.e2e.test.ts` or `*.e2e.atmosphere.test.ts` - -## Configuration Schema - -When modifying `migration-config.json` files, ensure compliance with `MIGRATION_CONFIG.md`. Required fields: -- `app.name`, `app.description`, `app.framework` -- `categories` object with valid category configurations - -## Environment Variables - -- `AMPLIFY_PATH` - Path to development Amplify CLI binary -- `ATMOSPHERE_ENDPOINT` - Atmosphere service endpoint (for CI) -- `DEFAULT_POOL` - Atmosphere pool identifier (for CI) - -For local Atmosphere testing, create `.gamma.env` in package root (git-ignored). - -## Commit Guidelines - -Follow the root `AGENTS.md` commit format. Use appropriate prefixes: -- `feat:` - New migration features or category support -- `fix:` - Bug fixes in migration logic -- `test:` - Test additions or modifications -- `docs:` - Documentation updates - -**ALWAYS FOLLOW THESE RULES WHEN YOU WORK IN THIS PACKAGE** diff --git a/packages/amplify-gen2-migration-e2e-system/MIGRATION_CONFIG.md b/packages/amplify-gen2-migration-e2e-system/MIGRATION_CONFIG.md deleted file mode 100644 index 06dee941629..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/MIGRATION_CONFIG.md +++ /dev/null @@ -1,501 +0,0 @@ -# Migration Configuration API - -This document describes the complete API for `migration-config.json` files used by the Amplify Migration System. - -## Configuration Schema - -Each app directory should contain a `migration-config.json` file with the following structure: - -```json -{ - "app": { - "name": "string", - "description": "string", - "framework": "string" - }, - "categories": { - "api": { /* API Configuration */ }, - "auth": { /* Auth Configuration */ }, - "storage": { /* Storage Configuration */ }, - "function": { /* Function Configuration */ }, - "hosting": { /* Hosting Configuration */ }, - "restApi": { /* REST API Configuration */ }, - "analytics": { /* Analytics Configuration */ } - } -} -``` - -## App Metadata - -```json -{ - "app": { - "name": "app-0", - "description": "Brief description of the application", - "framework": "react" - } -} -``` - -- **name**: App identifier (required) -- **description**: Human-readable description (required) -- **framework**: App framework - `react`, `none`, etc. (required) - -## API Category (GraphQL) - -```json -{ - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["API_KEY", "COGNITO_USER_POOLS", "IAM", "OIDC"], - "customQueries": ["listTodos", "getTodosByUser"], - "customMutations": ["createTodoWithValidation"] - } -} -``` - -- **type**: API type - `GraphQL` or `REST` (required) -- **schema**: Schema file name for GraphQL APIs (optional) -- **authModes**: Array of authorization modes (required) -- **customQueries**: Custom query operations using `@function` directive (optional) -- **customMutations**: Custom mutation operations using `@function` directive (optional) - -## REST API Category - -For REST APIs backed by Lambda functions: - -```json -{ - "restApi": { - "name": "nutritionapi", - "paths": ["/nutrition/log"], - "lambdaSource": "lognutrition" - } -} -``` - -- **name**: REST API friendly name (required) -- **paths**: Array of API paths (required) -- **lambdaSource**: Name of the Lambda function backing the API (required) - -## Auth Category - -```json -{ - "auth": { - "signInMethods": ["email", "phone", "username"], - "socialProviders": ["facebook", "google", "amazon", "apple"], - "userPoolGroups": ["Admin", "Basic"], - "triggers": { - "preSignUp": { - "type": "email-filter-allowlist" - } - }, - "userPoolConfig": { - "passwordPolicy": { - "minimumLength": 8, - "requireLowercase": true, - "requireUppercase": true, - "requireNumbers": true, - "requireSymbols": false - }, - "mfaConfiguration": { - "mode": "OFF" | "ON" | "OPTIONAL", - "smsMessage": "Your verification code is {####}", - "totpEnabled": false - }, - "emailVerification": true, - "phoneVerification": false - }, - "identityPoolConfig": { - "allowUnauthenticatedIdentities": false, - "cognitoIdentityProviders": ["cognito-idp.region.amazonaws.com/userPoolId"] - } - } -} -``` - -- **signInMethods**: How users can sign in - `email`, `phone`, `username` (required) -- **socialProviders**: Third-party authentication providers (required, can be empty array) -- **userPoolGroups**: Cognito User Pool groups (optional) -- **triggers**: Cognito Lambda triggers (optional) - - **preSignUp**: Pre sign-up trigger configuration - - **type**: Trigger type - `email-filter-allowlist`, etc. -- **userPoolConfig**: Cognito User Pool settings (optional) -- **identityPoolConfig**: Cognito Identity Pool settings (optional) - -## Storage Category - -Storage can be either S3 buckets or DynamoDB tables. - -### S3 Storage - -```json -{ - "storage": { - "buckets": [ - { - "name": "images", - "access": ["public", "protected", "private", "auth", "guest"], - "cors": { - "allowedOrigins": ["*"], - "allowedMethods": ["GET", "POST", "PUT", "DELETE"], - "allowedHeaders": ["*"], - "maxAge": 3000 - } - } - ], - "triggers": [ - { - "name": "imageProcessor", - "events": ["objectCreated", "objectRemoved"], - "function": "imageProcessorFunction" - } - ] - } -} -``` - -- **buckets**: S3 bucket configurations (required for S3 storage) - - **name**: Bucket friendly name (required) - - **access**: Access levels - `public`, `protected`, `private`, `auth`, `guest` (required) - - **cors**: CORS configuration (optional) -- **triggers**: Lambda triggers for S3 events (optional) - - **name**: Trigger name (required) - - **events**: S3 events - `objectCreated`, `objectRemoved`, `objectRestore` (required) - - **function**: Lambda function name to invoke (required) - -### DynamoDB Storage - -```json -{ - "storage": { - "type": "dynamodb", - "tables": [ - { - "name": "activity", - "partitionKey": "id", - "sortKey": "userId", - "gsi": [ - { - "name": "byUserId", - "partitionKey": "userId", - "sortKey": "timestamp" - } - ] - } - ] - } -} -``` - -- **type**: Storage type - `dynamodb` (required for DynamoDB) -- **tables**: DynamoDB table configurations (required) - - **name**: Table name (required) - - **partitionKey**: Partition key attribute name (required) - - **sortKey**: Sort key attribute name (optional) - - **gsi**: Global Secondary Indexes (optional) - - **name**: GSI name (required) - - **partitionKey**: GSI partition key (required) - - **sortKey**: GSI sort key (optional) - -## Function Category - -```json -{ - "function": { - "functions": [ - { - "name": "quotegenerator", - "runtime": "nodejs" | "python" | "java" | "dotnet", - "template": "hello-world", - "handler": "index.handler", - "environment": { - "TABLE_NAME": "TodoTable", - "API_ENDPOINT": "https://api.example.com" - }, - "permissions": [ - "dynamodb:GetItem", - "dynamodb:PutItem", - "s3:GetObject" - ], - "trigger": { - "type": "dynamodb-stream", - "source": ["Topic", "Post", "Comment"] - } - } - ] - } -} -``` - -- **functions**: Array of Lambda function configurations (required) -- **name**: Function name (required) -- **runtime**: Runtime environment - `nodejs`, `python`, `java`, `dotnet` (required) -- **template**: Function template - `hello-world`, `serverless-expressjs`, `lambda-trigger` (optional) -- **handler**: Entry point (optional) -- **environment**: Environment variables (optional) -- **permissions**: IAM permissions (optional) -- **trigger**: Event trigger configuration (optional) - - **type**: Trigger type - `dynamodb-stream`, `s3`, etc. (required) - - **source**: Trigger source - model names for DynamoDB streams (required) - -## Hosting Category - -```json -{ - "hosting": { - "type": "amplify-console" | "s3-cloudfront", - "customDomain": "myapp.example.com", - "sslCertificate": "arn:aws:acm:region:account:certificate/cert-id", - "buildSettings": { - "buildCommand": "npm run build", - "outputDirectory": "dist", - "nodeVersion": "18", - "environmentVariables": { - "REACT_APP_API_URL": "https://api.example.com" - } - } - } -} -``` - -- **type**: Hosting type (required) -- **customDomain**: Custom domain name (optional) -- **sslCertificate**: SSL certificate ARN (optional) -- **buildSettings**: Build configuration (optional) - -## Analytics Category - -```json -{ - "analytics": { - "type": "kinesis", - "name": "moodboardKinesis", - "shards": 1 - } -} -``` - -- **type**: Analytics provider - `kinesis` or `pinpoint` (required, only `kinesis` currently supported) -- **name**: Stream or resource name (required) -- **shards**: Number of Kinesis shards (optional, defaults to 1) - -## Complete Examples - -### Simple App (project-boards) - -```json -{ - "app": { - "name": "project-boards", - "description": "Project board app with authentication and file storage", - "framework": "react" - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["API_KEY"] - }, - "auth": { - "signInMethods": ["email"], - "socialProviders": [] - }, - "storage": { - "buckets": [ - { - "name": "images", - "access": ["auth", "guest"] - } - ] - }, - "function": { - "functions": [ - { - "name": "quotegenerator", - "runtime": "nodejs", - "template": "hello-world" - } - ] - }, - "hosting": { - "type": "amplify-console" - } - } -} -``` - -### App with Social Auth and User Groups (media-vault) - -```json -{ - "app": { - "name": "media-vault", - "description": "Personal media vault with social authentication", - "framework": "react" - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["COGNITO_USER_POOLS", "API_KEY"] - }, - "auth": { - "signInMethods": ["email", "phone"], - "socialProviders": ["facebook", "google"], - "userPoolGroups": ["Admin", "Basic"] - }, - "storage": { - "buckets": [ - { - "name": "mediavault", - "access": ["auth", "guest"] - } - ] - }, - "function": { - "functions": [ - { - "name": "thumbnailgen", - "runtime": "nodejs", - "template": "hello-world" - }, - { - "name": "addusertogroup", - "runtime": "nodejs", - "template": "hello-world" - }, - { - "name": "removeuserfromgroup", - "runtime": "nodejs", - "template": "hello-world" - } - ] - }, - "hosting": { - "type": "amplify-console" - } - } -} -``` - -### App with REST API and Auth Triggers (fitness-tracker) - -```json -{ - "app": { - "name": "fitness-tracker", - "description": "Fitness tracking with GraphQL and REST APIs", - "framework": "react" - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["COGNITO_USER_POOLS", "API_KEY"] - }, - "auth": { - "signInMethods": ["username"], - "socialProviders": [], - "triggers": { - "preSignUp": { - "type": "email-filter-allowlist" - } - } - }, - "restApi": { - "name": "nutritionapi", - "paths": ["/nutrition/log"], - "lambdaSource": "lognutrition" - }, - "function": { - "functions": [ - { - "name": "lognutrition", - "runtime": "nodejs", - "template": "serverless-expressjs" - } - ] - }, - "hosting": { - "type": "amplify-console" - } - } -} -``` - -### App with DynamoDB Storage (discussions) - -```json -{ - "app": { - "name": "discussions", - "description": "Discussion app with DynamoDB activity logging", - "framework": "none" - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["API_KEY"], - "customQueries": ["getUserActivity"], - "customMutations": ["logUserActivity"] - }, - "auth": { - "signInMethods": ["phone"], - "socialProviders": [] - }, - "storage": { - "type": "dynamodb", - "tables": [ - { - "name": "activity", - "partitionKey": "id", - "sortKey": "userId", - "gsi": [ - { - "name": "byUserId", - "partitionKey": "userId", - "sortKey": "timestamp" - } - ] - } - ] - }, - "function": { - "functions": [ - { - "name": "fetchuseractivity", - "runtime": "nodejs", - "template": "hello-world" - }, - { - "name": "recorduseractivity", - "runtime": "nodejs", - "template": "lambda-trigger", - "trigger": { - "type": "dynamodb-stream", - "source": ["Topic", "Post", "Comment"] - } - } - ] - }, - "hosting": { - "type": "amplify-console" - } - } -} -``` - -## Validation Rules - -1. **Required Fields**: `app.name`, `app.description`, `app.framework`, `categories` -2. **API Category**: If present, must have `type` and `authModes` -3. **Auth Category**: If present, must have `signInMethods` and `socialProviders` (can be empty array) -4. **Storage Category**: If S3, must have `buckets` array; if DynamoDB, must have `type: "dynamodb"` and `tables` array -5. **Function Category**: If present, must have `functions` array with valid function objects -6. **Hosting Category**: If present, must have valid `type` -7. **REST API Category**: If present, must have `name`, `paths`, and `lambdaSource` -8. **Analytics Category**: If present, must have valid `type` and `name` diff --git a/packages/amplify-gen2-migration-e2e-system/README.md b/packages/amplify-gen2-migration-e2e-system/README.md index 66ac479aaf7..1d0d91e14a0 100644 --- a/packages/amplify-gen2-migration-e2e-system/README.md +++ b/packages/amplify-gen2-migration-e2e-system/README.md @@ -1,322 +1,138 @@ -# Amplify Gen 2 Migration E2E System +# Amplify Gen2 Migration E2E System -Automation system for migrating AWS Amplify Gen1 applications to Gen2 with support for multiple apps and all Amplify categories. - -## Features - -### In-progress -- Test scripts to validate Gen 1 (pre-refactor and post-refactor) and Gen 2 stacks - -### Complete -- **Gen2 Migration Commands**: Executes `amplify gen2-migration` CLI commands (lock, generate) after Gen1 push -- **Category Support**: Full support for API, Auth, Storage, Function, and Hosting categories -- **Environment Detection**: Automatic detection of Atmosphere vs Local environments -- **Flexible Authentication**: Support for AWS profiles and Atmosphere credentials -- **Configuration-Driven**: JSON-based configuration for each app with API documentation - -## Installation and build - -You may choose to build the entire monorepo, or just a few key components. - -### Entire monorepo -Go to the monorepo root and run: -```shell -yarn install -yarn build -``` - -### Individual packages - -If you know how to do this with a one-liner with Lerna, let me know! - -Build the CLI if using the development binary. If you do not, this tool will look for the global installation of Amplify CLI from your `PATH`. -```shell -cd packages/amplify-cli -yarn install -yarn build -``` - -Build the Amplify E2E Core package. -```shell -cd packages/amplify-gen2-migration-e2e-system -yarn install -yarn build -``` - -Build the Amplify Gen2 Migration E2E System -```shell -cd packages/amplify-gen2-migration-e2e-system -yarn install -yarn build -``` +Automation system for end-to-end testing of the Amplify Gen1-to-Gen2 migration workflow. It deploys a Gen1 app, runs the migration CLI commands, deploys the Gen2 output, and validates both stacks with test scripts at each stage. ## Usage -### Basic Usage - -```shell - -# this tool will use your development Amplify CLI by default: {YOUR_WORKPLACE}/amplify-cli/.bin/amplify-dev -# if your development Amplify CLI is not built, then the tool will fall back to your global install of amplify -# you can override the default behavior by setting AMPLIFY_PATH +```bash +# Set AMPLIFY_PATH to your development Amplify CLI (optional - falls back to monorepo build, then global install) +export AMPLIFY_PATH=$(pwd)/.bin/amplify-dev -# Migrate an app (Project Boards) using the default profile +# Migrate an app using a named AWS profile npx tsx src/cli.ts --app project-boards --profile default -# Dry run (show what would be done, don't deploy any resources) -npx tsx src/cli.ts --dry-run --app discussions --profile default +# Verbose logging +npx tsx src/cli.ts --app project-boards --profile default --verbose ``` ### CLI Options -- `--app, -a`: Specific app to migrate (e.g., discussions, media-vault) -- `--dry-run, -d`: Show what would be done without executing -- `--verbose, -v`: Enable verbose logging -- `--profile`: AWS profile to use -- `--envName`: Amplify Gen1 environment name to create (defaults to a random 2-10 character lowercase string) -- `--list-apps, -l`: List available apps and exit - -### Examples - -```bash -# List all available apps -npx tsx src/cli.ts --list-apps - -# Migrate app with verbose logging -npx tsx src/cli.ts --app media-vault --verbose -``` - -## Configuration - -Each app directory should contain a `migration-config.json` file that defines the app's migration requirements. These configurations are manually created based on the comprehensive API documentation in `MIGRATION_CONFIG.md`. - -### Configuration Structure - -- App metadata (name, description) -- Category configurations (API, Auth, Storage, Function, Hosting) - -For complete API documentation and examples, see `MIGRATION_CONFIG.md`. - -Example configuration: +| Option | Alias | Description | +| ----------- | ----- | ---------------------------------------------------------------------------------- | +| `--app` | `-a` | App to migrate (required). Must match a directory under `amplify-migration-apps/`. | +| `--profile` | | AWS profile to use (required). | +| `--verbose` | `-v` | Enable debug-level logging. | + +## Migration Workflow + +The CLI executes the following steps for a given app: + +1. Copy app source to a temp directory (excluding `_snapshot*` and `node_modules`) +2. `amplify init` — initialize the Gen1 project +3. Configure categories by restoring the pre-generate snapshot into the `amplify/` directory +4. `npm install` +5. Run `pre-push` npm script (app-specific fixups before deployment) +6. `amplify push` — deploy the Gen1 stack +7. Run `post-push` npm script (app-specific fixups) +8. Run `test:gen1` — validate the Gen1 deployment +9. `amplify gen2-migration assess` +10. `amplify gen2-migration lock` +11. Checkout a new `gen2-` branch +12. `amplify gen2-migration generate` +13. `npm install` +14. Run `post-generate` npm script (app-specific fixups) +15. `npx ampx sandbox --once` — deploy the Gen2 stack +16. Run `post-sandbox` npm script (app-specific fixups after first sandbox deploy) +17. Run `test:gen1` and `test:gen2` — validate both stacks +18. Checkout `main` branch (refactor requires Gen1 files) +19. `amplify gen2-migration refactor` — move stateful resources to Gen2 +20. Checkout `gen2-` branch +21. Run `post-refactor` npm script (app-specific fixups) +22. Run `test:gen1` and `test:gen2` — validate both stacks +23. Redeploy Gen2 sandbox to pick up post-refactor changes +24. Run `test:gen1` and `test:gen2` — final validation + +Test scripts run at multiple points to verify that both stacks remain functional throughout the migration. + +The system runs npm scripts defined in each app's `package.json`: + +- `pre-push` — before `amplify push` +- `post-push` — after `amplify push` +- `post-generate` — after `gen2-migration generate` +- `post-sandbox` — after the first `npx ampx sandbox --once` deploy +- `post-refactor` — after `gen2-migration refactor` +- `test:gen1` — Jest tests against the Gen1 config (`src/amplifyconfiguration.json`) +- `test:gen2` — Jest tests against the Gen2 config (`amplify_outputs.json`) + +Scripts set to `"true"` in `package.json` are effectively no-ops. + +### Migration Config + +Each app can optionally include a `migration/config.json` to customize the E2E workflow: ```json { - "app": { - "name": "project-boards", - "description": "Project board app with authentication", - }, - "categories": { - "api": { - "type": "GraphQL", - "schema": "schema.graphql", - "authModes": ["API_KEY", "COGNITO_USER_POOLS"] - }, - "auth": { - "signInMethods": ["email"], - "socialProviders": [] - }, - "storage": { - "buckets": [ - { - "name": "images", - "access": ["auth", "guest"] - } - ] - }, - "function": { - "functions": [ - { - "name": "quotegenerator", - "runtime": "nodejs", - "template": "hello-world" - } - ] - }, - "hosting": { - "type": "amplify-console" - } - } + "lock": { "skipValidations": true } } ``` -## Architecture - -The system follows a modular architecture with: - -- **ConfigurationLoader**: Manages app-specific configurations -- **EnvironmentDetector**: Detects Atmosphere vs Local environments -- **AppSelector**: Handles app discovery and selection -- **Gen2MigrationExecutor**: Executes gen2-migration CLI commands (lock, generate, refactor) -- **Logger**: Formatted logging with file output -- **FileManager**, **DirectryManager**: File system operations - -### Migration Workflow - -The CLI executes the following workflow: - -1. **Initialize**: Copy app source, run `amplify init` -2. **Add Categories**: Add configured categories (auth, api, storage, function, analytics) -3. **Push**: Deploy Gen1 app to AWS via `amplify push` -4. **Git Init**: Initialize git repo and commit Gen1 state -5. **Lock**: Lock Gen1 environment via `amplify gen2-migration lock` -6. **Generate**: Generate Gen2 code via `amplify gen2-migration generate` -7. **Post-Generate**: Run app-specific `post-generate.ts` script (if present) -8. **Deploy Gen2**: Deploy Gen2 app via `npx ampx sandbox --once` -9. **Refactor**: Move stateful resources via `amplify gen2-migration refactor` -10. **Post-Refactor**: Run app-specific `post-refactor.ts` script (if present) -11. **Redeploy Gen2**: Redeploy Gen2 app to pick up post-refactor changes +| Field | Description | +| -------------------------- | ------------------------------------------------------- | +| `lock.skipValidations` | Pass `--skip-validations` to `gen2-migration lock`. | +| `refactor.skip` | Skip the refactor step entirely. | +| `refactor.skipValidations` | Pass `--skip-validations` to `gen2-migration refactor`. | -### Post-Generate and Post-Refactor Scripts +If the file does not exist, defaults are used. -Each app in `amplify-migration-apps/` can have optional TypeScript scripts that apply manual edits required during migration: +For details on the app layout, test scripts, and migration scripts, see the [amplify-migration-apps README](../../amplify-migration-apps/README.md). -- **`post-generate.ts`**: Runs after `amplify gen2-migration generate` and before Gen2 deployment -- **`post-refactor.ts`**: Runs after `amplify gen2-migration refactor` and before Gen2 redeployment +## Package Architecture -These scripts handle app-specific transformations that the migration CLI cannot automate, such as: -- Converting CommonJS Lambda functions to ESM syntax -- Updating frontend imports from `aws-exports` to `amplify_outputs.json` -- Adding IAM policies for cross-resource access (e.g., Kinesis permissions) -- Setting resource names to preserve original Gen1 names after refactor - -#### Script Interface - -Both scripts must export a function with the following signature: - -```typescript -// post-generate.ts -interface PostGenerateOptions { - appPath: string; // Path to the deployed app directory - envName?: string; // Amplify environment name (e.g., "main", "dev") -} - -export async function postGenerate(options: PostGenerateOptions): Promise; - -// post-refactor.ts -interface PostRefactorOptions { - appPath: string; - envName?: string; -} - -export async function postRefactor(options: PostRefactorOptions): Promise; ``` - -#### Example: post-generate.ts - -```typescript -import fs from 'fs/promises'; -import path from 'path'; - -export async function postGenerate(options: PostGenerateOptions): Promise { - const { appPath } = options; - - // Convert Lambda function from CommonJS to ESM - const handlerPath = path.join(appPath, 'amplify', 'function', 'myFunction', 'index.js'); - let content = await fs.readFile(handlerPath, 'utf-8'); - content = content.replace( - /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, - 'export async function handler($1) {' - ); - await fs.writeFile(handlerPath, content, 'utf-8'); -} +src/ +├── cli.ts # CLI entry point and migration workflow orchestration +└── core/ + ├── app.ts # App class — owns the full lifecycle of a migration app + ├── git.ts # Git operations (init, commit, checkout) + └── logger.ts # Logging with file output ``` -#### Loading Mechanism - -The CLI dynamically imports these scripts at runtime using `import()`. Scripts are located by path: -- `amplify-migration-apps//post-generate.ts` -- `amplify-migration-apps//post-refactor.ts` - -If a script doesn't exist, the step is silently skipped. - ## Development -### Installing -```bash -yarn install -``` - -### Compiling +### Building ```bash +# From package root yarn build -``` - -### Testing -```bash -yarn test # unit tests -yarn test:integ # integ (atmosphere) validation tests, requires atmosphere setup -yarn test:e2e # end-to-end tests (deploys Amplify Apps) +# Or build the full monorepo from the repo root +yarn install && yarn build ``` -### Linting - -```bash -yarn lint -yarn lint:fix -``` - -## Environment Configuration - -### Environment Detection - -The system detects the environment type based on the presence of specific environment variables: +### Environment Variables -**Atmosphere Environment Detection:** -- Run `migrate` with `--atmsophere` -- Works only if both variables are present: `ATMOSPHERE_ENDPOINT`, and `DEFAULT_POOL` -- Environment type: `atmosphere` -- Uses CDK Atmosphere client for integration tests -- For runs using the CLI, these variables must be manually set by the operator, to use them in the E2E tests, create a `.gamma.env` file (see below) +| Variable | Description | +| -------------- | ------------------------------------------------------------------------------------------ | +| `AMPLIFY_PATH` | Path to development Amplify CLI binary. Falls back to monorepo build, then global install. | -**Local Environment Detection:** -- Run `migrate` with `--profile` -- Environment type: `local` -- Uses AWS profiles from AWS config and credentials files +### Logging -### Atmosphere Configuration File: `.gamma.env` +Logs are written to both console and file. File logs go to `$TMPDIR/amplify-gen2-migration-e2e-system/logs/.log`. -Create a `.gamma.env` file in the project root to configure Atmosphere environment: - -```bash -# Atmosphere endpoint configuration -# Example format -ATMOSPHERE_ENDPOINT=https://my.atmosphere.endpoint.dev -DEFAULT_POOL=__exp.my-amplify-cli-pool__ -``` - -**Important Notes:** -- `.gamma.env` is git-ignored -- Tests automatically load this file if present, but manual runs require you to set the env vars yourself -- Both variables must be present for Atmosphere environment detection - -## Logging - -Logs are written to both console and file: -- Console: Colored, formatted output -- File: Structured logs in temp directory +## FAQ -## Error Handling +### The security token included in the request is invalid -Error handling with: -- Environment-specific error messages -- Graceful degradation for optional features +Re-authenticate and get new credentials for your working environment. -## FAQ +### CDK failed to publish assets -### 🛑 The security token included in the request is invalid -Please re-authenticate and get new admin credentials for your working environment +Likely a problem with your CDKToolkit bootstrap stack. Check it in your AWS account. -### [ERROR] [CDKAssetPublishError] CDK failed to publish assets ∟ Caused by: [ToolkitError] Failed to publish asset -This is likely a problem with your bootstrap stack in the environment you're deploying to. Please take a look at CDKToolkit in your AWS account. +### AppSync API limit exceeded -### Issues with not being able to deploy AppSync APIs -AppSync has a limit of 50 APIs per environment (account/region). Check to see if you have exceeded the limit, and delete the unnecessary ones before trying again. +AppSync has a limit of 50 APIs per account/region. Delete unused ones before retrying. ### Amplify init fails -Consider which `amplify` binary you are using. We recommend building the one in the monorepo and passing it to the tool using `export AMPLIFY_CLI`. Also consider that the maximum number of app allowed by the Amplify console in an environment is 25. Check to see if you have exceeded the limit, and delete the unnecessary ones before trying again. - -## License -Apache-2.0 +Check which `amplify` binary you are using. The Amplify console also has a limit of 25 apps per account/region. diff --git a/packages/amplify-gen2-migration-e2e-system/jest.config.js b/packages/amplify-gen2-migration-e2e-system/jest.config.js deleted file mode 100644 index bbd1512c7a4..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/jest.config.js +++ /dev/null @@ -1,24 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - roots: ['/src'], - testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], - transform: { - '^.+\\.tsx?$': [ - 'ts-jest', - { - tsconfig: 'tsconfig.json', - }, - ], - }, - moduleNameMapper: { - '^uuid$': require.resolve('uuid'), - '^yaml$': require.resolve('yaml'), - }, - transformIgnorePatterns: ['/node_modules/(?!(uuid|yaml)/)'], - collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/*.test.ts', '!src/**/*.spec.ts'], - coverageDirectory: 'coverage', - coverageReporters: ['text', 'lcov', 'html'], - testTimeout: 180000, - setupFilesAfterEnv: ['/src/test-setup.ts'], -}; diff --git a/packages/amplify-gen2-migration-e2e-system/package.json b/packages/amplify-gen2-migration-e2e-system/package.json index b61d3e9c7e7..a51f18216d0 100644 --- a/packages/amplify-gen2-migration-e2e-system/package.json +++ b/packages/amplify-gen2-migration-e2e-system/package.json @@ -9,12 +9,7 @@ }, "scripts": { "build": "tsc", - "migrate": "ts-node src/cli.ts", - "test": "jest --testPathIgnorePatterns='\\.e2e\\.' --testPathIgnorePatterns='integration'", - "test:integ": "NODE_OPTIONS='--experimental-vm-modules' jest --testPathPattern='integration'", - "test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --testPathPattern=\"\\.e2e\\.\"", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", + "test": "true", "lint": "eslint src/**/*.ts", "lint:fix": "eslint src/**/*.ts --fix" }, diff --git a/packages/amplify-gen2-migration-e2e-system/src/__tests__/amplify-initializer.e2e.atmosphere.test.ts b/packages/amplify-gen2-migration-e2e-system/src/__tests__/amplify-initializer.e2e.atmosphere.test.ts deleted file mode 100644 index 8d5c93c7357..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/__tests__/amplify-initializer.e2e.atmosphere.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -/** - * Integration Tests for AmplifyInitializer with CDK Atmosphere Client - * Tests the full end-to-end flow of using Atmosphere credentials to initialize Amplify apps - */ - -import { AmplifyInitializer, CDKAtmosphereIntegration, EnvironmentDetector } from '../core'; -import { Logger } from '../utils/logger'; -import { AppConfiguration, EnvironmentType, LogLevel } from '../types'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { execSync } from 'child_process'; - -// Test-specific logging functionality -class TestLogger { - private logFile: string; - private logStream: fs.WriteStream; - - constructor() { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logsDir = path.join(__dirname, '..', '..', 'logs'); - - // Ensure logs directory exists - if (!fs.existsSync(logsDir)) { - fs.mkdirSync(logsDir, { recursive: true }); - } - - this.logFile = path.join(logsDir, `atmosphere-integration-test-${timestamp}.log`); - this.logStream = fs.createWriteStream(this.logFile, { flags: 'w' }); - - this.log('INFO', 'Test logger initialized', { logFile: this.logFile, timestamp }); - } - - log(level: string, message: string, data?: any) { - const timestamp = new Date().toISOString(); - const logEntry = { - timestamp, - level, - message, - ...(data && { data }), - }; - - const logLine = JSON.stringify(logEntry) + '\n'; - this.logStream.write(logLine); - - // Also log to console for immediate visibility - console.log(`[${level}] ${message}`, data ? JSON.stringify(data, null, 2) : ''); - } - - close() { - return new Promise((resolve) => { - this.logStream.end(() => { - resolve(); - }); - }); - } - - getLogFile() { - return this.logFile; - } -} - -describe('AmplifyInitializer + CDK Atmosphere Integration', () => { - let logger: Logger; - let testLogger: TestLogger; - let environmentDetector: EnvironmentDetector; - let atmosphereIntegration: CDKAtmosphereIntegration; - let amplifyInitializer: AmplifyInitializer; - let testDir: string; - let atmosphereAvailable = false; - - beforeAll(async () => { - console.log('🔍 Checking integration test prerequisites...'); - - // Check if CDK Atmosphere client is available - try { - logger = new Logger(LogLevel.DEBUG); - environmentDetector = new EnvironmentDetector(logger); - const detectedEnvironment = await environmentDetector.detectEnvironment(); - atmosphereAvailable = detectedEnvironment === EnvironmentType.ATMOSPHERE; - } catch (error) { - throw Error(`❌ CDK Atmosphere client initialization failed: ${error}`); - } - - // Check if Amplify CLI is available - try { - const version = execSync('amplify --version', { stdio: 'pipe', timeout: 10000 }).toString().trim(); - console.log(`✅ Amplify CLI found: ${version}`); - } catch (error) { - console.log('❌ Amplify CLI not available - some tests may be skipped'); - } - - console.log(`📦 Node.js version: ${process.version}`); - console.log(`🏠 Test environment: ${process.env.NODE_ENV || 'development'}`); - }); - - beforeEach(() => { - console.log('🧪 Setting up integration test environment...'); - - // Initialize test logger for this test run - testLogger = new TestLogger(); - testLogger.log('INFO', 'Starting new test run', { - testSuite: 'AmplifyInitializer + CDK Atmosphere Integration', - nodeVersion: process.version, - timestamp: new Date().toISOString(), - }); - - atmosphereIntegration = new CDKAtmosphereIntegration(logger, environmentDetector); - amplifyInitializer = new AmplifyInitializer(logger); - - // Create a temporary directory for testing - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amplify-atmosphere-test-')); - console.log(`📁 Created test directory: ${testDir}`); - - testLogger.log('INFO', 'Test environment setup', { - testDirectory: testDir, - atmosphereAvailable: atmosphereAvailable, - }); - - // Ensure test directory is writable - try { - const testFile = path.join(testDir, 'test-write.tmp'); - fs.writeFileSync(testFile, 'test'); - fs.unlinkSync(testFile); - console.log('✅ Test directory is writable'); - testLogger.log('INFO', 'Test directory validated', { writable: true }); - } catch (error) { - console.error('❌ Test directory is not writable:', error); - testLogger.log('ERROR', 'Test directory validation failed', { error: (error as Error).message }); - throw error; - } - }); - - afterEach(async () => { - console.log('🧹 Cleaning up test environment...'); - - if (testLogger) { - testLogger.log('INFO', 'Test cleanup started'); - - // Clean up Atmosphere resources - try { - await atmosphereIntegration.cleanup(); - } catch (error) { - console.warn('⚠️ Failed to cleanup Atmosphere integration:', error); - testLogger.log('WARN', 'Atmosphere cleanup failed', { error: (error as Error).message }); - } - - // Clean up test directory - if (testDir && fs.existsSync(testDir)) { - try { - console.log(`🗑️ Removing test directory: ${testDir}`); - fs.rmSync(testDir, { recursive: true, force: true }); - console.log('✅ Test directory cleaned up successfully'); - testLogger.log('INFO', 'Test directory cleaned up', { testDirectory: testDir }); - } catch (error) { - console.warn(`⚠️ Failed to clean up test directory: ${testDir}`, error); - testLogger.log('ERROR', 'Test directory cleanup failed', { - testDirectory: testDir, - error: (error as Error).message, - }); - } - } - - testLogger.log('INFO', 'Test run completed'); - console.log(`📄 Test log saved to: ${testLogger.getLogFile()}`); - await testLogger.close(); - } - }); - - describe('Environment Detection Integration', () => { - it('should detect environment and check Atmosphere availability', async () => { - console.log('🔍 Testing environment detection integration...'); - - const isAtmosphere = await atmosphereIntegration.isAtmosphereEnvironment(); - - console.log(`📊 Environment detection results:`); - console.log(` - Is Atmosphere environment: ${isAtmosphere}`); - - expect(typeof isAtmosphere).toBe('boolean'); - - console.log('✅ Environment detection integration test passed'); - }); - }); - - describe('Credentials Integration', () => { - it('should get profile from allocation for Amplify initialization', async () => { - console.log('🔑 Testing profile-based credentials integration...'); - - if (!atmosphereAvailable) { - console.log('⏭️ Skipping test - CDK Atmosphere client not available'); - return; - } - - try { - const profileName = await atmosphereIntegration.getProfileFromAllocation(); - - console.log(`📊 Profile created:`); - console.log(` - Profile name: ${profileName}`); - console.log(` - Matches pattern: ${/^atmosphere-\d+-[a-f0-9]+$/.test(profileName)}`); - - expect(profileName).toBeDefined(); - expect(typeof profileName).toBe('string'); - expect(profileName).toMatch(/^atmosphere-\d+-[a-f0-9]+$/); - console.log('✅ Atmosphere profile successfully created'); - } catch (error) { - throw Error(`⚠️ Profile creation test failed: ${(error as Error).message}`); - } - }); - }); - - describe('Full Integration Test', () => { - it('should initialize Amplify app with Atmosphere profile', async () => { - console.log('🚀 Starting full Atmosphere + Amplify integration test...'); - - if (!atmosphereAvailable) { - console.log('⏭️ Skipping test - CDK Atmosphere client not available'); - return; - } - - const config = { - app: { - name: 'testatmosphereapp', - description: 'Test application with Atmosphere credentials', - framework: 'react', - }, - categories: { - api: { - type: 'GraphQL' as const, - authModes: ['API_KEY' as const], - }, - }, - }; - - console.log(`📋 Test configuration:`, JSON.stringify(config, null, 2)); - console.log(`📁 Test directory: ${testDir}`); - - // Create a timeout promise to prevent hanging - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject( - new Error('Integration test timed out after 90 seconds - this suggests an issue with the Atmosphere + Amplify integration'), - ); - }, 90000); // 90 second timeout for integration test - }); - - const startTime = Date.now(); - const testStartTime = new Date().toISOString(); - - // Log environment detection details - const envDetectionResult = await atmosphereIntegration.isAtmosphereEnvironment(); - testLogger.log('INFO', 'Environment detection completed', { - isAtmosphereEnvironment: envDetectionResult, - environmentVariables: { - ATMOSPHERE_ENDPOINT: process.env.ATMOSPHERE_ENDPOINT ? 'SET' : 'NOT_SET', - DEFAULT_POOL: process.env.DEFAULT_POOL ? 'SET' : 'NOT_SET', - }, - }); - - testLogger.log('INFO', '⏰ Starting full integration test', { - appConfig: config, - testDirectory: testDir, - startTime: testStartTime, - atmosphereEnvironmentDetected: envDetectionResult, - }); - - let profileName: string | undefined; - - try { - // Step 1: Get Atmosphere profile - testLogger.log('INFO', '🔑 Step 1: Getting Atmosphere profile'); - - profileName = await atmosphereIntegration.getProfileFromAllocation(); - - testLogger.log('INFO', '✅ Profile created successfully', { - profileName: profileName, - matchesPattern: /^atmosphere-\d+-[a-f0-9]+$/.test(profileName), - }); - - // Step 2: Initialize Amplify app with profile - testLogger.log('INFO', '🚀 Step 2: Initializing Amplify app with profile', { - profileName: profileName, - }); - - await Promise.race([ - amplifyInitializer.initializeApp({ - appPath: testDir, - config, - deploymentName: 'atmosphereAmplifyApp', - envName: 'testenv', - profile: profileName, - }), - timeoutPromise, - ]); - - const duration = Date.now() - startTime; - - // Step 3: Verify that amplify directory was created - const amplifyDir = path.join(testDir, 'amplify'); - const backendDir = path.join(testDir, 'amplify', 'backend'); - - testLogger.log('INFO', '🔍 Verifying Amplify initialization'); - - expect(fs.existsSync(amplifyDir)).toBe(true); - expect(fs.existsSync(backendDir)).toBe(true); - - // List contents for debugging and logging - let amplifyContents: string[] = []; - let backendContents: string[] = []; - try { - amplifyContents = fs.readdirSync(amplifyDir); - if (fs.existsSync(backendDir)) { - backendContents = fs.readdirSync(backendDir); - } - } catch (listError) { - testLogger.log('WARN', '⚠️ Failed to list directory contents', { error: (listError as Error).message }); - } - - // Log successful initialization details - testLogger.log('SUCCESS', '🎉 Amplify app initialization completed successfully', { - app: { - name: config.app.name, - description: config.app.description, - }, - duration: duration, - amplifyDirectoryExists: fs.existsSync(amplifyDir), - backendDirectoryExists: fs.existsSync(backendDir), - amplifyContents: amplifyContents, - backendContents: backendContents, - profileName: profileName, - environmentType: envDetectionResult ? 'ATMOSPHERE' : 'LOCAL', - }); - } catch (error) { - const duration = Date.now() - startTime; - console.error(`❌ Integration test failed after ${duration}ms:`, error); - - // Additional debugging information - console.log('🔍 Debug information:'); - console.log(`- Test directory exists: ${fs.existsSync(testDir)}`); - console.log(`- Test directory contents:`, fs.existsSync(testDir) ? fs.readdirSync(testDir) : 'N/A'); - console.log(`- Current working directory: ${process.cwd()}`); - console.log(`- Profile name: ${profileName}`); - console.log(`- Environment variables:`, { - NODE_ENV: process.env.NODE_ENV, - AWS_PROFILE: process.env.AWS_PROFILE, - AWS_REGION: process.env.AWS_REGION, - CDK_INTEG_ATMOSPHERE_POOL: process.env.CDK_INTEG_ATMOSPHERE_POOL, - }); - - // If it's our timeout error, provide helpful guidance - if ((error as Error).message.includes('timed out after 90 seconds')) { - console.log('This timeout suggests an issue with the Atmosphere + Amplify integration.'); - console.log('Potential causes:'); - console.log(' - Atmosphere profile not properly written to ~/.aws files'); - console.log(' - Interactive prompts waiting for user input'); - console.log(' - AWS authentication issues with Atmosphere credentials'); - console.log(' - Network connectivity problems'); - } - - throw error; - } - }, 120000); // 2 minute Jest timeout (our internal timeout is 90 seconds) - }); - - describe('Error Handling Integration', () => { - it('should handle initialization failures gracefully with profile cleanup', async () => { - console.log('❌ Testing error handling integration...'); - - if (!atmosphereAvailable) { - console.log('⏭️ Skipping test - CDK Atmosphere client not available'); - return; - } - - const config: AppConfiguration = { - app: { - name: 'testerrorapp', - description: 'Test application for error handling', - framework: 'react', - }, - categories: {}, - }; - - // Test with invalid path to trigger error - const invalidTestDir = '/invalid/path/that/does/not/exist'; - console.log(`❌ Testing with invalid path: ${invalidTestDir}`); - - // Get a valid profile - cleanup will happen in afterEach - const profileName = await atmosphereIntegration.getProfileFromAllocation(); - console.log(`📝 Created profile: ${profileName}`); - - // This should fail due to invalid path, regardless of profile - try { - await amplifyInitializer.initializeApp({ - appPath: invalidTestDir, - config, - deploymentName: 'invalidPathApp', - envName: 'testenv', - profile: profileName, - }); - // If we get here, the test should fail - fail('Expected an error to be thrown for invalid path'); - } catch (error) { - console.log(`Expected error caught: ${(error as Error).message}`); - } - - console.log('✅ Error handling test passed'); - }); - }); - - describe('Profile Management', () => { - it('should create profile with correct format', async () => { - console.log('🔧 Testing profile name format...'); - - if (!atmosphereAvailable) { - console.log('⏭️ Skipping test - CDK Atmosphere client not available'); - return; - } - - const profileName = await atmosphereIntegration.getProfileFromAllocation(); - - // Verify profile name format: atmosphere-{timestamp}-{random} - expect(profileName).toMatch(/^atmosphere-\d+-[a-f0-9]+$/); - - console.log(`✅ Profile name format test passed: ${profileName}`); - }); - }); -}); diff --git a/packages/amplify-gen2-migration-e2e-system/src/__tests__/amplify-initializer.e2e.test.ts b/packages/amplify-gen2-migration-e2e-system/src/__tests__/amplify-initializer.e2e.test.ts deleted file mode 100644 index d0344540498..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/__tests__/amplify-initializer.e2e.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -/** - * E2E Tests for AmplifyInitializer - * These tests require the Amplify CLI to be installed and available - */ - -import { AmplifyInitializer } from '../core'; -import { Logger } from '../utils/logger'; -import { EnvironmentType, LogLevel } from '../types'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { execSync } from 'child_process'; - -import { generateTimeBasedE2EAmplifyAppName } from '../utils/math'; -import { EnvironmentDetector } from '../core/environment-detector'; - -const TEST_RUNNER_PROFILE = 'amplify1'; // this is the profile that will be read from your local system to deploy the Amplify App -const TEST_AMPLIFY_ENV_NAME = 'endtoend'; - -/** - * Helper function to extract the Amplify App ID from the local project files - */ -function getAmplifyAppId(projectDir: string): string | null { - try { - // Try to get app ID from team-provider-info.json - const teamProviderPath = path.join(projectDir, 'amplify', 'team-provider-info.json'); - if (fs.existsSync(teamProviderPath)) { - const teamProviderInfo = JSON.parse(fs.readFileSync(teamProviderPath, 'utf-8')); - // The structure is: { "envName": { "awscloudformation": { "AmplifyAppId": "..." } } } - for (const envName of Object.keys(teamProviderInfo)) { - const appId = teamProviderInfo[envName]?.awscloudformation?.AmplifyAppId; - if (appId) { - return appId; - } - } - } - - // Fallback: try to get from local-env-info.json - const localEnvPath = path.join(projectDir, 'amplify', '.config', 'local-env-info.json'); - if (fs.existsSync(localEnvPath)) { - const localEnvInfo = JSON.parse(fs.readFileSync(localEnvPath, 'utf-8')); - if (localEnvInfo.AmplifyAppId) { - return localEnvInfo.AmplifyAppId; - } - } - - return null; - } catch (error) { - console.warn(`⚠️ Could not extract Amplify App ID: ${(error as Error).message}`); - return null; - } -} - -/** - * Response type for delete-backend-environment AWS CLI command - */ -interface DeleteBackendEnvironmentResponse { - backendEnvironment: { - backendEnvironmentArn: string; - environmentName: string; - stackName?: string; - deploymentArtifacts?: string; - createTime: string; - updateTime: string; - }; -} - -/** - * Helper function to delete an Amplify app using AWS CLI - * First deletes the backend environment, then ensures the CloudFormation stack is deleted, - * and finally deletes the app itself - */ -function deleteAmplifyApp(appId: string, profile: string = TEST_RUNNER_PROFILE, envName: string = TEST_AMPLIFY_ENV_NAME): boolean { - try { - // First, delete the backend environment - console.log(`🗑️ Deleting backend environment: ${envName} for app: ${appId}`); - let stackName: string | undefined; - - try { - const deleteBackendEnvironmentOutputJson = execSync( - `aws amplify delete-backend-environment --app-id ${appId} --environment-name ${envName} --profile ${profile} --output json`, - { - stdio: 'pipe', - timeout: 60000, - }, - ).toString(); - - // Parse the response to get the stackName - const backendEnvResponse = JSON.parse(deleteBackendEnvironmentOutputJson) as DeleteBackendEnvironmentResponse; - stackName = backendEnvResponse.backendEnvironment.stackName; - - console.log(`✅ Successfully initiated backend environment deletion: ${envName}`); - if (stackName) { - console.log(` CloudFormation stack: ${stackName}`); - } - } catch (envError) { - throw new Error(`Failed to delete backend environment ${envName}: ${(envError as Error).message}`); - } - - // If we have a stackName, ensure the CloudFormation stack is deleted - if (stackName) { - console.log(`🗑️ Deleting CloudFormation stack: ${stackName}`); - try { - // Delete the CloudFormation stack - execSync(`aws cloudformation delete-stack --stack-name ${stackName} --profile ${profile}`, { - stdio: 'pipe', - timeout: 30000, - }); - console.log(`⏳ Waiting for CloudFormation stack deletion to complete...`); - - // Wait for the stack deletion to complete (timeout after 10 minutes) - execSync(`aws cloudformation wait stack-delete-complete --stack-name ${stackName} --profile ${profile}`, { - stdio: 'pipe', - timeout: 600000, // 10 minutes - }); - console.log(`✅ CloudFormation stack ${stackName} deleted successfully`); - } catch (stackError) { - const errorMessage = (stackError as Error).message; - throw new Error( - `Failed to delete CloudFormation stack ${stackName}. ` + - `This may leave orphaned resources in your AWS account. ` + - `Please manually check and delete the stack if necessary. ` + - `Error: ${errorMessage}`, - ); - } - } - - // Then delete the app - console.log(`🗑️ Deleting Amplify app: ${appId} using profile: ${profile}`); - execSync(`aws amplify delete-app --app-id ${appId} --profile ${profile}`, { - stdio: 'pipe', - timeout: 30000, - }); - - console.log(`✅ Successfully deleted Amplify app: ${appId}`); - return true; - } catch (error) { - console.warn(`⚠️ Failed to delete Amplify app ${appId}: ${(error as Error).message}`); - return false; - } -} - -/** - * Helper function to wait for a specified number of milliseconds - */ -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -describe('AmplifyInitializer E2E', () => { - let logger: Logger; - let amplifyInitializer: AmplifyInitializer; - let testDir: string; - let cliAvailable = false; - let environmentDetector: EnvironmentDetector; - let atmosphereAvailable = false; - - beforeAll(async () => { - console.log('🔍 Checking Amplify CLI availability...'); - - // Check if Amplify CLI is available - try { - const version = execSync('amplify --version', { stdio: 'pipe', timeout: 10000 }).toString().trim(); - console.log(`✅ Amplify CLI found: ${version}`); - cliAvailable = true; - } catch (error) { - console.log('❌ Amplify CLI not available, E2E tests will be skipped'); - console.log(`Error: ${(error as Error).message}`); - cliAvailable = false; - } - - // Check AWS CLI availability - try { - const awsVersion = execSync('aws --version', { stdio: 'pipe', timeout: 5000 }).toString().trim(); - console.log(`✅ AWS CLI found: ${awsVersion}`); - } catch (error) { - console.log('⚠️ AWS CLI not available - this may cause authentication issues'); - } - - // Check Node.js version - console.log(`📦 Node.js version: ${process.version}`); - console.log(`🏠 Test environment: ${process.env.NODE_ENV || 'development'}`); - - // Check if CDK Atmosphere client is available - try { - logger = new Logger(LogLevel.DEBUG); - environmentDetector = new EnvironmentDetector(logger); - const detectedEnvironment = await environmentDetector.detectEnvironment(); - atmosphereAvailable = detectedEnvironment === EnvironmentType.ATMOSPHERE; - } catch (error) { - throw Error(`❌ CDK Atmosphere client initialization failed: ${error}`); - } - }); - - beforeEach(() => { - console.log('🧪 Setting up test environment...'); - - logger = new Logger(LogLevel.DEBUG); - amplifyInitializer = new AmplifyInitializer(logger); - - // Create a temporary directory for testing - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amplify-init-test-')); - console.log(`📁 Created test directory: ${testDir}`); - - // Ensure test directory is writable - try { - const testFile = path.join(testDir, 'test-write.tmp'); - fs.writeFileSync(testFile, 'test'); - fs.unlinkSync(testFile); - console.log('✅ Test directory is writable'); - } catch (error) { - console.error('❌ Test directory is not writable:', error); - throw error; - } - }); - - afterEach(() => { - console.log('🧹 Cleaning up test environment...'); - - // Clean up test directory - if (testDir && fs.existsSync(testDir)) { - try { - console.log(`🗑️ Removing test directory: ${testDir}`); - fs.rmSync(testDir, { recursive: true, force: true }); - console.log('✅ Test directory cleaned up successfully'); - } catch (error) { - console.warn(`⚠️ Failed to clean up test directory: ${testDir}`, error); - } - } - }); - - describe('CLI availability', () => { - it('should detect if Amplify CLI is available', () => { - console.log('🔍 Testing CLI availability detection...'); - - // Check if Amplify CLI is available - let detectedCliAvailable = false; - try { - const version = execSync('amplify --version', { stdio: 'pipe', timeout: 10000 }).toString().trim(); - console.log(`✅ CLI version detected: ${version}`); - detectedCliAvailable = true; - } catch (error) { - console.log(`❌ CLI not detected: ${(error as Error).message}`); - detectedCliAvailable = false; - } - - // This test just verifies we can check CLI availability - expect(typeof detectedCliAvailable).toBe('boolean'); - console.log(`📊 CLI availability result: ${detectedCliAvailable}`); - }); - }); - - describe('initializeApp', () => { - it('should successfully initialize an Amplify app using profile', async () => { - if (atmosphereAvailable) { - console.log('⏭️ Skipping test - CDK Atmosphere available, this test is only for profile'); - return; - } - - console.log('🚀 Starting full Amplify initialization test...'); - - // Check if we should skip this test (set SKIP_AMPLIFY_INIT=true to skip) - if (process.env.SKIP_AMPLIFY_INIT === 'true') { - console.log('⏭️ Skipping test - SKIP_AMPLIFY_INIT environment variable is set'); - return; - } - - // Check if Amplify CLI is available - if (!cliAvailable) { - console.log('⏭️ Skipping test - Amplify CLI not available'); - return; - } - - console.log('✅ Amplify CLI is available, proceeding with test'); - - // Generate a unique alphanumeric app name (3-20 chars, alphanumeric only) - const appName = generateTimeBasedE2EAmplifyAppName('app-name'); - const profile = TEST_RUNNER_PROFILE; - - const config = { - app: { - name: appName, - description: 'E2E test application', - framework: 'react', - }, - categories: { - api: { - type: 'GraphQL' as const, - authModes: ['API_KEY' as const], - }, - }, - }; - - console.log(`📋 Test configuration:`, JSON.stringify(config, null, 2)); - console.log(`📁 Test directory: ${testDir}`); - console.log(`📝 App name: ${appName}`); - - // Add progress tracking - const startTime = Date.now(); - console.log(`⏰ Starting amplify init at ${new Date().toISOString()}`); - - // Create a timeout promise to race against the actual init - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('Amplify init timed out after 60 seconds - this suggests the initJSProjectWithProfile function is hanging')); - }, 60000); // 60 second internal timeout - }); - - try { - // Race the amplify init against our timeout - await Promise.race([ - amplifyInitializer.initializeApp({ appPath: testDir, config, deploymentName: appName, profile, envName: TEST_AMPLIFY_ENV_NAME }), - timeoutPromise, - ]); - - const duration = Date.now() - startTime; - console.log(`✅ Amplify init completed in ${duration}ms`); - - // Verify that amplify directory was created - const amplifyDir = path.join(testDir, 'amplify'); - const backendDir = path.join(testDir, 'amplify', 'backend'); - - console.log(`🔍 Checking for amplify directory: ${amplifyDir}`); - expect(fs.existsSync(amplifyDir)).toBe(true); - - console.log(`🔍 Checking for backend directory: ${backendDir}`); - expect(fs.existsSync(backendDir)).toBe(true); - - // List contents for debugging - try { - const amplifyContents = fs.readdirSync(amplifyDir); - console.log(`📂 Amplify directory contents:`, amplifyContents); - - if (fs.existsSync(backendDir)) { - const backendContents = fs.readdirSync(backendDir); - console.log(`📂 Backend directory contents:`, backendContents); - } - } catch (listError) { - console.warn('⚠️ Could not list directory contents:', listError); - } - - console.log('🎉 All verification checks passed!'); - - // Cleanup: Wait 20 seconds then delete the Amplify app from AWS - console.log('⏳ Waiting 20 seconds before cleanup...'); - await sleep(20000); - } catch (error) { - const duration = Date.now() - startTime; - console.error(`❌ Amplify init failed after ${duration}ms:`, error); - - // Additional debugging information - console.log('🔍 Debug information:'); - console.log(`- Test directory exists: ${fs.existsSync(testDir)}`); - console.log(`- Test directory contents:`, fs.existsSync(testDir) ? fs.readdirSync(testDir) : 'N/A'); - console.log(`- Current working directory: ${process.cwd()}`); - console.log(`- Environment variables:`, { - NODE_ENV: process.env.NODE_ENV, - AWS_PROFILE: process.env.AWS_PROFILE, - AWS_REGION: process.env.AWS_REGION, - SKIP_AMPLIFY_INIT: process.env.SKIP_AMPLIFY_INIT, - }); - - // If it's our timeout error, provide helpful guidance - if ((error as Error).message.includes('timed out after 60 seconds')) { - console.log('💡 This timeout suggests that initJSProjectWithProfile is hanging.'); - console.log('💡 Common causes:'); - console.log(' - Interactive prompts waiting for user input'); - console.log(' - AWS authentication issues'); - console.log(' - Network connectivity problems'); - console.log(' - Subprocess not terminating properly'); - console.log('💡 To skip this test, set SKIP_AMPLIFY_INIT=true'); - } - - throw error; - } finally { - // Always attempt cleanup if an app was created - const appId = getAmplifyAppId(testDir); - if (appId) { - console.log(`🧹 Final cleanup - Found Amplify App ID: ${appId}`); - deleteAmplifyApp(appId); - } - } - }, 180000); // 3 minute Jest timeout (includes 20 second cleanup delay) - }); -}); diff --git a/packages/amplify-gen2-migration-e2e-system/src/__tests__/amplify-initializer.test.ts b/packages/amplify-gen2-migration-e2e-system/src/__tests__/amplify-initializer.test.ts deleted file mode 100644 index 9ffbba95110..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/__tests__/amplify-initializer.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Tests for AmplifyInitializer - */ - -import { AmplifyInitializer } from '../core'; -import { Logger } from '../utils/logger'; -import { AppConfiguration, LogLevel } from '../types'; -import { initJSProjectWithProfile } from '@aws-amplify/amplify-e2e-core'; -import fs from 'fs'; - -// Mock the e2e-core functions -jest.mock('@aws-amplify/amplify-e2e-core', () => ({ - initJSProjectWithProfile: jest.fn(), - initProjectWithAccessKey: jest.fn(), -})); - -describe('AmplifyInitializer', () => { - let logger: Logger; - let amplifyInitializer: AmplifyInitializer; - - beforeEach(() => { - console.log('🧪 Setting up AmplifyInitializer unit test...'); - logger = new Logger(LogLevel.ERROR); // Use ERROR level to suppress logs during tests - amplifyInitializer = new AmplifyInitializer(logger); - jest.clearAllMocks(); - console.log('✅ Test setup complete'); - }); - - describe('buildInitSettings', () => { - it('should build correct settings for different app configurations', () => { - console.log('🔧 Testing buildInitSettings method...'); - - const config = { - app: { - name: 'customappname', - description: 'Custom application', - }, - categories: { - api: { - type: 'GraphQL' as const, - authModes: ['COGNITO_USER_POOLS' as const], - }, - auth: { - signInMethods: ['email' as const], - socialProviders: [], - }, - }, - }; - - console.log(`📋 Input configuration:`, JSON.stringify(config, null, 2)); - - const deploymentName = 'customAppDeployName'; - - console.log(`📝 Deployment name: ${deploymentName}`); - - const profile = 'test-profile'; - const envName = 'testenv'; - console.log(`📝 Profile: ${profile}`); - - const settings = (amplifyInitializer as any).buildInitSettings({ config, deploymentName, profile, envName }); - - console.log(`⚙️ Generated settings:`, JSON.stringify(settings, null, 2)); - - expect(settings.name).toBe(deploymentName); - expect(settings.envName).toBe('testenv'); - expect(settings.framework).toBe('react'); - expect(settings.editor).toBe('Visual Studio Code'); - expect(settings.srcDir).toBe('src'); - expect(settings.distDir).toBe('dist'); - expect(settings.buildCmd).toBe('npm run build'); - expect(settings.startCmd).toBe('npm run start'); - expect(settings.profileName).toBe('test-profile'); - - console.log('✅ All settings validation checks passed'); - }); - }); - - describe('initializeApp', () => { - it('should call initJSProjectWithProfile with correct settings', async () => { - console.log('🚀 Testing initializeApp with mocked initJSProjectWithProfile...'); - - const config: AppConfiguration = { - app: { - name: 'mytestapp', - description: 'Test application', - framework: 'react', - }, - categories: {}, - }; - - const appPath = '/path/to/app'; - - console.log('📋 Test config:', JSON.stringify(config, null, 2)); - console.log('📁 App path:', appPath); - - // Mock fs operations for path validation - jest.spyOn(fs, 'existsSync').mockReturnValue(true); - jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); - jest.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); - - const deploymentName = 'mytestapp'; - const profile = 'test-profile'; - const envName = 'testenv'; - const startTime = Date.now(); - await amplifyInitializer.initializeApp({ - appPath, - config, - deploymentName, - envName, - profile, - }); - const duration = Date.now() - startTime; - - console.log(`⏰ Test completed in ${duration}ms`); - - expect(initJSProjectWithProfile).toHaveBeenCalledWith( - appPath, - expect.objectContaining({ - name: 'mytestapp', - envName: 'testenv', - editor: 'Visual Studio Code', - framework: 'react', - srcDir: 'src', - distDir: 'dist', - buildCmd: 'npm run build', - startCmd: 'npm run start', - profileName: 'test-profile', - disableAmplifyAppCreation: false, - }), - ); - - console.log('✅ initializeApp test passed'); - }); - - it('should handle errors from initJSProjectWithProfile', async () => { - console.log('❌ Testing error handling in initializeApp...'); - - const error = new Error('Init failed'); - // @ts-ignore - initJSProjectWithProfile.mockRejectedValue(error); - - const config: AppConfiguration = { - app: { - name: 'testapp', - description: 'Test application', - framework: 'react', - }, - categories: {}, - }; - - // Mock fs operations for path validation - jest.spyOn(fs, 'existsSync').mockReturnValue(true); - jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); - jest.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); - - console.log('🧪 Expecting initialization to fail...'); - const deploymentName = 'testapp'; - const profile = 'test-profile'; - const envName = 'testenv'; - - await expect(amplifyInitializer.initializeApp({ appPath: '/path/to/app', config, deploymentName, envName, profile })).rejects.toThrow( - error, - ); - - console.log('✅ Error handling test passed'); - }); - - it('should validate app name constraints', async () => { - console.log('🔍 Testing app name validation...'); - - // Test invalid names - const invalidNames = [ - { name: 'ap', expectedError: 'App name must be between 3-20 characters' }, - { name: 'verylongapplicationnamethatexceedslimit', expectedError: 'App name must be between 3-20 characters' }, - { name: 'my-app', expectedError: 'App name must contain only alphanumeric characters' }, - { name: 'my_app', expectedError: 'App name must contain only alphanumeric characters' }, - { name: 'app@123', expectedError: 'App name must contain only alphanumeric characters' }, - { name: '', expectedError: 'App name is required' }, - ]; - - for (const { name, expectedError } of invalidNames) { - const config = { - app: { name, description: 'Test app', framework: 'react' }, - categories: {}, - }; - - const profile = 'test-profile'; - const envName = 'testenv'; - - // Use the invalid name as the deploymentName to test validation - await expect( - amplifyInitializer.initializeApp({ appPath: '/valid/path', config, deploymentName: name, envName, profile }), - ).rejects.toThrow(expectedError); - - console.log(` ❌ Correctly rejected: "${name}"`); - } - }); - }); -}); diff --git a/packages/amplify-gen2-migration-e2e-system/src/__tests__/cdk-atmosphere-integration.test.ts b/packages/amplify-gen2-migration-e2e-system/src/__tests__/cdk-atmosphere-integration.test.ts deleted file mode 100644 index 74cba8496d6..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/__tests__/cdk-atmosphere-integration.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Test for the fixed CDK Atmosphere Integration - * This test verifies that our integration works correctly with the proper API usage - */ - -import { CDKAtmosphereIntegration } from '../core/cdk-atmosphere-integration'; -import { EnvironmentDetector } from '../core/environment-detector'; -import { Logger } from '../utils/logger'; -import { AtmosphereAllocation } from '../types'; - -// Test helper functions -function hasValidCredentials(allocation: AtmosphereAllocation | undefined): boolean { - return !!allocation && !!allocation.accessKeyId && !!allocation.secretAccessKey; -} - -function getCredentialsSummary(allocation: AtmosphereAllocation | undefined): Record { - if (!allocation) { - return { status: 'no-credentials' }; - } - - return { - status: 'credentials-available', - region: allocation.region, - hasAccessKey: !!allocation.accessKeyId, - hasSecretKey: !!allocation.secretAccessKey, - hasSessionToken: !!allocation.sessionToken, - }; -} - -describe('CDK Atmosphere Integration', () => { - let integration: CDKAtmosphereIntegration; - let logger: Logger; - let environmentDetector: EnvironmentDetector; - let cachedAllocation: AtmosphereAllocation | undefined; - - beforeEach(() => { - logger = new Logger(); - environmentDetector = new EnvironmentDetector(logger); - integration = new CDKAtmosphereIntegration(logger, environmentDetector); - cachedAllocation = undefined; - }); - - afterEach(async () => { - // Clean up any resources - try { - await integration.cleanup(); - } catch (error) { - // Ignore cleanup errors in tests - } - }); - - describe('Environment Detection', () => { - it('should detect if we are in an Atmosphere environment', async () => { - const isAtmosphere = await integration.isAtmosphereEnvironment(); - console.log(`Is Atmosphere environment: ${isAtmosphere}`); - - // This should return true if CDK client is available and we're in the right environment - expect(typeof isAtmosphere).toBe('boolean'); - }); - }); - - describe('Atmosphere Integration', () => { - it('should attempt to initialize for Atmosphere environment', async () => { - const isAtmosphere = await integration.isAtmosphereEnvironment(); - - console.log(`Environment type: ${isAtmosphere ? 'Atmosphere' : 'Local'}`); - - if (!isAtmosphere) { - console.log('Skipping Atmosphere initialization (not in Atmosphere environment or client not available)'); - } - - console.log('Attempting to initialize for Atmosphere environment...'); - - try { - const allocation = await integration.initializeForAtmosphere(); - cachedAllocation = allocation; - - console.log('✅ Successfully initialized Atmosphere client!'); - console.log('Credentials summary:', { - hasAccessKey: !!allocation.accessKeyId, - hasSecretKey: !!allocation.secretAccessKey, - hasSessionToken: !!allocation.sessionToken, - region: allocation.region, - }); - - expect(allocation).toBeDefined(); - expect(allocation.accessKeyId).toBeDefined(); - expect(allocation.secretAccessKey).toBeDefined(); - expect(allocation.region).toBeDefined(); - } catch (error) { - console.log(`Failed to initialize Atmosphere client: ${(error as Error).message}`); - - // Check for common issues - if ((error as Error).message.includes('dynamic import')) { - throw Error('The CDK Atmosphere client requires the Node.js --experimental-vm-modules flag'); - } else if ((error as Error).message.includes('pool')) { - throw Error('Pool access issue. Check if CDK_INTEG_ATMOSPHERE_POOL is set and accessible'); - } else if ((error as Error).message.includes('timeoutSeconds')) { - throw Error('Parameter issue. The acquire() method expects proper parameters (pool and requester)'); - } - } - }); - - it('should get profile from allocation', async () => { - const isAtmosphere = await integration.isAtmosphereEnvironment(); - - if (!isAtmosphere) { - console.log('Skipping test, not in Atmosphere environment or client not available.'); - return; - } - - try { - const profileName = await integration.getProfileFromAllocation(); - - console.log('✅ Successfully got profile from allocation!'); - console.log('Profile name:', profileName); - - expect(profileName).toBeDefined(); - expect(typeof profileName).toBe('string'); - expect(profileName).toMatch(/^atmosphere-\d+-[a-f0-9]+$/); - } catch (error) { - throw Error(`Failed to get profile from allocation: ${(error as Error).message}`); - } - }); - }); - - describe('Utility Methods', () => { - it('should provide credentials status', () => { - const hasCredentials = hasValidCredentials(cachedAllocation); - console.log(`Has valid credentials: ${hasCredentials}`); - - expect(typeof hasCredentials).toBe('boolean'); - }); - - it('should provide credentials summary', () => { - const summary = getCredentialsSummary(cachedAllocation); - console.log('Credentials summary:', JSON.stringify(summary, null, 2)); - - expect(summary).toBeDefined(); - expect(summary.status).toBeDefined(); - }); - }); - - describe('Environment Summary', () => { - it('should provide complete environment summary', async () => { - const envType = await environmentDetector.detectEnvironment(); - - const summary = { - type: envType, - nodeVersion: process.version, - platform: process.platform, - architecture: process.arch, - workingDirectory: process.cwd(), - awsRegion: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1', - hasAWSCredentials: !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY), - isCI: !!process.env.CI, - }; - - console.log('Environment summary:', JSON.stringify(summary, null, 2)); - - expect(summary.type).toBeDefined(); - expect(summary.nodeVersion).toBeDefined(); - }); - }); -}); diff --git a/packages/amplify-gen2-migration-e2e-system/src/__tests__/configuration-loader.test.ts b/packages/amplify-gen2-migration-e2e-system/src/__tests__/configuration-loader.test.ts deleted file mode 100644 index b23c3b7a649..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/__tests__/configuration-loader.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Tests for ConfigurationLoader - * **Feature: amplify-gen1-to-gen2-migration-script, Property 1: Configuration validation consistency** - */ - -import { ConfigurationLoader } from '../core/configuration-loader'; -import { Logger } from '../utils/logger'; -import { FileManager } from '../utils/file-manager'; -import { LogLevel, AppConfiguration } from '../types'; - -describe('ConfigurationLoader', () => { - let logger: Logger; - let fileManager: FileManager; - let configLoader: ConfigurationLoader; - - beforeEach(() => { - logger = new Logger(LogLevel.ERROR); // Suppress logs during tests - fileManager = new FileManager(logger); - configLoader = new ConfigurationLoader(logger, fileManager, './test-apps'); - }); - - describe('validateConfiguration', () => { - it('should validate a complete valid configuration', () => { - const validConfig: AppConfiguration = { - app: { - name: 'testapp', - description: 'Test application', - framework: 'react', - }, - categories: { - api: { - type: 'GraphQL', - authModes: ['API_KEY', 'COGNITO_USER_POOLS'], - }, - auth: { - signInMethods: ['email'], - socialProviders: [], - }, - storage: { - buckets: [ - { - name: 'test-bucket', - access: ['auth', 'guest'], - }, - ], - }, - function: { - functions: [ - { - name: 'test-function', - runtime: 'nodejs', - }, - ], - }, - }, - }; - - const result = configLoader.validateConfiguration(validConfig); - expect(result.errors).toHaveLength(0); - }); - - it('should reject configuration without app metadata', () => { - const invalidConfig = { - categories: {}, - } as AppConfiguration; - - const result = configLoader.validateConfiguration(invalidConfig); - expect(result.errors).toContain('App metadata is required'); - }); - - it('should reject configuration without categories', () => { - const invalidConfig = { - app: { - name: 'testapp', - description: 'Test application', - }, - } as AppConfiguration; - - const result = configLoader.validateConfiguration(invalidConfig); - expect(result.errors).toContain('Categories configuration is required'); - }); - }); -}); diff --git a/packages/amplify-gen2-migration-e2e-system/src/__tests__/directory-manager.test.ts b/packages/amplify-gen2-migration-e2e-system/src/__tests__/directory-manager.test.ts deleted file mode 100644 index 99978bcbced..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/__tests__/directory-manager.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Integration Tests for DirectoryManager - * Tests DirectoryManager integration with existing system components - */ - -import * as fs from 'fs-extra'; -import * as path from 'path'; -import * as os from 'os'; -import { DirectoryManager } from '../utils/directory-manager'; -import { Logger } from '../utils/logger'; -import { LogLevel } from '../types'; - -describe('DirectoryManager Integration', () => { - let logger: Logger; - let directoryManager: DirectoryManager; - let tempDir: string; - - beforeEach(async () => { - logger = new Logger(LogLevel.INFO); - directoryManager = new DirectoryManager(logger); - - // Create a temporary directory for testing - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'directory-manager-integration-')); - }); - - afterEach(async () => { - // Clean up temporary directory - if (await fs.pathExists(tempDir)) { - await fs.remove(tempDir); - } - }); - - describe('createAppDirectory', () => { - it('should create a new directory successfully', async () => { - const result = await directoryManager.createAppDirectory({ - basePath: tempDir, - appName: 'testapp', - }); - - expect(result).toBe(path.join(tempDir, 'testapp')); - - // Verify directory exists - expect(await fs.pathExists(result)).toBe(true); - expect((await fs.stat(result)).isDirectory()).toBe(true); - }); - - it('should throw error when colliding with existing directory', async () => { - const appPath = path.join(tempDir, 'testapp'); - - // Create existing directory with content - await fs.ensureDir(appPath); - await fs.writeFile(path.join(appPath, 'existing-file.txt'), 'content'); - - await expect( - directoryManager.createAppDirectory({ - basePath: tempDir, - appName: 'testapp', - }), - ).rejects.toThrow(`Failed to create app directory: Directory already exists: ${appPath}`); - - // Verify directory exists and old content is still present - expect(await fs.pathExists(appPath)).toBe(true); - expect(await fs.pathExists(path.join(appPath, 'existing-file.txt'))).toBe(true); - }); - }); - - describe('copyDirectory', () => { - it('should copy directory and all contents', async () => { - const sourceDir = path.join(tempDir, 'source'); - const destDir = path.join(tempDir, 'destination'); - - // Create source directory with content - await fs.ensureDir(path.join(sourceDir, 'subdir')); - await fs.writeFile(path.join(sourceDir, 'file.txt'), 'content'); - await fs.writeFile(path.join(sourceDir, 'subdir', 'nested.txt'), 'nested content'); - - await directoryManager.copyDirectory(sourceDir, destDir); - - // Verify destination exists and has same content - expect(await fs.pathExists(destDir)).toBe(true); - expect(await fs.pathExists(path.join(destDir, 'file.txt'))).toBe(true); - expect(await fs.pathExists(path.join(destDir, 'subdir', 'nested.txt'))).toBe(true); - - const copiedContent = await fs.readFile(path.join(destDir, 'file.txt'), 'utf-8'); - expect(copiedContent).toBe('content'); - }); - - it('should fail when source does not exist', async () => { - const nonExistentSource = path.join(tempDir, 'non-existent'); - const destDir = path.join(tempDir, 'destination'); - - await expect(directoryManager.copyDirectory(nonExistentSource, destDir)).rejects.toThrow('Source directory does not exist'); - }); - }); - - it('should integrate with Logger for comprehensive logging', async () => { - // Test that DirectoryManager properly logs operations through the Logger - const result = await directoryManager.createAppDirectory({ - basePath: tempDir, - appName: 'integrationtestapp', - }); - - expect(result).toBeDefined(); - - const expectedPath = path.join(tempDir, 'integrationtestapp'); - expect(result).toBe(expectedPath); - - // Verify directory was created - expect(await fs.pathExists(expectedPath)).toBe(true); - }); - - it('should handle realistic app initialization workflow', async () => { - // Simulate a realistic workflow similar to what AmplifyInitializer would do - - // Step 1: Create app directory - const createResult = await directoryManager.createAppDirectory({ - basePath: tempDir, - appName: 'myamplifyapp', - }); - - expect(createResult).toBeDefined(); - - // Step 2: Simulate creating some app files (like package.json) - const packageJsonPath = path.join(createResult, 'package.json'); - await fs.writeFile( - packageJsonPath, - JSON.stringify( - { - name: 'myamplifyapp', - version: '1.0.0', - dependencies: {}, - }, - null, - 2, - ), - ); - - // Step 3: Verify files exist - expect(await fs.pathExists(packageJsonPath)).toBe(true); - - // Step 4: Fail when trying to create same app again - await expect( - directoryManager.createAppDirectory({ - basePath: tempDir, - appName: 'myamplifyapp', - }), - ).rejects.toThrow('Failed to create app directory: Directory already exists:'); - }); - - it('should handle directory copying for app migration scenarios', async () => { - // Simulate copying an existing Gen1 app to a new location for Gen2 migration - - // Create a mock Gen1 app structure - const gen1AppDir = path.join(tempDir, 'gen1app'); - await fs.ensureDir(gen1AppDir); - await fs.ensureDir(path.join(gen1AppDir, 'amplify', 'backend')); - await fs.writeFile( - path.join(gen1AppDir, 'package.json'), - JSON.stringify( - { - name: 'gen1app', - dependencies: { - '@aws-amplify/cli': '^4.0.0', - }, - }, - null, - 2, - ), - ); - await fs.writeFile( - path.join(gen1AppDir, 'amplify', 'backend', 'backend-config.json'), - JSON.stringify( - { - api: {}, - auth: {}, - }, - null, - 2, - ), - ); - - // Create migration target directory - const migrationResult = await directoryManager.createAppDirectory({ - basePath: tempDir, - appName: 'gen2migrationtarget', - }); - - expect(migrationResult).toBeDefined(); - - // Clean the target directory and copy Gen1 app - await directoryManager.copyDirectory(gen1AppDir, migrationResult); - - // Verify the copy was successful - expect(await fs.pathExists(path.join(migrationResult, 'package.json'))).toBe(true); - expect(await fs.pathExists(path.join(migrationResult, 'amplify', 'backend', 'backend-config.json'))).toBe(true); - - // Verify content is correct - const copiedPackageJson = await fs.readJson(path.join(migrationResult, 'package.json')); - expect(copiedPackageJson.name).toBe('gen1app'); - }); -}); diff --git a/packages/amplify-gen2-migration-e2e-system/src/cli.ts b/packages/amplify-gen2-migration-e2e-system/src/cli.ts index ab905c5ca28..7d1c2b69688 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/cli.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/cli.ts @@ -1,657 +1,127 @@ #!/usr/bin/env node -/** - * CLI entry point for the Amplify Migration System - */ - // eslint-disable-next-line spellcheck/spell-checker import * as yargs from 'yargs'; import chalk from 'chalk'; -import { Logger } from './utils/logger'; -import { FileManager } from './utils/file-manager'; -import { ConfigurationLoader } from './core/configuration-loader'; -import { EnvironmentDetector } from './core/environment-detector'; -import { AppSelector } from './core/app-selector'; -import { AmplifyInitializer } from './core/amplify-initializer'; -import { CategoryInitializer } from './core/category-initializer'; -import { DirectoryManager } from './utils/directory-manager'; -import { CDKAtmosphereIntegration } from './core/cdk-atmosphere-integration'; -import { Gen2MigrationExecutor } from './core/gen2-migration-executor'; -import execa from 'execa'; -import { LogLevel, CLIOptions, AppConfiguration, EnvironmentType, InitializeAppFromCLIParams } from './types'; -import { generateTimeBasedE2EAmplifyAppName } from './utils/math'; -import path from 'path'; -import os from 'os'; -import fs from 'fs'; -import * as fsExtra from 'fs-extra'; -import { getCLIPath } from '@aws-amplify/amplify-e2e-core'; - -/** Options passed to app-specific post-generate scripts */ -interface PostGenerateOptions { - appPath: string; - envName?: string; -} - -/** Options passed to app-specific post-refactor scripts */ -interface PostRefactorOptions { - appPath: string; - envName?: string; -} - -/** Shape of an app's post-generate module */ -interface PostGenerateModule { - postGenerate: (options: PostGenerateOptions) => Promise; -} - -/** Shape of an app's post-refactor module */ -interface PostRefactorModule { - postRefactor: (options: PostRefactorOptions) => Promise; -} - -// Initialize core components -const logger = new Logger(LogLevel.INFO); -const fileManager = new FileManager(logger); -const configurationLoader = new ConfigurationLoader(logger, fileManager); -const environmentDetector = new EnvironmentDetector(logger); -const appSelector = new AppSelector(logger, fileManager); -const amplifyInitializer = new AmplifyInitializer(logger); -const categoryInitializer = new CategoryInitializer(logger); -const directoryManager = new DirectoryManager(logger); -const cdkAtmosphereIntegration = new CDKAtmosphereIntegration(logger, environmentDetector); -const gen2MigrationExecutor = new Gen2MigrationExecutor(logger); - -// Default migration target directory -const MIGRATION_TARGET_DIR = path.join(os.tmpdir(), 'amplify-gen2-migration-e2e-system', 'output-apps'); +import { App } from './core/app'; async function main(): Promise { - try { - // eslint-disable-next-line spellcheck/spell-checker - const argv = await yargs - .scriptName('amplify-migrate') - .usage('$0 [options]') - .option('app', { - alias: 'a', - type: 'string', - description: 'App to migrate (e.g., project-boards)', - string: true, - }) - .option('dry-run', { - alias: 'd', - type: 'boolean', - description: 'Show what would be done without executing', - default: false, - }) - .option('verbose', { - alias: 'v', - type: 'boolean', - description: 'Enable verbose logging', - default: false, - }) - .option('profile', { - type: 'string', - description: 'AWS profile to use', - string: true, - }) - .option('atmosphere', { - type: 'boolean', - description: 'Use atmosphere credentials in execution environment', - }) - .option('envName', { - type: 'string', - description: 'Amplify env name to create', - string: true, - }) - .option('list-apps', { - alias: 'l', - type: 'boolean', - description: 'List available apps and exit', - default: false, - }) - .help() - .alias('help', 'h') - .version() - .alias('version', 'V') - .example('$0 -a project-boards', 'Migrate specific app') - .example('$0 --dry-run', 'Show what would be done') - .example('$0 --list-apps', 'List all available apps').argv; - - // Set log level based on verbose flag - if (argv.verbose) { - logger.setLogLevel(LogLevel.DEBUG); - } - - // Print banner - printBanner(); - - // Handle special commands - if (argv['list-apps']) { - await handleListApps(); - return; - } - - // Validate required options for migration - if (!argv.app) { - logger.error('Error: --app is required for migration'); - process.exit(1); - } - - if (!argv.profile && !argv.atmosphere) { - throw new Error('Either --profile or --atmosphere must be specified'); - } - - // Build CLI options - const options: CLIOptions = { - app: argv.app, - dryRun: argv['dry-run'], - verbose: argv.verbose, - profile: argv.profile, - isAtmosphere: argv.atmosphere, - envName: argv.envName, - }; - - // Select apps to process - logger.debug('Selecting apps for migration...'); - await appSelector.validateAppExists(options.app); - const selectedApp = options.app; - const deploymentName = generateTimeBasedE2EAmplifyAppName(selectedApp); - - logger.setAppName(deploymentName); - - // Detect environment and get credentials if needed - logger.debug('Detecting execution environment...'); - let environment; - - if (argv.profile) { - environment = EnvironmentType.LOCAL; - } else if (argv.atmosphere) { - environment = EnvironmentType.ATMOSPHERE; - const didValidateAtmosphereEnvVars = await environmentDetector.isAtmosphereEnvironment(); - if (!didValidateAtmosphereEnvVars) { - throw Error('Atmosphere environment requested but required environment variables are not set'); - } else { - logger.info('Atmosphere environment validated successfully'); - } - } - - // Get appropriate credentials based on environment - let profile: string; - const isAtmosphereEnv = environment === EnvironmentType.ATMOSPHERE; - - if (isAtmosphereEnv) { - logger.info('Atmosphere environment detected - obtaining atmosphere credentials...'); - - try { - profile = await cdkAtmosphereIntegration.getProfileFromAllocation(); - logger.info(`Successfully created Atmosphere AWS profile: ${profile}`); - } catch (atmosphereError) { - logger.error(`Failed to get atmosphere credentials: ${(atmosphereError as Error).message}`); - logger.error('Cannot proceed without atmosphere credentials in atmosphere environment'); - throw new Error(`Atmosphere environment detected but credentials unavailable: ${(atmosphereError as Error).message}`); - } - } else { - logger.info(`Local environment detected - will use local AWS profile: ${options.profile} for credentials`); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - profile = options.profile!; - } - - // Generate envName if not provided via CLI - const envName = options.envName ?? AmplifyInitializer.generateRandomEnvName(); - logger.info(`Using Amplify environment name: ${envName}`); - - // Enable file logging - const logDir = path.join(os.tmpdir(), 'amplify-gen2-migration-e2e-system', 'logs'); - const logFile = path.join(logDir, `${deploymentName}.log`); - - logger.setLogFilePath(logFile); - - // Load configuration for selected app - logger.debug('Loading app configuration...'); - - let config: AppConfiguration | undefined; - - try { - config = await configurationLoader.loadAppConfiguration(selectedApp); - } catch (error) { - logger.error(`Failed to load configuration for ${selectedApp}`, error as Error); - throw error; - } - - // Handle dry-run mode - if (options.dryRun) { - logger.info('Dry run mode - showing what would be done:'); - await showDryRunSummary(selectedApp, config); - return; - } - - // Initialize app - const migrationTargetPath = MIGRATION_TARGET_DIR; - - try { - await initializeAppFromCLI({ appName: selectedApp, deploymentName, config, migrationTargetPath, envName, profile }); - } finally { - // Cleanup atmosphere profile if we created one - if (isAtmosphereEnv) { - logger.info('Cleaning up atmosphere resources...'); - try { - await cdkAtmosphereIntegration.cleanup(); - logger.info('Successfully cleaned up atmosphere resources'); - } catch (cleanupError) { - logger.warn(`Failed to cleanup atmosphere resources: ${(cleanupError as Error).message}`); - } - } - } - } catch (error) { - logger.error('Migration failed', error as Error); + // eslint-disable-next-line spellcheck/spell-checker + const argv = await yargs + .scriptName('amplify-migrate') + .usage('$0 [options]') + .option('app', { + alias: 'a', + type: 'string', + description: 'App to migrate (e.g., project-boards)', + string: true, + }) + .option('verbose', { + alias: 'v', + type: 'boolean', + description: 'Enable verbose logging', + default: false, + }) + .option('profile', { + type: 'string', + description: 'AWS profile to use', + string: true, + }) + .help() + .alias('help', 'h') + .version() + .alias('version', 'V') + .example('$0 -a project-boards --profile default', 'Migrate specific app').argv; + + printBanner(); + + if (!argv.app) { + console.error('Error: --app is required'); process.exit(1); } -} - -function printBanner(): void { - console.log( - chalk.cyan(` -╔══════════════════════════════════════════════════════════════╗ -║ ║ -║ AWS Amplify Gen1 to Gen2 Migration System ║ -║ ║ -║ Automation for migrating Amplify applications from ║ -║ Gen1 to Gen2 ║ -║ ║ -╚══════════════════════════════════════════════════════════════╝ -`), - ); -} - -async function handleListApps(): Promise { - logger.info('Listing available apps...'); + if (!argv.profile) { + throw new Error('--profile must be specified'); + } + const app = new App(argv.app, argv.profile, argv.verbose); try { - const availableApps = await appSelector.discoverAvailableApps(); - - if (availableApps.length === 0) { - console.log(chalk.yellow('No apps found in the apps directory')); - return; - } - - console.log(chalk.green(`\nFound ${availableApps.length} available apps:\n`)); - - for (const appName of availableApps) { - const appPath = appSelector.getAppPath(appName); - console.log(` ${chalk.cyan(appName.padEnd(10))} ${appPath}`); - } + await migrate(app); + app.logger.info('Migration completed successfully'); } catch (error) { - logger.error('Failed to list apps', error as Error); - process.exit(1); - } -} - -async function showDryRunSummary(selectedApp: string, config?: AppConfiguration): Promise { - console.log(chalk.yellow('\n=== DRY RUN SUMMARY ===\n')); - console.log(''); - - console.log(chalk.cyan('Initialization Actions:')); - console.log(` Migration target directory: ${MIGRATION_TARGET_DIR}`); - console.log(''); - console.log(chalk.cyan(`${selectedApp}:`)); - - if (config) { - const appConfig = config as AppConfiguration; - const categories = Object.keys(appConfig.categories || {}); - const sourceDir = appSelector.getAppPath(selectedApp); - - console.log(` Config app name: ${appConfig.app.name}`); - console.log(` Source directory: ${sourceDir}`); - console.log(` Would copy to: ${MIGRATION_TARGET_DIR}/`); - console.log(` Would run: amplify init with generated deployment name`); - console.log(` Categories: ${categories.join(', ') || 'None'}`); - } else { - console.log(chalk.red(' Configuration not loaded')); - } - console.log(''); - - console.log(chalk.yellow('=== END DRY RUN SUMMARY ===\n')); -} - -/** - * Run the app-specific post-generate script if it exists. - * Each app in amplify-migration-apps can have a post-generate.ts that applies - * manual edits required after `amplify gen2-migration generate`. - */ -async function runPostGenerateScript(appName: string, targetAppPath: string, envName?: string): Promise { - const sourceAppPath = appSelector.getAppPath(appName); - const postGeneratePath = path.join(sourceAppPath, 'post-generate.ts'); - - if (!fs.existsSync(postGeneratePath)) { - logger.debug(`No post-generate script found for ${appName} at ${postGeneratePath}`); - return; - } - - logger.info(`Running post-generate script for ${appName}...`); - - const postGenerateModule = (await import(postGeneratePath)) as PostGenerateModule; - - if (typeof postGenerateModule.postGenerate !== 'function') { - throw new Error(`post-generate.ts for ${appName} does not export a postGenerate function`); - } - - await postGenerateModule.postGenerate({ appPath: targetAppPath, envName }); - logger.info(`Post-generate script completed for ${appName}`); -} - -/** - * Run the app-specific post-refactor script if it exists. - * Each app in amplify-migration-apps can have a post-refactor.ts that applies - * manual edits required after `amplify gen2-migration refactor`. - */ -async function runPostRefactorScript(appName: string, targetAppPath: string, envName?: string): Promise { - const sourceAppPath = appSelector.getAppPath(appName); - const postRefactorPath = path.join(sourceAppPath, 'post-refactor.ts'); - - if (!fs.existsSync(postRefactorPath)) { - logger.debug(`No post-refactor script found for ${appName} at ${postRefactorPath}`); - return; - } - - logger.info(`Running post-refactor script for ${appName}...`); - - const postRefactorModule = (await import(postRefactorPath)) as PostRefactorModule; - - if (typeof postRefactorModule.postRefactor !== 'function') { - throw new Error(`post-refactor.ts for ${appName} does not export a postRefactor function`); - } - - await postRefactorModule.postRefactor({ appPath: targetAppPath, envName }); - logger.info(`Post-refactor script completed for ${appName}`); -} - -/** - * Run the app's gen1-test-script.ts to validate the Gen1 deployment. - * - * Copies _test-common to the migration target directory so relative - * imports like ../_test-common resolve, then executes the test script - * via npx tsx from the target app directory. - */ -async function runGen1TestScript(targetAppPath: string, migrationTargetPath: string, sourceAppsBasePath: string): Promise { - const testScriptName = 'gen1-test-script.ts'; - - // Copy _test-common so ../_test-common imports resolve from the target app dir - const testCommonSource = path.join(sourceAppsBasePath, '_test-common'); - const testCommonDest = path.join(migrationTargetPath, '_test-common'); - logger.info(`Copying _test-common to ${testCommonDest}`); - await fsExtra.copy(testCommonSource, testCommonDest, { overwrite: true }); - - // Install dependencies for the test script (aws-amplify, etc.) - logger.info(`Installing dependencies in ${targetAppPath}`); - await execa('npm', ['install'], { cwd: targetAppPath }); - - // Install dependencies for _test-common - logger.info(`Installing _test-common dependencies in ${testCommonDest}`); - await execa('npm', ['install'], { cwd: testCommonDest }); - - logger.info(`Running ${testScriptName} in ${targetAppPath}`); - const result = await execa('npx', ['tsx', testScriptName], { - cwd: targetAppPath, - stdio: 'inherit', - reject: false, - env: { ...process.env, AWS_SDK_LOAD_CONFIG: '1' }, - }); - - if (result.exitCode !== 0) { - throw new Error(`${testScriptName} failed with exit code ${result.exitCode}\n`); + (error as Error).message = `Migration failed: ${chalk.red((error as Error).message)} (${app.targetAppPath})`; + throw error; } - - logger.info(`${testScriptName} completed successfully`); } -/** - * Run the app's gen2-test-script.ts to validate the Gen2 deployment. - * - * Same pattern as runGen1TestScript but runs gen2-test-script.ts instead. - * Copies _test-common and installs deps before executing. - */ -async function runGen2TestScript(targetAppPath: string, migrationTargetPath: string, sourceAppsBasePath: string): Promise { - const testScriptName = 'gen2-test-script.ts'; - - // Check if the gen2 test script exists - if (!fs.existsSync(path.join(targetAppPath, testScriptName))) { - logger.debug(`No ${testScriptName} found in ${targetAppPath}, skipping`); +async function migrate(app: App): Promise { + app.logger.info(`Starting migration`); + + await app.gitInit(); + await app.init(); + await app.configure(); + await app.installDeps(); + await app.status(); + await app.prePush(); + await app.push(); + await app.postPush(); + await app.gitCommit('chore: post push'); + + await app.testGen1(); + + await app.assess(); + await app.lock(); + await app.gitCheckoutGen2(true); + await app.generate(); + await app.gitCommit('chore: generate'); + await app.installDeps(); + await app.gitCommit('chore: install dependencies'); + await app.postGenerate(); + await app.gitDiff(); + await app.gitCommit('chore: post generate'); + await app.preSandbox(); + const gen2StackName = await app.deployGen2Sandbox(); + await app.postSandbox(gen2StackName); + + await app.testGen1(); + await app.testGen2(); + + if (app.skipRefactor) { + app.logger.info('Skipping refactor (configured in migration/config.json)'); return; } - // Copy _test-common so ../_test-common imports resolve from the target app dir - const testCommonSource = path.join(sourceAppsBasePath, '_test-common'); - const testCommonDest = path.join(migrationTargetPath, '_test-common'); - logger.info(`Copying _test-common to ${testCommonDest}`); - await fsExtra.copy(testCommonSource, testCommonDest, { overwrite: true }); + await app.gitCheckoutGen1(); + await app.refactor(gen2StackName); - // Install dependencies for _test-common - logger.info(`Installing _test-common dependencies in ${testCommonDest}`); - await execa('npm', ['install'], { cwd: testCommonDest }); + await app.testGen1(); + await app.testGen2(); - logger.info(`Running ${testScriptName} in ${targetAppPath}`); - const result = await execa('npx', ['tsx', testScriptName], { - cwd: targetAppPath, - stdio: 'inherit', - reject: false, - }); + await app.gitCheckoutGen2(); + await app.postRefactor(); + await app.gitDiff(); + await app.gitCommit('chore: post refactor'); - if (result.exitCode !== 0) { - throw new Error(`${testScriptName} failed with exit code ${result.exitCode}\n`); - } - - logger.info(`${testScriptName} completed successfully`); -} + await app.deployGen2Sandbox(); -/** - * Spawn the amplify CLI directly to run amplify push --yes. - * - * Uses AMPLIFY_PATH env var if set, otherwise - * falls back to the amplify CLI built in the monorepo, then amplify in PATH. - */ -async function amplifyPush(targetAppPath: string): Promise { - const amplifyPath = getCLIPath(true); - logger.info(`Using amplify CLI at: ${amplifyPath}`); - const originalCwd = process.cwd(); - - process.chdir(targetAppPath); - try { - const result = await execa(amplifyPath, ['push', '--yes', '--debug'], { - cwd: targetAppPath, - stdio: logger.isDebug() ? 'inherit' : 'pipe', - }); - - if (result.exitCode !== 0) { - throw new Error(`amplify push failed with exit code ${result.exitCode}`); - } - } finally { - process.chdir(originalCwd); - } + await app.testGen1(); + await app.testGen2(); } -/** - * Initialize a single app. - * Copies the source directory to the migration target, runs amplify init, - * initializes categories, pushes, runs test scripts, and executes the - * full gen2-migration workflow. - */ -async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise { - const { appName, deploymentName, config, migrationTargetPath, envName, profile } = params; - logger.info(`Starting initialization for ${appName} with deployment name: ${deploymentName}`); - - const sourceAppPath = appSelector.getAppPath(appName); - logger.debug(`Source app path: ${sourceAppPath}`); - logger.debug(`Config app name: ${config.app.name}`); - - // Derive sourceAppsBasePath once for all test script calls - const sourceAppsBasePath = path.dirname(sourceAppPath); - - try { - // Create target directory and copy source - const targetAppPath = await directoryManager.createAppDirectory({ - basePath: migrationTargetPath, - appName: deploymentName, - }); - - logger.info(`Copying source directory to target...`); - await directoryManager.copyDirectory(sourceAppPath, targetAppPath); - - // Update package.json name to use deploymentName for predictable Gen2 stack naming - const packageJsonPath = path.join(targetAppPath, 'package.json'); - const packageJson = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf-8')) as { name: string }; - packageJson.name = deploymentName; - await fs.promises.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8'); - logger.debug(`Updated package.json name to ${deploymentName}`); - - logger.info(`Running amplify init in ${targetAppPath}`); - - // Amplify init - logger.debug(`Using AWS profile '${profile}' for Amplify initialization`); - await amplifyInitializer.initializeApp({ - appPath: targetAppPath, - config, - deploymentName, - envName, - profile, - }); - - // Initialize categories (auth, api, storage, function, etc.) - logger.info(`Initializing categories for ${deploymentName}...`); - const categoryResult = await categoryInitializer.initializeCategories({ - appPath: targetAppPath, - config, - deploymentName, - }); - - if (categoryResult.initializedCategories.length > 0) { - logger.info(`Successfully initialized categories: ${categoryResult.initializedCategories.join(', ')}`); - } - if (categoryResult.skippedCategories.length > 0) { - logger.warn(`Skipped categories: ${categoryResult.skippedCategories.join(', ')}`); - } - if (categoryResult.errors.length > 0) { - for (const error of categoryResult.errors) { - logger.error(`Category '${error.category}' failed: ${error.error}`, undefined); - } - throw new Error(`Failed to initialize ${categoryResult.errors.length} category(ies)`); - } - - // Run configure.sh if present (copies custom source files into amplify/backend/) - const configureScriptPath = path.join(targetAppPath, 'configure.sh'); - if (fs.existsSync(configureScriptPath)) { - logger.info(`Running configure.sh for ${deploymentName}...`); - await execa('bash', ['configure.sh'], { cwd: targetAppPath, stdio: 'inherit' }); - logger.info(`Successfully ran configure.sh for ${deploymentName}`); - } else { - logger.debug(`No configure.sh found for ${deploymentName}, skipping`); - } - - // Push the initialized app to AWS - logger.info(`Pushing ${deploymentName} to AWS...`); - await amplifyPush(targetAppPath); - logger.info(`Successfully pushed ${deploymentName} to AWS`); - - // Run gen1 test script to validate the Gen1 deployment - logger.info(`Running gen1 test script (post-push) for ${deploymentName}...`); - await runGen1TestScript(targetAppPath, migrationTargetPath, sourceAppsBasePath); - logger.info(`Gen1 test script passed (post-push) for ${deploymentName}`); - - // Initialize git repo and commit the Gen1 state - logger.info(`Initializing git repository for ${deploymentName}...`); - await execa('git', ['init'], { cwd: targetAppPath }); - await execa('git', ['add', '.'], { cwd: targetAppPath }); - await execa('git', ['commit', '-m', 'feat: gen1 initial commit'], { cwd: targetAppPath }); - logger.info(`Git repository initialized and Gen1 state committed`); - - // Run gen2-migration pre-deployment workflow (lock -> checkout -> generate) - logger.info(`Running gen2-migration pre-deployment workflow for ${deploymentName}...`); - await gen2MigrationExecutor.runPreDeploymentWorkflow(targetAppPath, envName); - logger.info(`Successfully completed gen2-migration pre-deployment workflow for ${deploymentName}`); - - // Run app-specific post-generate script - await runPostGenerateScript(appName, targetAppPath, envName); - - // Commit Gen2 generated code - logger.info(`Committing Gen2 generated code for ${deploymentName}...`); - await execa('git', ['add', '.'], { cwd: targetAppPath }); - await execa('git', ['commit', '-m', 'feat: gen2 migration generate'], { cwd: targetAppPath }); - logger.info(`Gen2 generated code committed`); - - // Deploy Gen2 using ampx sandbox - logger.info(`Deploying Gen2 app using ampx sandbox for ${deploymentName}...`); - const gen2BranchName = `gen2-${envName}`; - const gen2StackName = await gen2MigrationExecutor.deployGen2Sandbox(targetAppPath, deploymentName, gen2BranchName); - logger.info(`Gen2 app deployed with stack name: ${gen2StackName}`); - - // Run gen2 test script to validate the Gen2 code before deploying - logger.info(`Running gen2 test script (post-generate) for ${deploymentName}...`); - await runGen2TestScript(targetAppPath, migrationTargetPath, sourceAppsBasePath); - logger.info(`Gen2 test script passed (post-generate) for ${deploymentName}`); - - // Checkout back to main branch for refactor (refactor must run from Gen1 branch) - logger.info(`Checking out main branch for refactor (refactor requires Gen1 files)...`); - await execa('git', ['add', '.'], { cwd: targetAppPath }); - await execa('git', ['commit', '--allow-empty', '-m', 'chore: before refactor'], { cwd: targetAppPath }); - await execa('git', ['checkout', 'main'], { cwd: targetAppPath }); - logger.info('Installing dependencies...'); - await execa('npm', ['install'], { cwd: targetAppPath }); - - // Run refactor to move stateful resources from Gen1 to Gen2 - logger.info(`Running gen2-migration refactor for ${deploymentName}...`); - await gen2MigrationExecutor.refactor(targetAppPath, gen2StackName); - logger.info(`Successfully completed gen2-migration refactor for ${deploymentName}`); - - // Run gen1 test script to validate post-refactor state - logger.info(`Running gen1 test script (post-refactor) for ${deploymentName}...`); - await runGen1TestScript(targetAppPath, migrationTargetPath, sourceAppsBasePath); - logger.info(`Gen1 test script passed (post-refactor) for ${deploymentName}`); - - // Checkout back to Gen2 branch for post-refactor edits - logger.info(`Checking out ${gen2BranchName} branch for post-refactor edits...`); - await execa('git', ['add', '.'], { cwd: targetAppPath }); - await execa('git', ['commit', '--allow-empty', '-m', 'chore: after refactor'], { cwd: targetAppPath }); - await execa('git', ['checkout', gen2BranchName], { cwd: targetAppPath }); - - // Run app-specific post-refactor script - await runPostRefactorScript(appName, targetAppPath, envName); - - // Commit post-refactor changes - logger.info(`Committing post-refactor changes for ${deploymentName}...`); - await execa('git', ['add', '.'], { cwd: targetAppPath }); - await execa('git', ['commit', '-m', 'fix: post-refactor edits'], { cwd: targetAppPath }); - logger.info(`Post-refactor changes committed`); - - // Redeploy Gen2 to pick up post-refactor changes - logger.info(`Redeploying Gen2 app after refactor for ${deploymentName}...`); - await gen2MigrationExecutor.deployGen2Sandbox(targetAppPath, deploymentName, gen2BranchName); - logger.info(`Gen2 app redeployed successfully`); - - // Run gen1 test script to validate final deployment - logger.info(`Running gen1 test script (post-redeployment) for ${deploymentName}...`); - await runGen1TestScript(targetAppPath, migrationTargetPath, sourceAppsBasePath); - logger.info(`Gen1 test script passed (post-redeployment) for ${deploymentName}`); - - logger.info(`App ${deploymentName} fully initialized and migrated at ${targetAppPath}`); - } catch (error) { - logger.error(`Failed to initialize ${appName}`, error as Error); - throw error; - } +function printBanner(): void { + console.log( + chalk.cyan(` +╔══════════════════════════════════════════════════════════════════════╗ +║ ║ +║ AWS Amplify Gen1 to Gen2 Migration E2E ║ +║ ║ +║ Automation for migrating Amplify applications from Gen1 to Gen2 ║ +║ ║ +╚══════════════════════════════════════════════════════════════════════╝ +`), + ); } -process.on('uncaughtException', (error) => { - logger.error('Uncaught exception', error); - process.exit(1); -}); - -process.on('unhandledRejection', (reason) => { - logger.error('Unhandled rejection', reason as Error); +main().catch((error) => { + console.error(error); process.exit(1); }); - -// Run the CLI -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -if (require.main === module) { - main().catch((error) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - console.error(chalk.red('Fatal error:'), error.message); - process.exit(1); - }); -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/amplify-initializer.ts b/packages/amplify-gen2-migration-e2e-system/src/core/amplify-initializer.ts deleted file mode 100644 index 69fa3771435..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/core/amplify-initializer.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Amplify Initializer for executing amplify init programmatically - * Uses the e2e-core utilities for reliable amplify init execution - */ - -import { AppConfiguration } from '../types'; -import { initJSProjectWithProfile } from '@aws-amplify/amplify-e2e-core'; -import { Logger } from '../utils/logger'; - -export interface InitializeAppOptions { - appPath: string; - config: AppConfiguration; - deploymentName: string; - /** Amplify environment name (required, 2-10 lowercase letters) */ - envName: string; - profile: string; -} - -export interface AmplifyInitSettings { - name: string; - envName: string; - editor: string; - framework: string; - srcDir: string; - distDir: string; - buildCmd: string; - startCmd: string; - profileName?: string; - accessKeyId?: string; - secretAccessKey?: string; - sessionToken?: string; - includeGen2RecommendationPrompt?: boolean; - includeUsageDataPrompt?: boolean; -} - -interface BuildInitSettingsOptions { - config: AppConfiguration; - deploymentName: string; - envName: string; - profile: string; -} - -export class AmplifyInitializer { - constructor(private readonly logger: Logger) {} - - async initializeApp(options: InitializeAppOptions): Promise { - const { appPath, config, deploymentName, envName, profile } = options; - - this.logger.info(`Starting amplify init for ${deploymentName} (config: ${config.app.name})`); - this.logger.debug(`App path: ${appPath}`); - this.logger.debug(`Configuration: ${JSON.stringify(config, null, 2)}`); - this.logger.debug(`Deployment name: ${deploymentName}`); - - const appNameValidation = this.validateAppName(deploymentName); - if (!appNameValidation.valid) { - throw Error(`Invalid app name: ${appNameValidation.error}`); - } - - const amplifyEnvNameValidation = this.validateEnvName(envName); - if (!amplifyEnvNameValidation.valid) { - throw Error(`Invalid env name: ${amplifyEnvNameValidation.error}`); - } - - const startTime = Date.now(); - try { - this.logger.debug(`Calling initJSProjectWithProfile...`); - const settings = this.buildInitSettings({ config, deploymentName, profile, envName }); - this.logger.debug(`Init settings: ${JSON.stringify(settings, null, 2)}`); - await initJSProjectWithProfile(appPath, settings); - - const duration = Date.now() - startTime; - this.logger.info(`Successfully initialized Amplify app in ${appPath} (${duration}ms)`); - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`Failed to initialize Amplify app: ${deploymentName} (failed after ${duration}ms)`, error as Error); - throw error; - } - } - - validateAppName(appName: string): { valid: boolean; error?: string } { - // Amplify app names must be alphanumeric only, 3-20 characters - if (!appName) { - return { valid: false, error: 'App name is required' }; - } - - if (appName.length < 3 || appName.length > 20) { - return { valid: false, error: 'App name must be between 3-20 characters' }; - } - - // Check for alphanumeric only (no dashes, underscores, or special characters) - const alphanumericRegex = /^[a-zA-Z0-9]+$/; - if (!alphanumericRegex.test(appName)) { - return { - valid: false, - error: `App name not valid: ${appName}. App name must contain only alphanumeric characters (a-z, A-Z, 0-9). No dashes, underscores, or special characters allowed`, - }; - } - - return { valid: true }; - } - - validateEnvName(envName: string): { valid: boolean; error?: string } { - // Env names must be lowercase letters only, 2-10 characters - if (!envName) { - return { valid: false, error: 'Env name is required' }; - } - - const isValid = /^[a-z]{2,10}$/.test(envName); - if (!isValid) { - return { - valid: false, - error: `Env name not valid: ${envName}. Env name must be 2-10 lowercase letters only (a-z). No numbers, dashes, underscores, or special characters allowed`, - }; - } - - return { valid: true }; - } - - /** Generates a random env name (2-10 lowercase letters) */ - static generateRandomEnvName(): string { - const length = Math.floor(Math.random() * 9) + 2; - return Array.from({ length }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join(''); - } - - private buildInitSettings(options: BuildInitSettingsOptions): Partial { - const { config, deploymentName, profile, envName } = options; - - const settings = { - name: deploymentName, - envName, - editor: 'Visual Studio Code', - framework: config.app.framework ?? 'react', - srcDir: 'src', - distDir: 'dist', - buildCmd: 'npm run build', - startCmd: 'npm run start', - disableAmplifyAppCreation: false, // always create app in Amplify console - profileName: profile, - }; - - // Log the settings being used - this.logger.debug(`Built init settings for ${deploymentName} (config: ${config.app.name}):`); - this.logger.debug(`- Name: ${settings.name}`); - this.logger.info(`Using Amplify environment name: ${settings.envName}`); - this.logger.debug(`- Using default selections for editor, framework, etc.`); - - return settings; - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/app-selector.ts b/packages/amplify-gen2-migration-e2e-system/src/core/app-selector.ts deleted file mode 100644 index 8543156cd10..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/core/app-selector.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * App selection and management for the Amplify Migration System - */ - -import * as path from 'path'; -import { CLIOptions } from '../types'; -import { Logger } from '../utils/logger'; -import { FileManager } from '../utils/file-manager'; - -export class AppSelector { - private readonly appsBasePath: string; - private availableApps?: string[]; - - constructor(private readonly logger: Logger, private readonly fileManager: FileManager, appsBasePath = '../../amplify-migration-apps') { - // Resolve path relative to the project root, not the current file - this.appsBasePath = path.resolve(process.cwd(), appsBasePath); - } - - async discoverAvailableApps(): Promise { - if (this.availableApps) { - return this.availableApps; - } - - this.logger.debug('Discovering available apps'); - - try { - if (!(await this.fileManager.pathExists(this.appsBasePath))) { - throw new Error(`Apps directory does not exist: ${this.appsBasePath}`); - } - - const appDirectories = await this.fileManager.listDirectories(this.appsBasePath); - - this.availableApps = appDirectories.sort(); - this.logger.debug(`Discovered ${this.availableApps.length} available apps: ${this.availableApps.join(', ')}`); - - return this.availableApps; - } catch (error) { - throw Error(`Failed to discover available apps: ${error}`); - } - } - - public async validateAppExists(appName: string): Promise { - this.logger.debug(`Validating app exists: ${appName}`); - - const availableApps = await this.discoverAvailableApps(); - const exists = availableApps.includes(appName); - - if (!exists) { - this.logger.warn(`App does not exist: ${appName}. Available apps: ${availableApps.join(', ')}`); - } - - return exists; - } - - async selectApp(options: CLIOptions): Promise { - this.logger.debug('Selecting app based on CLI option'); - - const availableApps = await this.discoverAvailableApps(); - - if (availableApps.length === 0) { - throw new Error('No valid apps found in the apps directory'); - } - - if (!(await this.validateAppExists(options.app))) { - throw Error(`Invalid app specified: ${options.app}. Available apps: ${availableApps.join(', ')}`); - } - - this.logger.info(`Selected app: ${options.app}`); - return options.app; - } - - getAppPath(appName: string): string { - return path.join(this.appsBasePath, appName); - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/app.ts b/packages/amplify-gen2-migration-e2e-system/src/core/app.ts new file mode 100644 index 00000000000..17ff49e7097 --- /dev/null +++ b/packages/amplify-gen2-migration-e2e-system/src/core/app.ts @@ -0,0 +1,475 @@ +import execa from 'execa'; +import fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; +import { getCLIPath, initJSProjectWithProfile } from '@aws-amplify/amplify-e2e-core'; +import { Logger, LogLevel } from './logger'; +import { Git } from './git'; + +const MIGRATION_TARGET_DIR = path.join(os.tmpdir(), 'amplify-gen2-migration-e2e-system', 'output-apps'); +const MIGRATION_APPS_DIR = path.join(__dirname, '..', '..', '..', '..', 'amplify-migration-apps'); + +interface MigrationConfig { + /** + * Per-step configuration overrides. + */ + readonly lock?: StepConfig; + readonly refactor?: RefactorConfig; +} + +interface StepConfig { + /** + * Pass --skip-validations to the step. + */ + readonly skipValidations?: boolean; +} + +interface RefactorConfig { + /** + * Skip the refactor step entirely (e.g., when a sub-feature breaks refactoring). + */ + readonly skip?: boolean; + /** + * Pass --skip-validations to the refactor step. + */ + readonly skipValidations?: boolean; +} + +/** + * Represents a migration app deployed to a temporary directory. + * Exposes all lifecycle operations as public methods. + */ +export class App { + private readonly deploymentName: string; + private readonly gen2BranchName: string; + + private readonly sourceAppPath: string; + private readonly envName: string; + private readonly migrationConfig: MigrationConfig; + + /** + * Whether the refactor step should be skipped entirely for this app. + */ + public get skipRefactor(): boolean { + return this.migrationConfig.refactor?.skip === true; + } + private readonly amplifyPath: string; + + public readonly logger: Logger; + public readonly targetAppPath: string; + + private readonly git: Git; + + constructor(public readonly appName: string, private readonly profile: string, verbose = false) { + this.sourceAppPath = path.join(MIGRATION_APPS_DIR, appName); + if (!fs.existsSync(this.sourceAppPath)) { + throw new Error(`App not found: ${this.sourceAppPath}`); + } + + this.deploymentName = generateTimeBasedName(appName); + this.logger = new Logger(this.deploymentName, verbose ? LogLevel.DEBUG : LogLevel.INFO); + + this.envName = generateRandomEnvName(); + this.gen2BranchName = `gen2-${this.envName}`; + this.amplifyPath = getCLIPath(true); + + // Copy source to temp directory + this.targetAppPath = path.join(MIGRATION_TARGET_DIR, this.deploymentName); + fs.mkdirSync(this.targetAppPath, { recursive: true }); + fs.copySync(this.sourceAppPath, this.targetAppPath, { + filter: (src: string) => !src.includes('_snapshot') && !src.includes('node_modules'), + }); + + // Update package.json name for predictable Gen2 stack naming + const packageJsonPath = path.join(this.targetAppPath, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as { name: string }; + packageJson.name = this.deploymentName; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8'); + + this.migrationConfig = this.loadMigrationConfig(); + this.git = new Git(this.targetAppPath, this.logger); + + this.logger.info(`App ${appName} prepared at ${this.targetAppPath}`); + this.logger.info(`Deployment name: ${this.deploymentName}, env: ${this.envName}`); + } + + // ============================================================ + // Gen1 Lifecycle + // ============================================================ + + /** + * Run `amplify init` to initialize the Gen1 project. + */ + public async init(): Promise { + this.logger.info('amplify init'); + const mainTsx = path.join(this.sourceAppPath, 'src', 'main.tsx'); + const framework = fs.existsSync(mainTsx) ? 'react' : 'none'; + + await initJSProjectWithProfile(this.targetAppPath, { + name: this.deploymentName, + envName: this.envName, + editor: 'Visual Studio Code', + framework, + srcDir: 'src', + distDir: 'dist', + buildCmd: 'npm run build', + startCmd: 'npm run start', + disableAmplifyAppCreation: false, + profileName: this.profile, + }); + this.logger.info('amplify init completed'); + } + + /** + * Restore the pre-generate snapshot into the amplify/ directory. + */ + public async configure(): Promise { + this.logger.info('Configuring categories...'); + const restore = (p: string): void => { + fs.removeSync(path.join(this.targetAppPath, 'amplify', p)); + fs.copySync(path.join(this.targetAppPath, '.amplify.init', p), path.join(this.targetAppPath, 'amplify', p)); + }; + + const metaPath = path.join(this.targetAppPath, 'amplify', 'backend', 'amplify-meta.json'); + const oldMeta = JSON.parse(fs.readFileSync(metaPath, { encoding: 'utf-8' })) as { providers: Record }; + + fs.moveSync(path.join(this.targetAppPath, 'amplify'), path.join(this.targetAppPath, '.amplify.init')); + fs.copySync(path.join(this.sourceAppPath, '_snapshot.pre.generate', 'amplify'), path.join(this.targetAppPath, 'amplify')); + + restore('#current-cloud-backend'); + restore('.config'); + restore('team-provider-info.json'); + + const newMeta = JSON.parse(fs.readFileSync(metaPath, { encoding: 'utf-8' })) as { providers: Record }; + newMeta.providers['awscloudformation'] = oldMeta.providers['awscloudformation']; + fs.writeFileSync(metaPath, JSON.stringify(newMeta, null, 2)); + fs.removeSync(path.join(this.targetAppPath, '.amplify.init')); + this.removeGitignoreLine('amplifyconfiguration.json'); + this.logger.info('Categories configured'); + } + + /** + * Run `npm install` in the target directory. + */ + public async installDeps(): Promise { + this.logger.info('Installing dependencies...'); + await execa('npm', ['install'], { cwd: this.targetAppPath }); + this.logger.info('Finished installing dependencies'); + } + + /** + * Run `amplify status`. + */ + public async status(): Promise { + this.logger.info('amplify status'); + await this.runAmplify(['status'], { stdio: 'inherit' }); + this.logger.info('amplify status completed'); + } + + /** + * Run `amplify push --yes`. + */ + public async push(): Promise { + this.logger.info('amplify push'); + await this.runAmplify(['push', '--yes', '--debug']); + this.logger.info('amplify push completed'); + } + + // ============================================================ + // Gen2 Migration + // ============================================================ + + /** + * Run `amplify gen2-migration assess`. + */ + public async assess(): Promise { + await this.runMigrationStep('assess'); + } + + /** + * Run `amplify gen2-migration lock`. + */ + public async lock(): Promise { + const extraArgs = this.migrationConfig.lock?.skipValidations ? ['--skip-validations'] : []; + await this.runMigrationStep('lock', extraArgs); + } + + /** + * Run `amplify gen2-migration generate` and install dependencies. + */ + public async generate(): Promise { + await this.runMigrationStep('generate'); + this.removeGitignoreLine('amplify_outputs*'); + } + + /** + * Run `amplify gen2-migration refactor`. + */ + public async refactor(gen2StackName: string): Promise { + const extraArgs = ['--to', gen2StackName]; + if (this.migrationConfig.refactor?.skipValidations) { + extraArgs.push('--skip-validations'); + } + await this.runMigrationStep('refactor', extraArgs); + } + + /** + * Deploy Gen2 app using `npx ampx sandbox --once`. + * Returns the Gen2 root stack name. + */ + public async deployGen2Sandbox(): Promise { + this.logger.info('Deploying Gen2 app using ampx sandbox...'); + const startTime = Date.now(); + + const result = await execa('npx', ['ampx', 'sandbox', '--once'], { + cwd: this.targetAppPath, + reject: false, + stdio: 'inherit', + env: { ...process.env, AWS_BRANCH: this.gen2BranchName }, + }); + + if (result.exitCode !== 0) { + throw new Error('ampx sandbox failed'); + } + + this.logger.info(`ampx sandbox completed (${Date.now() - startTime}ms)`); + + const username = os.userInfo().username; + const stackPrefix = `amplify-${this.deploymentName}-${username}-sandbox`; + return this.findGen2RootStack(stackPrefix); + } + + // ============================================================ + // App Scripts + // ============================================================ + + /** + * Run the Jest tests against the Gen1 config. + */ + public async testGen1(): Promise { + await this.gitCheckoutGen1(); + await this.runNpmScript('test:gen1'); + } + + /** + * Run the Jest tests against the Gen2 config. + */ + public async testGen2(): Promise { + await this.gitCheckoutGen2(); + await this.runNpmScript('test:gen2'); + } + + /** + * Run the pre-push script. + */ + public async prePush(): Promise { + await this.runNpmScript('pre-push'); + } + + /** + * Run the post-push script. + */ + public async postPush(): Promise { + await this.runNpmScript('post-push'); + } + + /** + * Run the post-generate script. + */ + public async postGenerate(): Promise { + await this.runNpmScript('post-generate', { AWS_BRANCH: 'sandbox' }); + } + + /** + * Run the post-refactor script. + */ + public async postRefactor(): Promise { + await this.runNpmScript('post-refactor'); + } + + /** + * Run the post-sandbox script with the Gen2 root stack name. + */ + public async postSandbox(gen2StackName: string): Promise { + await this.runNpmScript('post-sandbox', { APP_GEN2_ROOT_STACK_NAME: gen2StackName }); + } + + /** + * Run the pre-sandbox script. + */ + public async preSandbox(): Promise { + await this.runNpmScript('pre-sandbox'); + } + + // ============================================================ + // Git + // ============================================================ + + /** + * Initialize a git repo and create the initial commit. + */ + public async gitInit(): Promise { + await this.git.init(); + } + + /** + * Commit all changes. + */ + public async gitCommit(message: string): Promise { + await this.git.commit(message); + } + + public async gitDiff(): Promise { + await this.git.diff(); + } + + /** + * Checkout the Gen1 (main) branch. + */ + public async gitCheckoutGen1(): Promise { + await this.git.checkout('main', false); + } + + /** + * Checkout the Gen2 branch (creates it if create is true). + */ + public async gitCheckoutGen2(create = false): Promise { + await this.git.checkout(this.gen2BranchName, create); + } + + // ============================================================ + // Private Helpers + // ============================================================ + + private removeGitignoreLine(line: string): void { + const gitignorePath = path.join(this.targetAppPath, '.gitignore'); + if (!fs.existsSync(gitignorePath)) return; + const content = fs.readFileSync(gitignorePath, 'utf-8'); + const updated = content + .split('\n') + .filter((l) => l.trim() !== line) + .join('\n'); + fs.writeFileSync(gitignorePath, updated, 'utf-8'); + this.logger.info(`Removed '${line}' from .gitignore`); + } + + private loadMigrationConfig(): MigrationConfig { + const configPath = path.join(this.targetAppPath, 'migration', 'config.json'); + if (!fs.existsSync(configPath)) return {}; + return JSON.parse(fs.readFileSync(configPath, 'utf-8')) as MigrationConfig; + } + + private async runAmplify(args: string[], options?: { stdio?: 'inherit' }): Promise { + const originalCwd = process.cwd(); + process.chdir(this.targetAppPath); + try { + const result = await execa(this.amplifyPath, args, { + cwd: this.targetAppPath, + stdio: options?.stdio, + }); + if (result.exitCode !== 0) { + throw new Error(`amplify ${args[0]} failed with exit code ${result.exitCode}`); + } + } finally { + process.chdir(originalCwd); + } + } + + private async runMigrationStep(step: string, extraArgs: string[] = []): Promise { + const argsStr = extraArgs.length > 0 ? ` ${extraArgs.join(' ')}` : ''; + this.logger.info(`Executing gen2-migration ${step}${argsStr}...`); + const startTime = Date.now(); + + const result = await execa(this.amplifyPath, ['gen2-migration', step, '--yes', ...extraArgs], { + cwd: this.targetAppPath, + stdio: 'inherit', + reject: false, + }); + + if (result.exitCode !== 0) { + throw new Error(`gen2-migration ${step} failed with exit code ${result.exitCode}`); + } + + this.logger.info(`gen2-migration ${step} completed (${Date.now() - startTime}ms)`); + } + + /** + * Run an npm script defined in the app's package.json. + * Silently skips if the script is not defined. + */ + private async runNpmScript(scriptName: string, extraEnv?: Record): Promise { + const result = await execa('npm', ['run', scriptName], { + cwd: this.targetAppPath, + stdio: 'inherit', + reject: false, + env: { ...process.env, AWS_SDK_LOAD_CONFIG: '1', ...extraEnv }, + }); + + if (result.exitCode !== 0) { + throw new Error(`npm run ${scriptName} failed with exit code ${result.exitCode}`); + } + } + + private async findGen2RootStack(stackPrefix: string): Promise { + const result = await execa( + 'aws', + [ + 'cloudformation', + 'list-stacks', + '--stack-status-filter', + 'CREATE_COMPLETE', + 'UPDATE_COMPLETE', + '--query', + `StackSummaries[?starts_with(StackName, '${stackPrefix}')].StackName`, + '--output', + 'text', + ], + { reject: false }, + ); + + if (result.exitCode !== 0) { + throw new Error(`Failed to list CloudFormation stacks: ${result.stderr || result.stdout}`); + } + + const stacks = result.stdout + .trim() + .split(/\s+/) + .filter((s) => s.length > 0); + const rootStacks = stacks.filter((name) => /^-[a-f0-9]+$/.test(name.replace(stackPrefix, ''))); + + if (rootStacks.length === 0) { + throw new Error(`No Gen2 sandbox stack found with prefix: ${stackPrefix}`); + } + + this.logger.info(`Gen2 stack name: ${rootStacks[0]}`); + return rootStacks[0]; + } +} + +/** + * Generates a time-based Amplify app name. + * Format: [prefix][YYMMDDHHMM] (20 chars max for Amplify compatibility). + */ +function generateTimeBasedName(appName: string): string { + const now = new Date(); + const timestamp = [ + String(now.getFullYear()).slice(-2), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + String(now.getHours()).padStart(2, '0'), + String(now.getMinutes()).padStart(2, '0'), + ].join(''); + + const alphanumericOnly = appName.replace(/[^a-zA-Z0-9]/g, ''); + const prefix = alphanumericOnly.slice(0, 10).toLowerCase(); + const safePrefix = /^[a-z]/.test(prefix) ? prefix : `e${prefix.slice(1)}`; + return `${safePrefix}${timestamp}`; +} + +/** + * Generates a random env name (2-10 lowercase letters). + */ +function generateRandomEnvName(): string { + const length = Math.floor(Math.random() * 9) + 2; + return Array.from({ length }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join(''); +} diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts b/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts deleted file mode 100644 index 789689f8e67..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts +++ /dev/null @@ -1,644 +0,0 @@ -/** - * Category Initializer for adding Amplify categories programmatically - * Uses the e2e-core utilities for reliable category initialization - */ - -import { - AppConfiguration, - APIConfiguration, - AuthConfiguration, - StorageConfiguration, - FunctionConfiguration, - RestApiConfiguration, - AnalyticsConfiguration, -} from '../types'; -import { - addAuthWithDefault, - addAuthWithDefaultSocial, - addAuthWithEmail, - addAuthWithGroups, - addApi, - addRestApi, - addS3Storage, - addS3StorageWithAuthOnly, - addS3WithGroupAccess, - addS3WithTrigger, - addDynamoDBWithGSIWithSettings, - addFunction, - addLambdaTrigger, - addLambdaTriggerWithModels, - addKinesis, - updateSchema, -} from '@aws-amplify/amplify-e2e-core'; -import * as fs from 'fs'; -import * as path from 'path'; -import { Logger } from '../utils/logger'; - -export interface CategoryInitializerOptions { - appPath: string; - config: AppConfiguration; - deploymentName: string; -} - -export interface InitializeCategoriesResult { - initializedCategories: string[]; - skippedCategories: string[]; - errors: Array<{ category: string; error: string }>; -} - -export class CategoryInitializer { - constructor(private readonly logger: Logger) {} - - /** - * Initialize all categories defined in the configuration - */ - async initializeCategories(options: CategoryInitializerOptions): Promise { - const { appPath, config, deploymentName } = options; - - const result: InitializeCategoriesResult = { - initializedCategories: [], - skippedCategories: [], - errors: [], - }; - - this.logger.info(`Starting category initialization for ${deploymentName}`); - - const categories = config.categories; - if (!categories) { - this.logger.info('No categories defined in configuration'); - return result; - } - - // Initialize categories in the correct order: - // 1. Auth first (other categories may depend on it) - // 2. Analytics before functions (functions may reference analytics resources) - // 3. Regular functions (non-trigger) before API - // 4. Storage (may have triggers that reference functions) - // 5. GraphQL API (creates AppSync tables that trigger functions may reference) - // 6. Trigger functions (need AppSync/DynamoDB tables to exist) - // 7. REST API last (needs functions to exist) - if (categories.auth) { - await this.initializeAuthCategory(appPath, categories.auth, result); - } - - if (categories.analytics) { - await this.initializeAnalyticsCategory(appPath, categories.analytics, result); - } - - // Initialize regular (non-trigger) functions before API - if (categories.function) { - await this.initializeRegularFunctions(appPath, categories.function, result); - } - - if (categories.storage) { - await this.initializeStorageCategory(appPath, categories.storage, categories.auth, result); - } - - if (categories.api) { - await this.initializeApiCategory(appPath, categories.api, categories.function, result); - } - - // Initialize trigger functions after API (they need AppSync tables to exist) - if (categories.function) { - await this.initializeTriggerFunctions(appPath, categories.function, result); - } - - // Initialize REST API separately if configured - if (categories.restApi) { - await this.initializeRestApiCategory(appPath, categories.restApi, categories.function, result); - } - - this.logger.info(`Category initialization complete. Initialized: ${result.initializedCategories.join(', ') || 'none'}`); - - return result; - } - - /** - * Initialize the auth category based on configuration - * Supports: social providers, user pool groups - * Not yet supported: auth triggers (preSignUp, etc.) - */ - private async initializeAuthCategory(appPath: string, authConfig: AuthConfiguration, result: InitializeCategoriesResult): Promise { - const hasSocialProviders = authConfig.socialProviders && authConfig.socialProviders.length > 0; - const hasUserPoolGroups = authConfig.userPoolGroups && authConfig.userPoolGroups.length > 0; - const hasAuthTriggers = authConfig.triggers && Object.keys(authConfig.triggers).length > 0; - - // Log what we're configuring - const features: string[] = []; - if (hasSocialProviders) features.push('social providers'); - if (hasUserPoolGroups) features.push('user pool groups'); - if (hasAuthTriggers) features.push('triggers (not yet supported)'); - - const authType = features.length > 0 ? `with ${features.join(', ')}` : 'with default settings'; - this.logger.info(`Initializing auth category ${authType}...`); - - // Warn about unsupported features - if (hasAuthTriggers) { - this.logger.warn('Auth triggers (preSignUp, postConfirmation, etc.) are not yet supported by category-initializer'); - } - - try { - if (hasUserPoolGroups) { - // Use auth with groups (creates Admins and Users groups by default) - // Note: addAuthWithGroups creates hardcoded "Admins" and "Users" groups - this.logger.debug(`User pool groups configured: ${authConfig.userPoolGroups?.join(', ')}`); - await addAuthWithGroups(appPath); - } else if (hasSocialProviders) { - // Use social auth when social providers are configured - // This sets up Cognito with Facebook, Google, and Amazon OAuth - this.logger.debug(`Social providers configured: ${authConfig.socialProviders.join(', ')}`); - await addAuthWithDefaultSocial(appPath); - } else if (authConfig.signInMethods?.includes('email')) { - // Use email sign-in when explicitly configured - this.logger.debug('Using email sign-in method'); - await addAuthWithEmail(appPath); - } else { - // Use default auth configuration (username sign-in) - await addAuthWithDefault(appPath); - } - - result.initializedCategories.push('auth'); - this.logger.info('Auth category initialized successfully'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to initialize auth category: ${errorMessage}`, error as Error); - result.errors.push({ category: 'auth', error: errorMessage }); - } - } - - /** - * Initialize the GraphQL API category - */ - private async initializeApiCategory( - appPath: string, - apiConfig: APIConfiguration, - functionConfig: FunctionConfiguration | undefined, - result: InitializeCategoriesResult, - ): Promise { - // Only handle GraphQL here; REST is handled separately - if (apiConfig.type !== 'GraphQL') { - // If type is REST but no restApi config, use legacy behavior - if (apiConfig.type === 'REST') { - await this.initializeRestApiFromLegacyConfig(appPath, functionConfig, result); - } - return; - } - - this.logger.info('Initializing GraphQL API category...'); - - try { - // Build authTypesConfig in the order specified by migration-config.json so the - // first auth mode becomes the default (addApi uses the first key as default). - const authModeMap: Record = { - IAM: 'IAM', - API_KEY: 'API key', - COGNITO_USER_POOLS: 'Amazon Cognito User Pool', - AMAZON_COGNITO_USER_POOLS: 'Amazon Cognito User Pool', - }; - - const authTypesConfig: Record> = {}; - for (const mode of apiConfig.authModes ?? []) { - const mapped = authModeMap[mode]; - if (!mapped) { - throw new Error( - `Unsupported auth mode '${mode}' in migration-config.json. Supported modes: ${Object.keys(authModeMap).join(', ')}`, - ); - } - authTypesConfig[mapped] = {}; - } - - if (Object.keys(authTypesConfig).length === 0) { - throw new Error('migration-config.json must specify at least one authMode for the GraphQL API'); - } - - // Pass requireAuthSetup = false because the auth category is already initialized - await addApi(appPath, authTypesConfig, false); - - // If a schema file is specified, update the schema - if (apiConfig.schema) { - const schemaPath = path.join(appPath, apiConfig.schema); - if (fs.existsSync(schemaPath)) { - const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); - // Get the API name from the amplify backend config - const apiName = this.getApiNameFromBackend(appPath); - if (apiName) { - updateSchema(appPath, apiName, schemaContent); - this.logger.debug(`Updated schema from ${apiConfig.schema}`); - } - } else { - this.logger.warn(`Schema file not found: ${schemaPath}`); - } - } - - result.initializedCategories.push('api'); - this.logger.info('GraphQL API category initialized successfully'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to initialize GraphQL API category: ${errorMessage}`, error as Error); - result.errors.push({ category: 'api', error: errorMessage }); - } - } - - /** - * Initialize REST API from the new restApi configuration - */ - private async initializeRestApiCategory( - appPath: string, - restApiConfig: RestApiConfiguration, - functionConfig: FunctionConfiguration | undefined, - result: InitializeCategoriesResult, - ): Promise { - this.logger.info(`Initializing REST API category (${restApiConfig.name})...`); - - // REST API requires at least one Lambda function to exist - const hasFunctions = functionConfig && functionConfig.functions.length > 0; - if (!hasFunctions) { - this.logger.warn('REST API requires at least one Lambda function, skipping'); - result.skippedCategories.push('restApi'); - return; - } - - // Check if the specified lambda source exists - const lambdaExists = functionConfig.functions.some((f) => f.name === restApiConfig.lambdaSource); - if (!lambdaExists) { - this.logger.warn(`REST API lambda source '${restApiConfig.lambdaSource}' not found in functions, skipping`); - result.skippedCategories.push('restApi'); - return; - } - - try { - await addRestApi(appPath, { - isFirstRestApi: true, - existingLambda: true, - restrictAccess: true, - allowGuestUsers: false, - projectContainsFunctions: true, - apiName: restApiConfig.name, - }); - - result.initializedCategories.push('restApi'); - this.logger.info('REST API category initialized successfully'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to initialize REST API category: ${errorMessage}`, error as Error); - result.errors.push({ category: 'restApi', error: errorMessage }); - } - } - - /** - * Initialize REST API from legacy api.type: "REST" configuration - */ - private async initializeRestApiFromLegacyConfig( - appPath: string, - functionConfig: FunctionConfiguration | undefined, - result: InitializeCategoriesResult, - ): Promise { - this.logger.info('Initializing REST API category (legacy config)...'); - - const hasFunctions = functionConfig && functionConfig.functions.length > 0; - if (!hasFunctions) { - this.logger.warn('REST API requires at least one Lambda function, skipping'); - result.skippedCategories.push('api'); - return; - } - - try { - await addRestApi(appPath, { - isFirstRestApi: true, - existingLambda: true, - restrictAccess: true, - allowGuestUsers: false, - projectContainsFunctions: true, - }); - - result.initializedCategories.push('api'); - this.logger.info('REST API category initialized successfully'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to initialize REST API category: ${errorMessage}`, error as Error); - result.errors.push({ category: 'api', error: errorMessage }); - } - } - - /** - * Initialize the storage category based on configuration - * Supports: S3 buckets (auth-only, auth+guest, with triggers), DynamoDB tables - */ - private async initializeStorageCategory( - appPath: string, - storageConfig: StorageConfiguration, - authConfig: AuthConfiguration | undefined, - result: InitializeCategoriesResult, - ): Promise { - // Check if this is DynamoDB storage - if (storageConfig.type === 'dynamodb' && storageConfig.tables && storageConfig.tables.length > 0) { - await this.initializeDynamoDBStorage(appPath, storageConfig, result); - return; - } - - // S3 storage - if (!storageConfig.buckets || storageConfig.buckets.length === 0) { - this.logger.warn('No storage buckets configured, skipping storage category'); - result.skippedCategories.push('storage'); - return; - } - - // When user pool groups exist, the CLI prompts "Restrict access by?" instead of - // "Who should have access:". Use the group-aware helper to avoid a prompt timeout. - const hasUserPoolGroups = authConfig?.userPoolGroups && authConfig.userPoolGroups.length > 0; - - // Check if guest access is configured for any bucket - const hasGuestAccess = storageConfig.buckets.some((bucket) => bucket.access.includes('guest') || bucket.access.includes('public')); - // Check if triggers are configured - const hasTriggers = storageConfig.triggers && storageConfig.triggers.length > 0; - - const accessType = hasGuestAccess ? 'auth and guest' : 'auth-only'; - const triggerInfo = hasTriggers ? ' with Lambda trigger' : ''; - const groupInfo = hasUserPoolGroups ? ' (with user pool groups)' : ''; - this.logger.info(`Initializing S3 storage category with ${accessType} access${triggerInfo}${groupInfo}...`); - - try { - if (hasTriggers) { - // Add S3 storage with Lambda trigger (creates a new trigger function) - const projectHasFunctions = result.initializedCategories.includes('function'); - this.logger.debug(`Adding S3 storage with Lambda trigger (projectHasFunctions: ${projectHasFunctions})`); - await addS3WithTrigger(appPath, { projectHasFunctions }); - } else if (hasUserPoolGroups) { - // Use group-aware helper when user pool groups are configured. - // addAuthWithGroups creates hardcoded "Admins" and "Users" groups regardless - // of what the config specifies, so we must pass those names here. - this.logger.debug(`Adding S3 storage with group access (Admins, Users)`); - await addS3WithGroupAccess(appPath); - } else if (hasGuestAccess) { - // Add S3 storage with auth and guest access - await addS3Storage(appPath); - } else { - // Add S3 storage with auth-only access - await addS3StorageWithAuthOnly(appPath); - } - - result.initializedCategories.push('storage'); - this.logger.info('Storage category initialized successfully'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to initialize storage category: ${errorMessage}`, error as Error); - result.errors.push({ category: 'storage', error: errorMessage }); - } - } - - /** - * Initialize DynamoDB storage - */ - private async initializeDynamoDBStorage( - appPath: string, - storageConfig: StorageConfiguration, - result: InitializeCategoriesResult, - ): Promise { - const tables = storageConfig.tables; - if (!tables || tables.length === 0) { - this.logger.warn('No DynamoDB tables configured, skipping storage category'); - result.skippedCategories.push('storage'); - return; - } - - this.logger.info(`Initializing DynamoDB storage with ${tables.length} table(s)...`); - - try { - for (const table of tables) { - this.logger.debug(`Adding DynamoDB table: ${table.name}`); - - // Use addDynamoDBWithGSIWithSettings if GSI is configured - if (table.gsi && table.gsi.length > 0) { - await addDynamoDBWithGSIWithSettings(appPath, { - resourceName: table.name, - tableName: table.name, - gsiName: table.gsi[0].name, - }); - } else { - // For tables without GSI, we still use the GSI function but it will create default columns - // This is a limitation of the e2e-core helpers - this.logger.warn(`DynamoDB table '${table.name}' without GSI - using default schema`); - await addDynamoDBWithGSIWithSettings(appPath, { - resourceName: table.name, - tableName: table.name, - gsiName: `${table.name}GSI`, - }); - } - - this.logger.debug(`DynamoDB table ${table.name} added successfully`); - } - - result.initializedCategories.push('storage'); - this.logger.info('DynamoDB storage category initialized successfully'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to initialize DynamoDB storage: ${errorMessage}`, error as Error); - result.errors.push({ category: 'storage', error: errorMessage }); - } - } - - /** - * Initialize regular (non-trigger) Lambda functions - */ - private async initializeRegularFunctions( - appPath: string, - functionConfig: FunctionConfiguration, - result: InitializeCategoriesResult, - ): Promise { - const regularFunctions = functionConfig.functions.filter((f) => !f.trigger); - - if (regularFunctions.length === 0) { - this.logger.debug('No regular functions to initialize'); - return; - } - - this.logger.info(`Initializing ${regularFunctions.length} regular function(s)...`); - - try { - for (const func of regularFunctions) { - this.logger.debug(`Adding function: ${func.name}`); - - const runtime = this.mapRuntime(func.runtime); - const template = this.mapTemplate(func.template); - - await addFunction( - appPath, - { - name: func.name, - functionTemplate: template, - }, - runtime, - ); - - this.logger.debug(`Function ${func.name} added successfully`); - } - - result.initializedCategories.push('function'); - this.logger.info('Regular functions initialized successfully'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to initialize regular functions: ${errorMessage}`, error as Error); - result.errors.push({ category: 'function', error: errorMessage }); - } - } - - /** - * Initialize trigger-based Lambda functions (DynamoDB streams, Kinesis, etc.) - * Must be called after API category is initialized so AppSync tables exist. - */ - private async initializeTriggerFunctions( - appPath: string, - functionConfig: FunctionConfiguration, - result: InitializeCategoriesResult, - ): Promise { - const triggerFunctions = functionConfig.functions.filter((f) => f.trigger); - - if (triggerFunctions.length === 0) { - this.logger.debug('No trigger functions to initialize'); - return; - } - - this.logger.info(`Initializing ${triggerFunctions.length} trigger function(s)...`); - - try { - for (const func of triggerFunctions) { - this.logger.debug(`Adding trigger function: ${func.name}`); - - const runtime = this.mapRuntime(func.runtime); - const triggerType = func.trigger?.type; - - if (triggerType === 'dynamodb-stream') { - // DynamoDB stream trigger - use Lambda trigger template with model selection - await addFunction( - appPath, - { - name: func.name, - functionTemplate: 'Lambda trigger', - triggerType: 'DynamoDB', - eventSource: 'AppSync', // Use AppSync tables from GraphQL API - }, - runtime, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - addLambdaTriggerWithModels, - ); - } else if (triggerType === 'kinesis') { - // Kinesis stream trigger - await addFunction( - appPath, - { - name: func.name, - functionTemplate: 'Lambda trigger', - triggerType: 'Kinesis', - }, - runtime, - addLambdaTrigger, - ); - } else { - this.logger.warn(`Unsupported trigger type '${triggerType}' for function ${func.name}, skipping`); - continue; - } - - this.logger.debug(`Trigger function ${func.name} added successfully`); - } - - this.logger.info('Trigger functions initialized successfully'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to initialize trigger functions: ${errorMessage}`, error as Error); - result.errors.push({ category: 'function-triggers', error: errorMessage }); - } - } - - /** - * Map runtime string to e2e-core runtime type - */ - private mapRuntime(runtime: string): 'nodejs' | 'python' | 'java' | 'dotnet8' | 'go' { - switch (runtime.toLowerCase()) { - case 'nodejs': - case 'node': - return 'nodejs'; - case 'python': - return 'python'; - case 'java': - return 'java'; - case 'dotnet': - case 'dotnet8': - return 'dotnet8'; - case 'go': - return 'go'; - default: - return 'nodejs'; - } - } - - /** - * Map template string to e2e-core template name - */ - private mapTemplate(template?: string): string { - if (!template) return 'Hello World'; - - switch (template.toLowerCase()) { - case 'hello-world': - return 'Hello World'; - case 'serverless-expressjs': - return 'Serverless ExpressJS function (Integration with API Gateway)'; - case 'lambda-trigger': - return 'Lambda trigger'; - case 'crud-dynamodb': - return 'CRUD function for DynamoDB (Integration with API Gateway)'; - default: - return template; - } - } - - /** - * Get the API name from the amplify backend configuration - */ - private getApiNameFromBackend(appPath: string): string | null { - try { - const backendConfigPath = path.join(appPath, 'amplify', 'backend', 'backend-config.json'); - if (fs.existsSync(backendConfigPath)) { - const backendConfig = JSON.parse(fs.readFileSync(backendConfigPath, 'utf-8')) as Record>; - const apiNames = Object.keys(backendConfig.api || {}); - return apiNames.length > 0 ? apiNames[0] : null; - } - return null; - } catch { - return null; - } - } - - /** - * Initialize the analytics category - * Supports: Kinesis Data Streams - */ - private async initializeAnalyticsCategory( - appPath: string, - analyticsConfig: AnalyticsConfiguration, - result: InitializeCategoriesResult, - ): Promise { - this.logger.info(`Initializing analytics category (${analyticsConfig.type}: ${analyticsConfig.name})...`); - - if (analyticsConfig.type !== 'kinesis') { - this.logger.warn(`Analytics type '${analyticsConfig.type}' is not yet supported, skipping`); - result.skippedCategories.push('analytics'); - return; - } - - try { - // addKinesis expects rightName (valid name) and wrongName (invalid name for validation test) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - await addKinesis(appPath, { - rightName: analyticsConfig.name, - wrongName: '$', // Invalid name to trigger validation, then correct it - }); - - result.initializedCategories.push('analytics'); - this.logger.info('Analytics category initialized successfully'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to initialize analytics category: ${errorMessage}`, error as Error); - result.errors.push({ category: 'analytics', error: errorMessage }); - } - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/cdk-atmosphere-integration.ts b/packages/amplify-gen2-migration-e2e-system/src/core/cdk-atmosphere-integration.ts deleted file mode 100644 index 1b97219bb4d..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/core/cdk-atmosphere-integration.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * CDK Atmosphere Client Integration - * Handles detection and integration with Atmosphere environments while supporting local AWS configurations - */ - -import { EnvironmentType, AtmosphereAllocation } from '../types'; -import { AtmosphereClient } from '@cdklabs/cdk-atmosphere-client'; -import { AWSProfileManager } from '../utils/aws-profile-manager'; -import * as crypto from 'crypto'; -import { Logger } from '../utils/logger'; -import { EnvironmentDetector } from './environment-detector'; - -// Amplify supported regions (copied from @aws-amplify/amplify-e2e-core to avoid ESM import issues) -const amplifyRegions = [ - 'us-east-1', - 'us-east-2', - 'us-west-1', - 'us-west-2', - 'eu-north-1', - 'eu-south-1', - 'eu-west-1', - 'eu-west-2', - 'eu-west-3', - 'eu-central-1', - 'ap-northeast-1', - 'ap-northeast-2', - 'ap-northeast-3', - 'ap-southeast-1', - 'ap-southeast-2', - 'ap-south-1', - 'ca-central-1', - 'me-south-1', - 'sa-east-1', -]; - -// Maximum number of allocation attempts to get a supported Amplify region -const MAX_ALLOCATION_ATTEMPTS = 10; - -export class CDKAtmosphereIntegration { - private atmosphereClient?: AtmosphereClient; - private cachedAllocation?: AtmosphereAllocation; - private allocationId?: string; - private currentProfileName?: string; - private readonly profileManager: AWSProfileManager; - private readonly supportedRegions: Set; - - constructor(private readonly logger: Logger, private readonly environmentDetector: EnvironmentDetector, homeDir?: string) { - this.profileManager = new AWSProfileManager(logger, homeDir); - this.supportedRegions = new Set(amplifyRegions); - } - - /** - * Checks if a region is supported by Amplify - */ - isRegionSupported(region: string): boolean { - return this.supportedRegions.has(region); - } - - /** - * Generates a unique profile name with format `atmosphere-{timestamp}-{random}` - * The timestamp ensures chronological ordering and the random suffix prevents collisions - */ - generateProfileName(): string { - const timestamp = Date.now(); - const randomSuffix = crypto.randomBytes(3).toString('hex'); // 6 hex characters - return `atmosphere-${timestamp}-${randomSuffix}`; - } - - async isAtmosphereEnvironment(): Promise { - try { - const envType = await this.environmentDetector.detectEnvironment(); - const isAtmosphere = envType === EnvironmentType.ATMOSPHERE; - - this.logger.debug(`Environment type detected: ${envType}`); - return isAtmosphere; - } catch (error) { - this.logger.error('Failed to detect environment type', error as Error); - return false; - } - } - - async initializeForAtmosphere(): Promise { - this.logger.info('Initializing CDK Atmosphere client for Atmosphere environment'); - - if (this.cachedAllocation) { - this.logger.debug('Using cached Atmosphere credentials'); - return this.cachedAllocation; - } - - const atmosphereEndpoint = process.env.ATMOSPHERE_ENDPOINT; - if (!atmosphereEndpoint) { - throw Error('ATMOSPHERE_ENDPOINT must be configured as an env variable.'); - } - this.atmosphereClient = new AtmosphereClient(atmosphereEndpoint, { - logStream: process.stdout, - }); - - this.logger.debug(`Initialized Atmosphere client with endpoint: ${atmosphereEndpoint}`); - - const allocation = await this.getAtmosphereAllocation(); - - this.cachedAllocation = allocation; - - this.logger.info('Successfully initialized CDK Atmosphere client'); - return allocation; - } - - /** - * Gets credentials from Atmosphere and writes them to AWS profile files. - * Returns the generated profile name that can be used with AWS CLI/SDK. - */ - async getProfileFromAllocation(): Promise { - const isAtmosphere = await this.isAtmosphereEnvironment(); - - if (!isAtmosphere) { - throw Error('Must use Atmosphere for this method'); - } - - try { - const allocation = await this.initializeForAtmosphere(); - - // Generate a unique name - const profileName = this.generateProfileName(); - this.currentProfileName = profileName; - - // Write credentials to AWS profile files on the runner - await this.profileManager.writeProfile(profileName, { - credentials: { - accessKeyId: allocation.accessKeyId, - secretAccessKey: allocation.secretAccessKey, - sessionToken: allocation.sessionToken, - }, - region: allocation.region, - }); - - this.logger.info(`Created AWS profile: ${profileName}`); - return profileName; - } catch (atmosphereError) { - throw Error(`Atmosphere credentials failed: ${(atmosphereError as Error).message}`); - } - } - - async cleanup(): Promise { - const isAtmosphere = await this.isAtmosphereEnvironment(); - - if (!isAtmosphere) { - this.logger.debug('Not in Atmosphere environment, skipping Atmosphere cleanup.'); - return; - } - - this.logger.debug('Cleaning up CDK Atmosphere client'); - - try { - if (this.currentProfileName) { - this.logger.debug(`Removing AWS profile: ${this.currentProfileName}`); - try { - await this.profileManager.removeProfile(this.currentProfileName); - this.logger.debug(`Successfully removed AWS profile: ${this.currentProfileName}`); - } catch (profileError) { - this.logger.warn(`Failed to remove AWS profile ${this.currentProfileName}: ${(profileError as Error).message}`); - } - this.currentProfileName = undefined; - } - - // Release the Atmosphere allocation if we have one - if (this.atmosphereClient && this.allocationId) { - this.logger.debug(`Releasing Atmosphere allocation: ${this.allocationId}`); - await this.atmosphereClient.release(this.allocationId, 'success'); - this.allocationId = undefined; - } - - // Clean up client reference - this.atmosphereClient = undefined; - this.cachedAllocation = undefined; - - this.logger.debug('Successfully cleaned up CDK Atmosphere client'); - } catch (error) { - this.logger.error('Failed to cleanup CDK Atmosphere client', error as Error); - throw error; - } - } - - private async getAtmosphereAllocation(): Promise { - if (!this.atmosphereClient) { - throw Error('Atmosphere client not initialized'); - } - - if (!process.env.DEFAULT_POOL) { - throw Error('DEFAULT_POOL must be present in env vars'); - } - - const poolName = process.env.DEFAULT_POOL; - const requesterName = process.env.USER || process.env.USERNAME || 'amplify-gen2-migration-e2e-system'; - - // Retry loop to get an allocation in a supported Amplify region, Atmosphere doesn't support requesting a specific region - for (let attempt = 1; attempt <= MAX_ALLOCATION_ATTEMPTS; attempt++) { - try { - this.logger.debug(`Attempt ${attempt}/${MAX_ALLOCATION_ATTEMPTS}: Acquiring environment allocation from pool: ${poolName}`); - - const allocation = await this.atmosphereClient.acquire({ - pool: poolName, - requester: requesterName, - timeoutSeconds: 60 * 30, // 30 minutes timeout - }); - - if (!allocation) { - throw new Error('No allocation returned from Atmosphere'); - } - - if (!allocation.credentials) { - throw new Error('No credentials found in Atmosphere allocation'); - } - - if (!allocation.environment) { - throw new Error('No environment found in Atmosphere allocation'); - } - - const { credentials, environment } = allocation; - - if (!credentials.accessKeyId || !credentials.secretAccessKey) { - throw new Error('Invalid credentials format from Atmosphere allocation'); - } - - // Check if the region is supported by Amplify - if (!this.isRegionSupported(environment.region)) { - this.logger.warn(`Allocation ${allocation.id} is in unsupported region ${environment.region}. Releasing and retrying...`); - - // Release this allocation and try again - await this.atmosphereClient.release(allocation.id, 'success'); - continue; - } - - // Store the allocation ID for later release - this.allocationId = allocation.id; - - const atmosphereAllocation: AtmosphereAllocation = { - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken || '', - region: environment.region, - }; - - this.logger.info( - `Successfully acquired allocation ${allocation.id} in supported region ${environment.region} (attempt ${attempt})`, - ); - this.logger.debug(`Account: ${environment.account}, Region: ${environment.region}`); - - return atmosphereAllocation; - } catch (error) { - // If this is the last attempt, throw the error - if (attempt === MAX_ALLOCATION_ATTEMPTS) { - this.logger.error( - `Failed to get Atmosphere allocation in supported region after ${MAX_ALLOCATION_ATTEMPTS} attempts`, - error as Error, - ); - throw error; - } - - // Log and continue to next attempt - this.logger.warn(`Attempt ${attempt} failed: ${(error as Error).message}. Retrying...`); - } - } - - throw new Error(`Failed to acquire Atmosphere allocation in a supported Amplify region after ${MAX_ALLOCATION_ATTEMPTS} attempts`); - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/configuration-loader.ts b/packages/amplify-gen2-migration-e2e-system/src/core/configuration-loader.ts deleted file mode 100644 index 87e0ed79b85..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/core/configuration-loader.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Configuration loader for managing app-specific migration configurations - */ - -import * as fs from 'fs-extra'; -import * as path from 'path'; -import { AppConfiguration, ValidationResult } from '../types'; -import { Logger } from '../utils/logger'; -import { FileManager } from '../utils/file-manager'; - -export class ConfigurationLoader implements ConfigurationLoader { - private readonly appsBasePath: string; - private readonly configFileName = 'migration-config.json'; - - constructor(private readonly logger: Logger, private readonly fileManager: FileManager, appsBasePath = '../../amplify-migration-apps') { - // Resolve path relative to the project root, not the current file - this.appsBasePath = path.resolve(process.cwd(), appsBasePath); - } - - async loadAppConfiguration(appName: string): Promise { - this.logger.debug(`Loading configuration for app: ${appName}`); - - const configPath = this.getConfigPath(appName); - - if (!(await fs.pathExists(configPath))) { - throw new Error(`Configuration file not found: ${configPath}. Please create a migration-config.json file for ${appName}.`); - } - - try { - const configContent = await this.fileManager.readFile(configPath); - const rawConfig = JSON.parse(configContent) as Partial; - - const config: AppConfiguration = { - ...rawConfig, - app: rawConfig.app!, - categories: rawConfig.categories!, - }; - - const validationResult = this.validateConfiguration(config); - if (validationResult.errors.length > 0) { - this.logger.warn(`Configuration validation failed for ${appName}`); - this.logger.warn(`${validationResult.errors.join(', ')}`); - throw Error('App configuration did not pass validation.'); - } - - this.logger.info(`Successfully loaded configuration for ${appName}`); - return config; - } catch (error) { - throw new Error(`Failed to load configuration for ${appName}: ${(error as Error).message}`); - } - } - - validateConfiguration(config: AppConfiguration): ValidationResult { - const errors: string[] = []; - - // Validate app metadata - if (!config.app) { - errors.push('App metadata is required'); - } else { - if (!config.app.name) { - errors.push('App name is required'); - } - if (!config.app.framework) { - errors.push('App framework is required'); - } - } - - // Validate categories config exists - if (!config.categories) { - errors.push('Categories configuration is required'); - } - return { errors }; - } - - private getConfigPath(appName: string): string { - return path.join(this.appsBasePath, appName, this.configFileName); - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/environment-detector.ts b/packages/amplify-gen2-migration-e2e-system/src/core/environment-detector.ts deleted file mode 100644 index 682d5972fc2..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/core/environment-detector.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Environment detection for Atmosphere vs Local environments - */ - -import { EnvironmentType } from '../types'; -import { Logger } from '../utils/logger'; - -export class EnvironmentDetector implements EnvironmentDetector { - private detectedEnvironment?: EnvironmentType; - private environmentVariables: Record; - - constructor(private readonly logger: Logger) { - this.environmentVariables = Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record< - string, - string - >; - } - - async detectEnvironment(): Promise { - if (this.detectedEnvironment) { - return this.detectedEnvironment; - } - - this.logger.debug('Detecting execution environment'); - - const isAtmosphere = await this.isAtmosphereEnvironment(); - this.detectedEnvironment = isAtmosphere ? EnvironmentType.ATMOSPHERE : EnvironmentType.LOCAL; - - this.logger.info(`Detected environment: ${this.detectedEnvironment}`); - return this.detectedEnvironment; - } - - async isAtmosphereEnvironment(): Promise { - // Check for Atmosphere environment variables - const atmosphereEnvVars = ['ATMOSPHERE_ENDPOINT', 'DEFAULT_POOL']; - - const hasAllAtmosphereVars = atmosphereEnvVars.every((indicator) => this.environmentVariables[indicator]); - - if (hasAllAtmosphereVars) { - this.logger.debug('Atmosphere environment detected via environment variables'); - return true; - } - - this.logger.debug('No Atmosphere environment indicators found, assuming local environment'); - return false; - } - - isCI(): boolean { - return !!( - this.environmentVariables.CI || - this.environmentVariables.CONTINUOUS_INTEGRATION || - this.environmentVariables.BUILD_NUMBER || - this.environmentVariables.GITHUB_ACTIONS || - this.environmentVariables.TRAVIS || - this.environmentVariables.CIRCLECI || - this.environmentVariables.JENKINS_URL - ); - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts b/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts deleted file mode 100644 index d5e87a5f986..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Gen2 Migration Executor - * - * Executes gen2-migration CLI commands (lock, generate, refactor) - * from the amplify-cli package to migrate Gen1 apps to Gen2. - */ - -import execa from 'execa'; -import os from 'os'; -import { getCLIPath } from '@aws-amplify/amplify-e2e-core'; -import { Logger } from '../utils/logger'; - -/** - * Available gen2-migration steps - */ -export type Gen2MigrationStep = 'lock' | 'generate' | 'refactor'; - -/** - * Options for Gen2MigrationExecutor - */ -export interface Gen2MigrationExecutorOptions { - /** AWS profile to use for CLI commands */ - profile?: string; -} - -/** - * Executor for gen2-migration CLI commands. - * - * The migration workflow consists of: - * 1. lock - Lock the Gen1 environment to prevent updates during migration - * 2. generate - Generate Gen2 code from Gen1 configuration - * 3. refactor - Move stateful resources from Gen1 to Gen2 stacks - */ -export class Gen2MigrationExecutor { - private readonly amplifyPath: string; - private readonly profile?: string; - - constructor(private readonly logger: Logger, options?: Gen2MigrationExecutorOptions) { - this.amplifyPath = getCLIPath(true); - this.profile = options?.profile; - } - - /** - * Execute a gen2-migration step. Throws on failure. - */ - private async executeStep(step: Gen2MigrationStep, appPath: string, extraArgs: string[] = []): Promise { - this.logger.info(`Executing gen2-migration ${step}...`); - this.logger.debug(`App path: ${appPath}`); - this.logger.debug(`Using amplify CLI at: ${this.amplifyPath}`); - - const args = ['gen2-migration', step, '--yes', ...extraArgs]; - this.logger.debug(`Command: ${this.amplifyPath} ${args.join(' ')}`); - - const startTime = Date.now(); - - // Set AWS_PROFILE env var if profile is specified - const env = this.profile ? { ...process.env, AWS_PROFILE: this.profile } : undefined; - - const result = await execa(this.amplifyPath, args, { - cwd: appPath, - stdio: 'inherit', - reject: false, - env, - }); - - const durationMs = Date.now() - startTime; - - if (result.exitCode !== 0) { - const errorMessage = result.stderr || result.stdout || `Exit code ${result.exitCode}`; - this.logger.error(`gen2-migration ${step} failed: ${errorMessage}`, undefined); - throw new Error(`gen2-migration ${step} failed: ${errorMessage}`); - } - - this.logger.info(`gen2-migration ${step} completed (${durationMs}ms)`); - } - - /** - * Lock the Gen1 environment. - * - * Enables deletion protection on DynamoDB tables, sets a deny-all stack policy, - * and adds GEN2_MIGRATION_ENVIRONMENT_NAME env var to the Amplify app. - */ - public async lock(appPath: string): Promise { - await this.executeStep('lock', appPath); - } - - /** - * Generate Gen2 code from Gen1 configuration. - * - * Creates/updates package.json with Gen2 dependencies, replaces the amplify - * folder with Gen2 TypeScript definitions, and installs dependencies. - */ - public async generate(appPath: string): Promise { - await this.executeStep('generate', appPath); - this.logger.info('Installing dependencies..'); - await execa('npm', ['install'], { cwd: appPath }); - } - - /** - * Move stateful resources from Gen1 to Gen2 stacks. - * - * Requires Gen2 deployment to be complete before running. - */ - public async refactor(appPath: string, gen2StackName: string): Promise { - await this.executeStep('refactor', appPath, ['--to', gen2StackName]); - } - - /** - * Run pre-deployment workflow: lock -> checkout gen2 branch -> generate - */ - public async runPreDeploymentWorkflow(appPath: string, envName = 'main'): Promise { - this.logger.info('Starting pre-deployment workflow (lock -> checkout -> generate)...'); - - // Lock on the main branch - await this.lock(appPath); - - // Create and checkout gen2 branch before generate - const gen2BranchName = `gen2-${envName}`; - this.logger.info(`Creating and checking out branch '${gen2BranchName}'...`); - await execa('git', ['add', '.'], { cwd: appPath }); - await execa('git', ['commit', '--allow-empty', '-m', 'chore: before generate'], { cwd: appPath }); - await execa('git', ['checkout', '-b', gen2BranchName], { cwd: appPath }); - - // Generate Gen2 code - await this.generate(appPath); - - this.logger.info('Pre-deployment workflow completed'); - } - - /** - * Deploy Gen2 app using ampx sandbox. - * - * Runs `npx ampx sandbox --once` to do a single non-interactive deployment. - * Returns the Gen2 root stack name by querying CloudFormation. - * - * @param appPath - Path to the app directory - * @param deploymentName - Unique deployment name for stack identification - * @param branchName - Branch name to set as AWS_BRANCH env var for unique resource naming - */ - public async deployGen2Sandbox(appPath: string, deploymentName: string, branchName: string): Promise { - this.logger.info('Deploying Gen2 app using ampx sandbox...'); - this.logger.debug(`App path: ${appPath}`); - this.logger.debug(`Branch name (AWS_BRANCH): ${branchName}`); - - const startTime = Date.now(); - - // Set AWS_PROFILE and AWS_BRANCH env vars - // AWS_BRANCH ensures unique Lambda function names across sandbox deployments - const env: NodeJS.ProcessEnv = { - ...process.env, - AWS_BRANCH: branchName, - ...(this.profile && { AWS_PROFILE: this.profile }), - }; - - this.logger.info('Installing dependencies...'); - await execa('npm', ['install'], { cwd: appPath }); - const result = await execa('npx', ['ampx', 'sandbox', '--once'], { - cwd: appPath, - reject: false, - stdio: 'inherit', - env, - }); - - const durationMs = Date.now() - startTime; - - if (result.exitCode !== 0) { - throw new Error(`ampx sandbox failed`); - } - - this.logger.info(`ampx sandbox completed (${durationMs}ms)`); - - // Find the Gen2 root stack by querying CloudFormation - // Pattern: amplify---sandbox- - const username = os.userInfo().username; - const stackPrefix = `amplify-${deploymentName}-${username}-sandbox`; - - const gen2StackName = await this.findGen2RootStack(stackPrefix); - this.logger.info(`Gen2 stack name: ${gen2StackName}`); - - return gen2StackName; - } - - /** - * Find the Gen2 root stack by prefix using AWS CLI. - */ - private async findGen2RootStack(stackPrefix: string): Promise { - this.logger.debug(`Looking for stack with prefix: ${stackPrefix}`); - - const env = this.profile ? { ...process.env, AWS_PROFILE: this.profile } : undefined; - - const result = await execa( - 'aws', - [ - 'cloudformation', - 'list-stacks', - '--stack-status-filter', - 'CREATE_COMPLETE', - 'UPDATE_COMPLETE', - '--query', - `StackSummaries[?starts_with(StackName, '${stackPrefix}')].StackName`, - '--output', - 'text', - ], - { reject: false, env }, - ); - - if (result.exitCode !== 0) { - throw new Error(`Failed to list CloudFormation stacks: ${result.stderr || result.stdout}`); - } - - const stacks = result.stdout - .trim() - .split(/\s+/) - .filter((s) => s.length > 0); - - // Find root stacks (those without nested stack suffixes like -auth, -data, -storage) - const rootStacks = stacks.filter((name) => { - const suffix = name.replace(stackPrefix, ''); - // Root stack has pattern: - (10 char hex) - // Nested stacks have pattern: --- - return /^-[a-f0-9]+$/.test(suffix); - }); - - if (rootStacks.length === 0) { - throw new Error(`No Gen2 sandbox stack found with prefix: ${stackPrefix}`); - } - - // Return the most recently created (should only be one) - return rootStacks[0]; - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/git.ts b/packages/amplify-gen2-migration-e2e-system/src/core/git.ts new file mode 100644 index 00000000000..038d78de3c0 --- /dev/null +++ b/packages/amplify-gen2-migration-e2e-system/src/core/git.ts @@ -0,0 +1,31 @@ +import execa from 'execa'; +import { Logger } from './logger'; + +export class Git { + constructor(private readonly cwd: string, private readonly logger: Logger) {} + + public async checkout(branch: string, create: boolean): Promise { + await this.commit(`commit prior to switching to ${branch}`); + const args = create ? ['-b', branch] : [branch]; + await this.run('checkout', ...args); + } + + public async commit(message: string): Promise { + await this.run('add', '.'); + await this.run('commit', '--allow-empty', '-m', message); + } + + public async init(): Promise { + await this.run('init'); + } + + public async diff(): Promise { + this.logger.info('git diff'); + await execa('git', ['--no-pager', 'diff'], { cwd: this.cwd, stdio: 'inherit' }); + } + + private async run(...args: string[]): Promise { + this.logger.info(`git ${args.join(' ')}`); + await execa('git', args, { cwd: this.cwd }); + } +} diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/index.ts b/packages/amplify-gen2-migration-e2e-system/src/core/index.ts deleted file mode 100644 index 1d837db447c..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/core/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { AppSelector } from './app-selector'; -export { EnvironmentDetector } from './environment-detector'; -export { ConfigurationLoader } from './configuration-loader'; -export { AmplifyInitializer } from './amplify-initializer'; -export { CDKAtmosphereIntegration } from './cdk-atmosphere-integration'; -export { Gen2MigrationExecutor } from './gen2-migration-executor'; diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/logger.ts b/packages/amplify-gen2-migration-e2e-system/src/core/logger.ts new file mode 100644 index 00000000000..da7745e3576 --- /dev/null +++ b/packages/amplify-gen2-migration-e2e-system/src/core/logger.ts @@ -0,0 +1,93 @@ +/** + * Logging system for the Amplify Migration System. + */ + +import * as fs from 'fs-extra'; +import * as path from 'path'; +import chalk from 'chalk'; +import os from 'os'; + +const LOG_DIR = path.join(os.tmpdir(), 'amplify-gen2-migration-e2e-system', 'logs'); + +export enum LogLevel { + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', +} + +interface LogEntry { + readonly timestamp: Date; + readonly level: LogLevel; + readonly message: string; + readonly error?: Error; +} + +export class Logger { + private readonly logLevel: LogLevel; + private readonly logFilePath: string; + + constructor(private readonly appName: string, level: LogLevel = LogLevel.INFO) { + this.logLevel = level; + this.logFilePath = path.join(LOG_DIR, `${appName}.log`); + fs.ensureDirSync(LOG_DIR); + } + + public isDebug(): boolean { + return this.logLevel === LogLevel.DEBUG; + } + + public debug(message: string): void { + this.log(LogLevel.DEBUG, message); + } + + public info(message: string): void { + this.log(LogLevel.INFO, message); + } + + public warn(message: string): void { + this.log(LogLevel.WARN, message); + } + + public error(message: string, error?: Error): void { + this.log(LogLevel.ERROR, message, error); + } + + private log(level: LogLevel, message: string, error?: Error): void { + if (!this.shouldLog(level)) return; + + const entry: LogEntry = { timestamp: new Date(), level, message, error }; + const formatted = this.formatMessage(entry); + + console.log(formatted); + + fs.appendFileSync(this.logFilePath, formatted + '\n'); + } + + private shouldLog(level: LogLevel): boolean { + const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR]; + return levels.indexOf(level) >= levels.indexOf(this.logLevel); + } + + private formatMessage(entry: LogEntry): string { + const timestamp = entry.timestamp.toISOString(); + const level = this.colorizeLevel(entry.level); + const errorInfo = entry.error ? ` | Error: ${entry.error.message}${entry.error.stack ? `\n${chalk.red(entry.error.stack)}` : ''}` : ''; + return `[${timestamp}] ${level} [${this.appName}] ${entry.message}${errorInfo}`; + } + + private colorizeLevel(level: LogLevel): string { + switch (level) { + case LogLevel.DEBUG: + return chalk.gray('[DEBUG]'); + case LogLevel.INFO: + return chalk.blue('[INFO]'); + case LogLevel.WARN: + return chalk.yellow('[WARN]'); + case LogLevel.ERROR: + return chalk.red('[ERROR]'); + default: + return chalk.blue('[INFO]'); + } + } +} diff --git a/packages/amplify-gen2-migration-e2e-system/src/index.ts b/packages/amplify-gen2-migration-e2e-system/src/index.ts deleted file mode 100644 index e2d605740f4..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Main entry point for the Amplify Migration System - * Exports all public interfaces and classes - */ - -// Types -export * from './types'; - -// Core components -export { ConfigurationLoader } from './core/configuration-loader'; -export { EnvironmentDetector } from './core/environment-detector'; -export { AppSelector } from './core/app-selector'; -export { AmplifyInitializer } from './core/amplify-initializer'; - -// Utilities -export { Logger } from './utils/logger'; -export { FileManager } from './utils/file-manager'; diff --git a/packages/amplify-gen2-migration-e2e-system/src/test-setup.ts b/packages/amplify-gen2-migration-e2e-system/src/test-setup.ts deleted file mode 100644 index ae4223b0442..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/test-setup.ts +++ /dev/null @@ -1,27 +0,0 @@ -// @ts-nocheck -/** - * Jest Test Setup - * Automatically loads environment variables from .gamma.env for all tests, if the file is present - */ - -import * as dotenv from 'dotenv'; -import * as path from 'path'; -import nodeFetch from 'node-fetch'; - -// Polyfill fetch APIs for Node.js (required by @cdklabs/cdk-atmosphere-client) -// Node 18+ has these but they may not be globally available in all contexts -if (typeof globalThis.fetch === 'undefined') { - globalThis.fetch = nodeFetch.default || nodeFetch; - globalThis.Request = nodeFetch.Request; - globalThis.Response = nodeFetch.Response; - globalThis.Headers = nodeFetch.Headers; -} - -// Load environment variables from .gamma.env file -const envPath = path.join(__dirname, '..', '.gamma.env'); -dotenv.config({ path: envPath }); - -// Log loaded environment variables for debugging -console.log('Test Environment Configuration:'); -console.log('- ATMOSPHERE_ENDPOINT:', process.env.ATMOSPHERE_ENDPOINT || 'not set'); -console.log('- DEFAULT_POOL:', process.env.DEFAULT_POOL || 'not set'); diff --git a/packages/amplify-gen2-migration-e2e-system/src/types/index.ts b/packages/amplify-gen2-migration-e2e-system/src/types/index.ts deleted file mode 100644 index 7ba979fcaf0..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/types/index.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Core types and interfaces for the Amplify Migration System - */ - -export interface AppConfiguration { - app: AppMetadata; - categories: CategoryConfiguration; -} - -export interface AppMetadata { - name: string; - description: string; - framework: string; -} - -export interface CategoryConfiguration { - api?: APIConfiguration; - auth?: AuthConfiguration; - storage?: StorageConfiguration; - function?: FunctionConfiguration; - restApi?: RestApiConfiguration; - analytics?: AnalyticsConfiguration; -} - -export interface APIConfiguration { - type: 'GraphQL' | 'REST'; - schema?: string; - authModes: AuthMode[]; - customQueries?: string[]; - customMutations?: string[]; -} - -export interface RestApiConfiguration { - name: string; - paths: string[]; - lambdaSource: string; -} - -export interface AuthConfiguration { - signInMethods: SignInMethod[]; - socialProviders: SocialProvider[]; - userPoolGroups?: string[]; - triggers?: AuthTriggers; - userPoolConfig?: UserPoolConfiguration; - identityPoolConfig?: IdentityPoolConfiguration; -} - -export interface AuthTriggers { - preSignUp?: PreSignUpTrigger; - postConfirmation?: PostConfirmationTrigger; - preAuthentication?: PreAuthenticationTrigger; - postAuthentication?: PostAuthenticationTrigger; -} - -export interface PreSignUpTrigger { - type: 'email-filter-allowlist' | 'custom'; - allowedDomains?: string[]; -} - -export interface PostConfirmationTrigger { - type: 'add-to-group' | 'custom'; - groupName?: string; -} - -export interface PreAuthenticationTrigger { - type: 'custom'; -} - -export interface PostAuthenticationTrigger { - type: 'custom'; -} - -export interface StorageConfiguration { - type?: 'dynamodb'; - buckets?: StorageBucket[]; - tables?: DynamoDBTable[]; - triggers?: StorageTrigger[]; -} - -export interface DynamoDBTable { - name: string; - partitionKey: string; - sortKey?: string; - gsi?: GlobalSecondaryIndex[]; -} - -export interface GlobalSecondaryIndex { - name: string; - partitionKey: string; - sortKey?: string; -} - -export interface FunctionConfiguration { - functions: LambdaFunction[]; -} - -export interface AnalyticsConfiguration { - type: 'kinesis' | 'pinpoint'; - name: string; - shards?: number; -} - -// Supporting types -export type AuthMode = 'API_KEY' | 'COGNITO_USER_POOLS' | 'IAM' | 'OIDC'; -export type SignInMethod = 'email' | 'phone' | 'username'; -export type SocialProvider = 'facebook' | 'google' | 'amazon' | 'apple'; - -export interface UserPoolConfiguration { - passwordPolicy?: PasswordPolicy; - mfaConfiguration?: MFAConfiguration; - emailVerification?: boolean; - phoneVerification?: boolean; -} - -export interface IdentityPoolConfiguration { - allowUnauthenticatedIdentities?: boolean; - cognitoIdentityProviders?: string[]; -} - -export interface StorageBucket { - name: string; - access: AccessLevel[]; - cors?: CORSConfiguration; -} - -export interface StorageTrigger { - name: string; - events: S3Event[]; - function: string; -} - -export interface LambdaFunction { - name: string; - runtime: 'nodejs' | 'python' | 'java' | 'dotnet'; - template?: string; - handler?: string; - environment?: Record; - permissions?: string[]; - trigger?: FunctionTrigger; -} - -export interface FunctionTrigger { - type: 'dynamodb-stream' | 's3' | 'cognito' | 'kinesis'; - source: string[]; -} - -export interface BuildSettings { - buildCommand?: string; - outputDirectory?: string; - nodeVersion?: string; - environmentVariables?: Record; -} - -export type AccessLevel = 'public' | 'protected' | 'private' | 'auth' | 'guest'; -export type S3Event = 'objectCreated' | 'objectRemoved' | 'objectRestore'; - -export interface PasswordPolicy { - minimumLength?: number; - requireLowercase?: boolean; - requireUppercase?: boolean; - requireNumbers?: boolean; - requireSymbols?: boolean; -} - -export interface MFAConfiguration { - mode: 'OFF' | 'ON' | 'OPTIONAL'; - smsMessage?: string; - totpEnabled?: boolean; -} - -export interface CORSConfiguration { - allowedOrigins: string[]; - allowedMethods: string[]; - allowedHeaders: string[]; - maxAge?: number; -} - -export enum EnvironmentType { - ATMOSPHERE = 'atmosphere', - LOCAL = 'local', -} - -export interface AtmosphereAllocation { - accessKeyId: string; - secretAccessKey: string; - sessionToken: string; - region: string; -} - -export interface ValidationResult { - errors: string[]; -} - -// Logging types -export interface LogEntry { - timestamp: Date; - level: LogLevel; - message: string; - error?: Error; -} - -export enum LogLevel { - DEBUG = 'debug', - INFO = 'info', - WARN = 'warn', - ERROR = 'error', -} - -// CLI types -export interface CLIOptions { - app: string; - dryRun?: boolean; - verbose?: boolean; - profile?: string; - isAtmosphere?: boolean; - envName?: string; -} - -export interface InitializeAppFromCLIParams { - appName: string; - deploymentName: string; - /** Amplify environment name (required, 2-10 lowercase letters) */ - envName: string; - config: AppConfiguration; - migrationTargetPath: string; - profile: string; -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/utils/aws-profile-manager.ts b/packages/amplify-gen2-migration-e2e-system/src/utils/aws-profile-manager.ts deleted file mode 100644 index e253c7df8e7..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/utils/aws-profile-manager.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * AWS Profile Manager - * Manages AWS credentials and config files for named profiles - */ - -import * as fs from 'fs-extra'; -import * as path from 'path'; -import * as os from 'os'; -import { Logger } from './logger'; - -type IniSections = Record>; - -export interface AWSCredentials { - accessKeyId: string; - secretAccessKey: string; - sessionToken?: string; -} - -export interface AWSProfileData { - credentials: AWSCredentials; - region: string; -} - -export class AWSProfileManager { - private readonly credentialsPath: string; - private readonly configPath: string; - - constructor(private readonly logger: Logger, homeDir?: string) { - const home = homeDir || os.homedir(); - this.credentialsPath = path.join(home, '.aws', 'credentials'); - this.configPath = path.join(home, '.aws', 'config'); - } - - async writeProfile(profileName: string, profileData: AWSProfileData): Promise { - this.logger.debug(`Writing profile: ${profileName}`); - - await this.ensureAwsDirectory(); - - // Write credentials file - await this.writeCredentialsFile(profileName, profileData.credentials); - - // Write config file - await this.writeConfigFile(profileName, profileData.region); - - this.logger.info(`Successfully wrote profile: ${profileName}`); - } - - async removeProfile(profileName: string): Promise { - this.logger.debug(`Removing profile: ${profileName}`); - - // Remove from credentials file - await this.removeFromCredentialsFile(profileName); - - // Remove from config file - await this.removeFromConfigFile(profileName); - - this.logger.info(`Successfully removed profile: ${profileName}`); - } - - private async ensureAwsDirectory(): Promise { - const awsDir = path.dirname(this.credentialsPath); - if (!(await fs.pathExists(awsDir))) { - await fs.mkdir(awsDir, { recursive: true, mode: 0o700 }); - } - } - - private async writeCredentialsFile(profileName: string, credentials: AWSCredentials): Promise { - let sections: IniSections = {}; - - if (await fs.pathExists(this.credentialsPath)) { - const content = await fs.readFile(this.credentialsPath, 'utf-8'); - sections = this.parseIniFile(content); - } - - // Add or update the profile section - sections[profileName] = { - aws_access_key_id: credentials.accessKeyId, - aws_secret_access_key: credentials.secretAccessKey, - }; - - if (credentials.sessionToken) { - sections[profileName].aws_session_token = credentials.sessionToken; - } - - const serialized = this.serializeIniFile(sections); - await this.writeFileWithPermissions(this.credentialsPath, serialized); - - this.logger.debug(`Wrote credentials for profile: ${profileName}`); - } - - private async writeConfigFile(profileName: string, region: string): Promise { - let sections: IniSections = {}; - - if (await fs.pathExists(this.configPath)) { - const content = await fs.readFile(this.configPath, 'utf-8'); - sections = this.parseIniFile(content); - } - - // Config file uses "profile " format for non-default profiles - const sectionName = profileName === 'default' ? 'default' : `profile ${profileName}`; - - sections[sectionName] = { - region, - }; - - const serialized = this.serializeIniFile(sections); - await this.writeFileWithPermissions(this.configPath, serialized); - - this.logger.debug(`Wrote config for profile: ${profileName}`); - } - - private async removeFromCredentialsFile(profileName: string): Promise { - if (!(await fs.pathExists(this.credentialsPath))) { - this.logger.debug('Credentials file does not exist, nothing to remove'); - return; - } - - const content = await fs.readFile(this.credentialsPath, 'utf-8'); - const sections = this.parseIniFile(content); - - if (!(profileName in sections)) { - this.logger.debug(`Profile ${profileName} not found in credentials file`); - return; - } - - delete sections[profileName]; - - const serialized = this.serializeIniFile(sections); - await this.writeFileWithPermissions(this.credentialsPath, serialized); - - this.logger.debug(`Removed profile ${profileName} from credentials file`); - } - - private async removeFromConfigFile(profileName: string): Promise { - if (!(await fs.pathExists(this.configPath))) { - this.logger.debug('Config file does not exist, nothing to remove'); - return; - } - - const content = await fs.readFile(this.configPath, 'utf-8'); - const sections = this.parseIniFile(content); - - // Config file uses "profile " format for non-default profiles - const sectionName = profileName === 'default' ? 'default' : `profile ${profileName}`; - - if (!(sectionName in sections)) { - this.logger.debug(`Profile ${profileName} not found in config file`); - return; - } - - delete sections[sectionName]; - - const serialized = this.serializeIniFile(sections); - await this.writeFileWithPermissions(this.configPath, serialized); - - this.logger.debug(`Removed profile ${profileName} from config file`); - } - - private async writeFileWithPermissions(filePath: string, content: string): Promise { - const fileExists = await fs.pathExists(filePath); - - await fs.writeFile(filePath, content, 'utf-8'); - - // Set file permissions to 600 (owner read/write only) - if (!fileExists) { - await fs.chmod(filePath, 0o600); - } - } - - /** - * Parses an INI file content into sections - * Handles both credentials format [profile] and config format [profile name] - */ - parseIniFile(content: string): IniSections { - const sections: IniSections = {}; - let currentSection: string | null = null; - - const lines = content.split(/\r?\n/); - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Skip empty lines and comments - if (!trimmedLine || trimmedLine.startsWith('#') || trimmedLine.startsWith(';')) { - continue; - } - - // Check for section header - const sectionMatch = trimmedLine.match(/^\[([^\]]+)\]$/); - if (sectionMatch) { - currentSection = sectionMatch[1].trim(); - if (!(currentSection in sections)) { - sections[currentSection] = {}; - } - continue; - } - - // Parse key-value pair - if (currentSection) { - const keyValueMatch = trimmedLine.match(/^([^=]+)=(.*)$/); - if (keyValueMatch) { - const key = keyValueMatch[1].trim(); - const value = keyValueMatch[2].trim(); - sections[currentSection][key] = value; - } - } - } - - return sections; - } - - /** - * Serializes sections back to INI file format - */ - serializeIniFile(sections: IniSections): string { - const lines: string[] = []; - - for (const [sectionName, sectionData] of Object.entries(sections)) { - lines.push(`[${sectionName}]`); - - for (const [key, value] of Object.entries(sectionData)) { - lines.push(`${key} = ${value}`); - } - - lines.push(''); // Empty line between sections - } - - return lines.join('\n'); - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/utils/directory-manager.ts b/packages/amplify-gen2-migration-e2e-system/src/utils/directory-manager.ts deleted file mode 100644 index 304df39236d..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/utils/directory-manager.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Directory management utilities for Amplify app initialization - * Handles app directory creation, uniqueness guarantees, conflict resolution, and cleanup - */ - -import * as fs from 'fs-extra'; -import * as path from 'path'; -import { Logger } from './logger'; - -export interface DirectoryCreationOptions { - /** Base path where the app directory should be created */ - basePath: string; - /** Name of the app directory to create */ - appName: string; - /** Permissions to set on the created directory */ - permissions?: string | number; -} - -export class DirectoryManager { - constructor(private readonly logger: Logger) {} - - async createAppDirectory(options: DirectoryCreationOptions): Promise { - try { - this.logger.info(`Creating app directory for ${options.appName}`); - this.logger.debug(`Base path: ${options.basePath}`); - this.logger.debug(`Options: ${JSON.stringify(options, null, 2)}`); - - // Ensure base path exists (create if needed for temp directories) - await fs.ensureDir(options.basePath); - - // Determine the target directory path - const targetPath = path.join(options.basePath, options.appName); - - // Check if directory already exists - const exists = await fs.pathExists(targetPath); - if (exists) { - throw new Error(`Directory already exists: ${targetPath}`); - } - - // Create the directory - await fs.ensureDir(targetPath); - this.logger.debug(`Directory created: ${targetPath}`); - - // Set permissions if specified - if (options.permissions !== undefined) { - await fs.chmod(targetPath, options.permissions); - this.logger.debug(`Set permissions ${options.permissions} on: ${targetPath}`); - } - - this.logger.info(`Successfully created app directory: ${targetPath}`); - - return targetPath; - } catch (error) { - throw Error(`Failed to create app directory: ${(error as Error).message}`); - } - } - - async copyDirectory(source: string, destination: string): Promise { - try { - this.logger.debug(`Copying directory: ${source} -> ${destination}`); - - // Validate source exists and is a directory - if (!(await fs.pathExists(source))) { - throw new Error(`Source directory does not exist: ${source}`); - } - - const sourceStat = await fs.stat(source); - if (!sourceStat.isDirectory()) { - throw new Error(`Source path is not a directory: ${source}`); - } - - // Ensure destination parent directory exists - const destinationParent = path.dirname(destination); - await fs.ensureDir(destinationParent); - - // Copy the directory - await fs.copy(source, destination, { - overwrite: false, // Don't overwrite existing files - errorOnExist: true, // Throw error if destination exists - preserveTimestamps: true, // Preserve file timestamps - // eslint-disable-next-line @typescript-eslint/no-unused-vars - filter: async (src: string, _dest: string) => { - return !src.includes('_snapshot') && !src.includes('node_modules'); - }, - }); - - this.logger.debug(`Successfully copied directory: ${source} -> ${destination}`); - } catch (error) { - throw Error(`Failed to copy directory: ${source} -> ${destination}. Error: ${error}`); - } - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/utils/file-manager.ts b/packages/amplify-gen2-migration-e2e-system/src/utils/file-manager.ts deleted file mode 100644 index 49f54e3152c..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/utils/file-manager.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * File management utilities for the Amplify Migration System - */ - -import * as fs from 'fs-extra'; -import * as path from 'path'; -import { Logger } from './logger'; - -export class FileManager { - constructor(private readonly logger: Logger) {} - - async readFile(filePath: string): Promise { - try { - this.logger.debug(`Reading file: ${filePath}`); - - if (!(await fs.pathExists(filePath))) { - throw new Error(`File does not exist: ${filePath}`); - } - - const content = await fs.readFile(filePath, 'utf-8'); - this.logger.debug(`Successfully read file: ${filePath} (${content.length} chars)`); - - return content; - } catch (error) { - this.logger.error(`Failed to read file: ${filePath}`, error as Error); - throw error; - } - } - - async writeFile(filePath: string, content: string): Promise { - try { - this.logger.debug(`Writing file: ${filePath} (${content.length} chars)`); - - // Ensure directory exists - await this.ensureDirectory(path.dirname(filePath)); - - await fs.writeFile(filePath, content, 'utf-8'); - this.logger.debug(`Successfully wrote file: ${filePath}`); - } catch (error) { - this.logger.error(`Failed to write file: ${filePath}`, error as Error); - throw error; - } - } - - async ensureDirectory(dirPath: string): Promise { - try { - this.logger.debug(`Ensuring directory exists: ${dirPath}`); - - await fs.ensureDir(dirPath); - this.logger.debug(`Directory ensured: ${dirPath}`); - } catch (error) { - this.logger.error(`Failed to ensure directory: ${dirPath}`, error as Error); - throw error; - } - } - - async listDirectories(dirPath: string): Promise { - try { - this.logger.debug(`Listing directories in: ${dirPath}`); - - if (!(await fs.pathExists(dirPath))) { - throw new Error(`Directory does not exist: ${dirPath}`); - } - - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - const directories = entries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); - - this.logger.debug(`Found ${directories.length} directories in: ${dirPath}`); - return directories; - } catch (error) { - this.logger.error(`Failed to list directories in: ${dirPath}`, error as Error); - throw error; - } - } - - async pathExists(filePath: string): Promise { - try { - return await fs.pathExists(filePath); - } catch (error) { - this.logger.debug(`Error checking path existence: ${filePath}`); - return false; - } - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/utils/logger.ts b/packages/amplify-gen2-migration-e2e-system/src/utils/logger.ts deleted file mode 100644 index 037a697c34b..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/utils/logger.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Comprehensive logging system for the Amplify Migration System - */ - -import * as fs from 'fs-extra'; -import * as path from 'path'; -import chalk from 'chalk'; -import { LogLevel, LogEntry } from '../types'; - -export class Logger { - private logLevel: LogLevel; - private logFilePath?: string; - private appName?: string; - - constructor(logLevel: LogLevel = LogLevel.INFO) { - this.logLevel = logLevel; - } - - public isDebug(): boolean { - return this.logLevel === LogLevel.DEBUG; - } - - debug(message: string): void { - this.log(LogLevel.DEBUG, message, undefined); - } - - info(message: string): void { - this.log(LogLevel.INFO, message, undefined); - } - - warn(message: string): void { - this.log(LogLevel.WARN, message, undefined); - } - - error(message: string, error?: Error): void { - this.log(LogLevel.ERROR, message, error); - } - - setAppName(appName: string): void { - this.appName = appName; - } - - setLogLevel(level: LogLevel): void { - this.logLevel = level; - this.debug(`Log level set to: ${level}`); - } - - setLogFilePath(filePath: string): void { - this.logFilePath = filePath; - - // Ensure log directory exists - const logDir = path.dirname(filePath); - fs.ensureDirSync(logDir); - - this.info(`File logging set: ${filePath}`); - } - - private log(level: LogLevel, message: string, error?: Error): void { - if (!this.shouldLog(level)) { - return; - } - - const entry: LogEntry = { - timestamp: new Date(), - level, - message, - error, - }; - - // Console output - const formattedMessage = this.formatMessage(entry); - console.log(formattedMessage); - - // File output - if (this.logFilePath) { - this.writeToFile(entry); - } - } - - private shouldLog(level: LogLevel): boolean { - const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR]; - const currentLevelIndex = levels.indexOf(this.logLevel); - const messageLevelIndex = levels.indexOf(level); - - return messageLevelIndex >= currentLevelIndex; - } - - private formatMessage(entry: LogEntry): string { - const timestamp = entry.timestamp.toISOString(); - const level = this.colorizeLevel(entry.level); - const errorInfo = this.formatError(entry.error); - - return `[${timestamp}] ${level} [${this.appName ?? ''}] ${entry.message}${errorInfo}`; - } - - private formatError(error?: Error): string { - if (!error) { - return ''; - } - - const message = ` | Error: ${error.message}`; - const stack = error.stack ? `\n${chalk.red(error.stack)}` : ''; - - return message + stack; - } - - private colorizeLevel(level: LogLevel): string { - switch (level) { - case LogLevel.DEBUG: - return chalk.gray('[DEBUG]'); - case LogLevel.INFO: - return chalk.blue('[INFO]'); - case LogLevel.WARN: - return chalk.yellow('[WARN]'); - case LogLevel.ERROR: - return chalk.red('[ERROR]'); - default: - return chalk.blue('[INFO]'); - } - } - - private writeToFile(entry: LogEntry): void { - if (!this.logFilePath) { - return; - } - - try { - const logLine = this.formatMessage(entry); - fs.appendFileSync(this.logFilePath, logLine + '\n'); - } catch (error) { - // Avoid infinite recursion by not logging file write errors - console.error('Failed to write to log file:', error); - } - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/src/utils/math.ts b/packages/amplify-gen2-migration-e2e-system/src/utils/math.ts deleted file mode 100644 index 2f45e534ebb..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/utils/math.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Generates a time-based Amplify app name with optional app name suffix. - * Format: [last8alphanumeric][YYMMDDHHMMSS] (20 chars for Amplify compatibility) - * CDK resource names (based off of amplify app name) must start with an alphabetic character. - * @param appName Optional app name from which to extract last 8 alphanumeric characters - * @returns A unique, sortable app name starting with a letter (max 20 chars) - */ -export const generateTimeBasedE2EAmplifyAppName = (appName: string): string => { - const now = new Date(); - - // Format: YYMMDDHHMMSSMM (human-readable, sortable) - 14 chars - const timestamp = [ - String(now.getFullYear()).slice(-2), // YY - String(now.getMonth() + 1).padStart(2, '0'), // MM - String(now.getDate()).padStart(2, '0'), // DD - String(now.getHours()).padStart(2, '0'), // HH - String(now.getMinutes()).padStart(2, '0'), // MM - ].join(''); - - // Extract last 8 alphanumeric characters from appName if provided - // Total: 10 (prefix) + 10 (timestamp) = 20 chars (at 20 char limit) - const alphanumericOnly = appName.replace(/[^a-zA-Z0-9]/g, ''); - const prefix = alphanumericOnly.slice(0, 10).toLowerCase(); - // Ensure prefix starts with a letter to avoid CDK resource naming issues - const safePrefix = /^[a-z]/.test(prefix) ? prefix : `e${prefix.slice(1)}`; - return `${safePrefix}${timestamp}`; -}; diff --git a/yarn.lock b/yarn.lock index d698b3d58fa..49775c54da3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,9 +19,9 @@ __metadata: languageName: node linkType: hard -"@amplify-migration-apps/backend-only@workspace:amplify-migration-apps/backend-only/_snapshot.post.generate": +"@amplify-migration-apps/backend-only-snapshot@workspace:amplify-migration-apps/backend-only/_snapshot.post.generate": version: 0.0.0-use.local - resolution: "@amplify-migration-apps/backend-only@workspace:amplify-migration-apps/backend-only/_snapshot.post.generate" + resolution: "@amplify-migration-apps/backend-only-snapshot@workspace:amplify-migration-apps/backend-only/_snapshot.post.generate" dependencies: "@aws-amplify/backend": ^1.18.0 "@aws-amplify/backend-cli": ^1.8.0 @@ -38,9 +38,17 @@ __metadata: languageName: unknown linkType: soft -"@amplify-migration-apps/discussions@workspace:amplify-migration-apps/discussions/_snapshot.post.generate": +"@amplify-migration-apps/backend-only@workspace:amplify-migration-apps/backend-only": version: 0.0.0-use.local - resolution: "@amplify-migration-apps/discussions@workspace:amplify-migration-apps/discussions/_snapshot.post.generate" + resolution: "@amplify-migration-apps/backend-only@workspace:amplify-migration-apps/backend-only" + dependencies: + "@aws-sdk/client-cognito-identity-provider": ^3.936.0 + languageName: unknown + linkType: soft + +"@amplify-migration-apps/discussions-snapshot@workspace:amplify-migration-apps/discussions/_snapshot.post.generate": + version: 0.0.0-use.local + resolution: "@amplify-migration-apps/discussions-snapshot@workspace:amplify-migration-apps/discussions/_snapshot.post.generate" dependencies: "@aws-amplify/backend": ^1.18.0 "@aws-amplify/backend-cli": ^1.8.0 @@ -60,9 +68,24 @@ __metadata: languageName: unknown linkType: soft -"@amplify-migration-apps/fitness-goal-tracker@workspace:amplify-migration-apps/fitness-tracker/_snapshot.post.generate": +"@amplify-migration-apps/discussions@workspace:amplify-migration-apps/discussions": version: 0.0.0-use.local - resolution: "@amplify-migration-apps/fitness-goal-tracker@workspace:amplify-migration-apps/fitness-tracker/_snapshot.post.generate" + resolution: "@amplify-migration-apps/discussions@workspace:amplify-migration-apps/discussions" + dependencies: + "@aws-sdk/client-cognito-identity-provider": ^3.936.0 + "@aws-sdk/client-dynamodb": ^3.936.0 + "@aws-sdk/lib-dynamodb": ^3.936.0 + "@types/jest": ^29.5.14 + aws-amplify: ^6.15.8 + jest: ^29.7.0 + ts-jest: ^29.3.4 + vite: ^7.2.2 + languageName: unknown + linkType: soft + +"@amplify-migration-apps/fitness-goal-tracker-snapshot@workspace:amplify-migration-apps/fitness-tracker/_snapshot.post.generate": + version: 0.0.0-use.local + resolution: "@amplify-migration-apps/fitness-goal-tracker-snapshot@workspace:amplify-migration-apps/fitness-tracker/_snapshot.post.generate" dependencies: "@aws-amplify/backend": ^1.18.0 "@aws-amplify/backend-cli": ^1.8.0 @@ -99,9 +122,42 @@ __metadata: languageName: unknown linkType: soft -"@amplify-migration-apps/importedresources@workspace:amplify-migration-apps/imported-resources/_snapshot.post.generate": +"@amplify-migration-apps/fitness-goal-tracker@workspace:amplify-migration-apps/fitness-tracker": version: 0.0.0-use.local - resolution: "@amplify-migration-apps/importedresources@workspace:amplify-migration-apps/imported-resources/_snapshot.post.generate" + resolution: "@amplify-migration-apps/fitness-goal-tracker@workspace:amplify-migration-apps/fitness-tracker" + dependencies: + "@aws-amplify/ui-react": ^6.13.1 + "@aws-sdk/client-cognito-identity-provider": ^3.936.0 + "@aws-sdk/client-dynamodb": ^3.969.0 + "@aws-sdk/client-lambda": ^3.936.0 + "@aws-sdk/lib-dynamodb": ^3.969.0 + "@eslint/js": ^9.39.1 + "@types/jest": ^29.5.14 + "@types/node": ^24.10.1 + "@types/react": ^19.2.5 + "@types/react-dom": ^19.2.3 + "@vitejs/plugin-react": ^5.1.1 + aws-amplify: ^6.15.8 + aws-serverless-express: ^3.3.5 + body-parser: ^1.17.1 + eslint: ^9.39.1 + eslint-plugin-react-hooks: ^7.0.1 + eslint-plugin-react-refresh: ^0.4.24 + express: ^4.15.2 + globals: ^16.5.0 + jest: ^29.7.0 + react: ^19.2.0 + react-dom: ^19.2.0 + ts-jest: ^29.3.4 + typescript: ~5.9.3 + typescript-eslint: ^8.46.4 + vite: ^7.2.4 + languageName: unknown + linkType: soft + +"@amplify-migration-apps/importedresources-snapshot@workspace:amplify-migration-apps/imported-resources/_snapshot.post.generate": + version: 0.0.0-use.local + resolution: "@amplify-migration-apps/importedresources-snapshot@workspace:amplify-migration-apps/imported-resources/_snapshot.post.generate" dependencies: "@aws-amplify/backend": ^1.18.0 "@aws-amplify/backend-cli": ^1.8.0 @@ -133,9 +189,40 @@ __metadata: languageName: unknown linkType: soft -"@amplify-migration-apps/media-vault@workspace:amplify-migration-apps/media-vault/_snapshot.post.generate": +"@amplify-migration-apps/importedresources@workspace:amplify-migration-apps/imported-resources": + version: 0.0.0-use.local + resolution: "@amplify-migration-apps/importedresources@workspace:amplify-migration-apps/imported-resources" + dependencies: + "@aws-amplify/ui-react": ^6.13.0 + "@aws-amplify/ui-react-core": ^3.4.6 + "@aws-sdk/client-cognito-identity": ^3.0.0 + "@aws-sdk/client-cognito-identity-provider": ^3.0.0 + "@aws-sdk/client-iam": ^3.0.0 + "@eslint/js": ^9.36.0 + "@types/jest": ^29.5.0 + "@types/node": ^24.6.0 + "@types/react": ^19.1.16 + "@types/react-dom": ^19.1.9 + "@vitejs/plugin-react": ^5.0.4 + aws-amplify: ^6.15.7 + eslint: ^9.36.0 + eslint-plugin-react-hooks: ^5.2.0 + eslint-plugin-react-refresh: ^0.4.22 + globals: ^16.4.0 + jest: ^29.7.0 + react: ^19.1.1 + react-dom: ^19.1.1 + ts-jest: ^29.1.0 + tsx: ^4.19.0 + typescript: ~5.9.3 + typescript-eslint: ^8.45.0 + vite: ^7.1.7 + languageName: unknown + linkType: soft + +"@amplify-migration-apps/media-vault-snapshot@workspace:amplify-migration-apps/media-vault/_snapshot.post.generate": version: 0.0.0-use.local - resolution: "@amplify-migration-apps/media-vault@workspace:amplify-migration-apps/media-vault/_snapshot.post.generate" + resolution: "@amplify-migration-apps/media-vault-snapshot@workspace:amplify-migration-apps/media-vault/_snapshot.post.generate" dependencies: "@aws-amplify/backend": ^1.18.0 "@aws-amplify/backend-cli": ^1.8.0 @@ -170,9 +257,39 @@ __metadata: languageName: unknown linkType: soft -"@amplify-migration-apps/mood-board-app@workspace:amplify-migration-apps/mood-board/_snapshot.post.generate": +"@amplify-migration-apps/media-vault@workspace:amplify-migration-apps/media-vault": version: 0.0.0-use.local - resolution: "@amplify-migration-apps/mood-board-app@workspace:amplify-migration-apps/mood-board/_snapshot.post.generate" + resolution: "@amplify-migration-apps/media-vault@workspace:amplify-migration-apps/media-vault" + dependencies: + "@aws-amplify/storage": ^6.10.1 + "@aws-amplify/ui-react": ^6.13.1 + "@aws-sdk/client-cognito-identity-provider": ^3.936.0 + "@aws-sdk/client-s3": 3.966.0 + "@eslint/js": ^9.39.1 + "@types/jest": ^29.5.14 + "@types/node": ^24.10.1 + "@types/react": ^19.2.5 + "@types/react-dom": ^19.2.3 + "@vitejs/plugin-react": ^5.1.1 + aws-amplify: ^6.15.8 + eslint: ^9.39.1 + eslint-plugin-react-hooks: ^7.0.1 + eslint-plugin-react-refresh: ^0.4.24 + globals: ^16.5.0 + jest: ^29.7.0 + react: ^19.2.0 + react-dom: ^19.2.0 + sharp: ^0.34.5 + ts-jest: ^29.3.4 + typescript: ~5.9.3 + typescript-eslint: ^8.46.4 + vite: ^7.2.4 + languageName: unknown + linkType: soft + +"@amplify-migration-apps/mood-board-app-snapshot@workspace:amplify-migration-apps/mood-board/_snapshot.post.generate": + version: 0.0.0-use.local + resolution: "@amplify-migration-apps/mood-board-app-snapshot@workspace:amplify-migration-apps/mood-board/_snapshot.post.generate" dependencies: "@aws-amplify/backend": ^1.18.0 "@aws-amplify/backend-cli": ^1.8.0 @@ -197,9 +314,29 @@ __metadata: languageName: unknown linkType: soft -"@amplify-migration-apps/product-catalog@workspace:amplify-migration-apps/product-catalog/_snapshot.post.generate": +"@amplify-migration-apps/mood-board-app@workspace:amplify-migration-apps/mood-board": version: 0.0.0-use.local - resolution: "@amplify-migration-apps/product-catalog@workspace:amplify-migration-apps/product-catalog/_snapshot.post.generate" + resolution: "@amplify-migration-apps/mood-board-app@workspace:amplify-migration-apps/mood-board" + dependencies: + "@aws-amplify/ui-react": ^6.10.1 + "@aws-sdk/client-cognito-identity-provider": ^3.936.0 + "@types/jest": ^29.5.14 + "@types/react": ^18.3.3 + "@types/react-dom": ^18.3.0 + "@vitejs/plugin-react": ^4.3.1 + aws-amplify: ^6.14.1 + jest: ^29.7.0 + react: ^18.3.1 + react-dom: ^18.3.1 + ts-jest: ^29.3.4 + typescript: ^5.5.3 + vite: ^5.4.2 + languageName: unknown + linkType: soft + +"@amplify-migration-apps/product-catalog-snapshot@workspace:amplify-migration-apps/product-catalog/_snapshot.post.generate": + version: 0.0.0-use.local + resolution: "@amplify-migration-apps/product-catalog-snapshot@workspace:amplify-migration-apps/product-catalog/_snapshot.post.generate" dependencies: "@aws-amplify/backend": ^1.18.0 "@aws-amplify/backend-cli": ^1.8.0 @@ -235,9 +372,38 @@ __metadata: languageName: unknown linkType: soft -"@amplify-migration-apps/project-boards@workspace:amplify-migration-apps/project-boards/_snapshot.post.generate": +"@amplify-migration-apps/product-catalog@workspace:amplify-migration-apps/product-catalog": + version: 0.0.0-use.local + resolution: "@amplify-migration-apps/product-catalog@workspace:amplify-migration-apps/product-catalog" + dependencies: + "@aws-amplify/ui-react": ^6.13.1 + "@aws-sdk/client-appsync": ^3.936.0 + "@aws-sdk/client-cognito-identity-provider": ^3.936.0 + "@aws-sdk/client-ssm": ^3.936.0 + "@eslint/js": ^9.39.1 + "@types/jest": ^29.5.14 + "@types/node": ^24.10.1 + "@types/react": ^19.2.5 + "@types/react-dom": ^19.2.3 + "@vitejs/plugin-react": ^5.1.1 + aws-amplify: ^6.15.8 + eslint: ^9.39.1 + eslint-plugin-react-hooks: ^7.0.1 + eslint-plugin-react-refresh: ^0.4.24 + globals: ^16.5.0 + jest: ^29.7.0 + react: ^19.2.0 + react-dom: ^19.2.0 + ts-jest: ^29.3.4 + typescript: ~5.9.3 + typescript-eslint: ^8.46.4 + vite: ^7.2.4 + languageName: unknown + linkType: soft + +"@amplify-migration-apps/project-boards-snapshot@workspace:amplify-migration-apps/project-boards/_snapshot.post.generate": version: 0.0.0-use.local - resolution: "@amplify-migration-apps/project-boards@workspace:amplify-migration-apps/project-boards/_snapshot.post.generate" + resolution: "@amplify-migration-apps/project-boards-snapshot@workspace:amplify-migration-apps/project-boards/_snapshot.post.generate" dependencies: "@aws-amplify/backend": ^1.18.0 "@aws-amplify/backend-cli": ^1.8.0 @@ -269,9 +435,37 @@ __metadata: languageName: unknown linkType: soft -"@amplify-migration-apps/store-locator@workspace:amplify-migration-apps/store-locator/_snapshot.post.generate": +"@amplify-migration-apps/project-boards@workspace:amplify-migration-apps/project-boards": version: 0.0.0-use.local - resolution: "@amplify-migration-apps/store-locator@workspace:amplify-migration-apps/store-locator/_snapshot.post.generate" + resolution: "@amplify-migration-apps/project-boards@workspace:amplify-migration-apps/project-boards" + dependencies: + "@aws-amplify/ui-react": ^6.13.0 + "@aws-amplify/ui-react-core": ^3.4.6 + "@aws-sdk/client-cognito-identity-provider": ^3.936.0 + "@eslint/js": ^9.36.0 + "@types/jest": ^29.5.14 + "@types/node": ^24.6.0 + "@types/react": ^19.1.16 + "@types/react-dom": ^19.1.9 + "@vitejs/plugin-react": ^5.0.4 + aws-amplify: ^6.15.7 + eslint: ^9.36.0 + eslint-plugin-react-hooks: ^5.2.0 + eslint-plugin-react-refresh: ^0.4.22 + globals: ^16.4.0 + jest: ^29.7.0 + react: ^19.1.1 + react-dom: ^19.1.1 + ts-jest: ^29.3.4 + typescript: ~5.9.3 + typescript-eslint: ^8.45.0 + vite: ^7.1.7 + languageName: unknown + linkType: soft + +"@amplify-migration-apps/store-locator-snapshot@workspace:amplify-migration-apps/store-locator/_snapshot.post.generate": + version: 0.0.0-use.local + resolution: "@amplify-migration-apps/store-locator-snapshot@workspace:amplify-migration-apps/store-locator/_snapshot.post.generate" dependencies: "@aws-amplify/backend": ^1.18.0 "@aws-amplify/backend-cli": ^1.8.0 @@ -308,6 +502,37 @@ __metadata: languageName: unknown linkType: soft +"@amplify-migration-apps/store-locator@workspace:amplify-migration-apps/store-locator": + version: 0.0.0-use.local + resolution: "@amplify-migration-apps/store-locator@workspace:amplify-migration-apps/store-locator" + dependencies: + "@aws-amplify/geo": ^3.0.92 + "@aws-amplify/ui-react": ^6.13.2 + "@aws-amplify/ui-react-geo": ^2.2.13 + "@aws-sdk/client-cognito-identity-provider": ^3.936.0 + "@eslint/js": ^9.39.1 + "@types/jest": ^29.5.14 + "@types/node": ^24.10.1 + "@types/react": ^19.2.5 + "@types/react-dom": ^19.2.3 + "@vitejs/plugin-react": ^5.1.1 + aws-amplify: ^6.16.0 + eslint: ^9.39.1 + eslint-plugin-react-hooks: ^7.0.1 + eslint-plugin-react-refresh: ^0.4.24 + globals: ^16.5.0 + jest: ^29.7.0 + maplibre-gl: ^2.4.0 + maplibre-gl-js-amplify: ^4.0.2 + react: ^19.2.0 + react-dom: ^19.2.0 + ts-jest: ^29.3.4 + typescript: ~5.9.3 + typescript-eslint: ^8.46.4 + vite: ^7.2.4 + languageName: unknown + linkType: soft + "@apideck/better-ajv-errors@npm:^0.3.1": version: 0.3.6 resolution: "@apideck/better-ajv-errors@npm:0.3.6" @@ -4859,6 +5084,53 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-cognito-identity-provider@npm:^3.936.0": + version: 3.1023.0 + resolution: "@aws-sdk/client-cognito-identity-provider@npm:3.1023.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/credential-provider-node": ^3.972.29 + "@aws-sdk/middleware-host-header": ^3.972.8 + "@aws-sdk/middleware-logger": ^3.972.8 + "@aws-sdk/middleware-recursion-detection": ^3.972.9 + "@aws-sdk/middleware-user-agent": ^3.972.28 + "@aws-sdk/region-config-resolver": ^3.972.10 + "@aws-sdk/types": ^3.973.6 + "@aws-sdk/util-endpoints": ^3.996.5 + "@aws-sdk/util-user-agent-browser": ^3.972.8 + "@aws-sdk/util-user-agent-node": ^3.973.14 + "@smithy/config-resolver": ^4.4.13 + "@smithy/core": ^3.23.13 + "@smithy/fetch-http-handler": ^5.3.15 + "@smithy/hash-node": ^4.2.12 + "@smithy/invalid-dependency": ^4.2.12 + "@smithy/middleware-content-length": ^4.2.12 + "@smithy/middleware-endpoint": ^4.4.28 + "@smithy/middleware-retry": ^4.4.46 + "@smithy/middleware-serde": ^4.2.16 + "@smithy/middleware-stack": ^4.2.12 + "@smithy/node-config-provider": ^4.3.12 + "@smithy/node-http-handler": ^4.5.1 + "@smithy/protocol-http": ^5.3.12 + "@smithy/smithy-client": ^4.12.8 + "@smithy/types": ^4.13.1 + "@smithy/url-parser": ^4.2.12 + "@smithy/util-base64": ^4.3.2 + "@smithy/util-body-length-browser": ^4.2.2 + "@smithy/util-body-length-node": ^4.2.3 + "@smithy/util-defaults-mode-browser": ^4.3.44 + "@smithy/util-defaults-mode-node": ^4.2.48 + "@smithy/util-endpoints": ^3.3.3 + "@smithy/util-middleware": ^4.2.12 + "@smithy/util-retry": ^4.2.13 + "@smithy/util-utf8": ^4.2.2 + tslib: ^2.6.2 + checksum: 90c841b59797cf8af0c9114c801c4d4d9f55a2deb2ba2b8281f66f599eace37c81445cb96e8bcda4456fb1ee53ec980d2e68db5be9149f4bc05bd7599bb2ac67 + languageName: node + linkType: hard + "@aws-sdk/client-cognito-identity@npm:3.1007.0, @aws-sdk/client-cognito-identity@npm:^3.919.0": version: 3.1007.0 resolution: "@aws-sdk/client-cognito-identity@npm:3.1007.0" @@ -4955,6 +5227,53 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-cognito-identity@npm:^3.0.0": + version: 3.1023.0 + resolution: "@aws-sdk/client-cognito-identity@npm:3.1023.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/credential-provider-node": ^3.972.29 + "@aws-sdk/middleware-host-header": ^3.972.8 + "@aws-sdk/middleware-logger": ^3.972.8 + "@aws-sdk/middleware-recursion-detection": ^3.972.9 + "@aws-sdk/middleware-user-agent": ^3.972.28 + "@aws-sdk/region-config-resolver": ^3.972.10 + "@aws-sdk/types": ^3.973.6 + "@aws-sdk/util-endpoints": ^3.996.5 + "@aws-sdk/util-user-agent-browser": ^3.972.8 + "@aws-sdk/util-user-agent-node": ^3.973.14 + "@smithy/config-resolver": ^4.4.13 + "@smithy/core": ^3.23.13 + "@smithy/fetch-http-handler": ^5.3.15 + "@smithy/hash-node": ^4.2.12 + "@smithy/invalid-dependency": ^4.2.12 + "@smithy/middleware-content-length": ^4.2.12 + "@smithy/middleware-endpoint": ^4.4.28 + "@smithy/middleware-retry": ^4.4.46 + "@smithy/middleware-serde": ^4.2.16 + "@smithy/middleware-stack": ^4.2.12 + "@smithy/node-config-provider": ^4.3.12 + "@smithy/node-http-handler": ^4.5.1 + "@smithy/protocol-http": ^5.3.12 + "@smithy/smithy-client": ^4.12.8 + "@smithy/types": ^4.13.1 + "@smithy/url-parser": ^4.2.12 + "@smithy/util-base64": ^4.3.2 + "@smithy/util-body-length-browser": ^4.2.2 + "@smithy/util-body-length-node": ^4.2.3 + "@smithy/util-defaults-mode-browser": ^4.3.44 + "@smithy/util-defaults-mode-node": ^4.2.48 + "@smithy/util-endpoints": ^3.3.3 + "@smithy/util-middleware": ^4.2.12 + "@smithy/util-retry": ^4.2.13 + "@smithy/util-utf8": ^4.2.2 + tslib: ^2.6.2 + checksum: 5be8cac399da25ea91a1e90f56f621d980e3c3003f0b3639260b6bc733c2876dd5c527a58296fa73b76cc2d184e78423cf8086a439231af79edc38b758aae97c + languageName: node + linkType: hard + "@aws-sdk/client-comprehend@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/client-comprehend@npm:3.6.1" @@ -5521,6 +5840,54 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-iam@npm:^3.0.0": + version: 3.1023.0 + resolution: "@aws-sdk/client-iam@npm:3.1023.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/credential-provider-node": ^3.972.29 + "@aws-sdk/middleware-host-header": ^3.972.8 + "@aws-sdk/middleware-logger": ^3.972.8 + "@aws-sdk/middleware-recursion-detection": ^3.972.9 + "@aws-sdk/middleware-user-agent": ^3.972.28 + "@aws-sdk/region-config-resolver": ^3.972.10 + "@aws-sdk/types": ^3.973.6 + "@aws-sdk/util-endpoints": ^3.996.5 + "@aws-sdk/util-user-agent-browser": ^3.972.8 + "@aws-sdk/util-user-agent-node": ^3.973.14 + "@smithy/config-resolver": ^4.4.13 + "@smithy/core": ^3.23.13 + "@smithy/fetch-http-handler": ^5.3.15 + "@smithy/hash-node": ^4.2.12 + "@smithy/invalid-dependency": ^4.2.12 + "@smithy/middleware-content-length": ^4.2.12 + "@smithy/middleware-endpoint": ^4.4.28 + "@smithy/middleware-retry": ^4.4.46 + "@smithy/middleware-serde": ^4.2.16 + "@smithy/middleware-stack": ^4.2.12 + "@smithy/node-config-provider": ^4.3.12 + "@smithy/node-http-handler": ^4.5.1 + "@smithy/protocol-http": ^5.3.12 + "@smithy/smithy-client": ^4.12.8 + "@smithy/types": ^4.13.1 + "@smithy/url-parser": ^4.2.12 + "@smithy/util-base64": ^4.3.2 + "@smithy/util-body-length-browser": ^4.2.2 + "@smithy/util-body-length-node": ^4.2.3 + "@smithy/util-defaults-mode-browser": ^4.3.44 + "@smithy/util-defaults-mode-node": ^4.2.48 + "@smithy/util-endpoints": ^3.3.3 + "@smithy/util-middleware": ^4.2.12 + "@smithy/util-retry": ^4.2.13 + "@smithy/util-utf8": ^4.2.2 + "@smithy/util-waiter": ^4.2.14 + tslib: ^2.6.2 + checksum: 16fdee4717e0e0b4a0acf9225c2f42198e95d860afdcb1412159bb84bdb39f14663d036a86a344ed743a5c073f3e98fa8289ac58e02142d1c4c75605d683e02b + languageName: node + linkType: hard + "@aws-sdk/client-kinesis@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/client-kinesis@npm:3.6.1" @@ -8114,6 +8481,27 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/core@npm:^3.973.26": + version: 3.973.26 + resolution: "@aws-sdk/core@npm:3.973.26" + dependencies: + "@aws-sdk/types": ^3.973.6 + "@aws-sdk/xml-builder": ^3.972.16 + "@smithy/core": ^3.23.13 + "@smithy/node-config-provider": ^4.3.12 + "@smithy/property-provider": ^4.2.12 + "@smithy/protocol-http": ^5.3.12 + "@smithy/signature-v4": ^5.3.12 + "@smithy/smithy-client": ^4.12.8 + "@smithy/types": ^4.13.1 + "@smithy/util-base64": ^4.3.2 + "@smithy/util-middleware": ^4.2.12 + "@smithy/util-utf8": ^4.2.2 + tslib: ^2.6.2 + checksum: e3f7517b3c6d5e3a7cf12f812ba319f657e200b605682e8a50f89bf1f78fc1cb018a08b9c91517d86dc9de6560c24bc18a7d8902a6744a1fe98f72ad41cab430 + languageName: node + linkType: hard + "@aws-sdk/crc64-nvme@npm:3.965.0": version: 3.965.0 resolution: "@aws-sdk/crc64-nvme@npm:3.965.0" @@ -8259,6 +8647,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-env@npm:^3.972.24": + version: 3.972.24 + resolution: "@aws-sdk/credential-provider-env@npm:3.972.24" + dependencies: + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/types": ^3.973.6 + "@smithy/property-provider": ^4.2.12 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: bac713a6d4ca4e36c8fd783dad61b9ee279f10bf0a22c57c2f7c906735ebfadf0da951569ad14739a1b1eff4dbe1bce7afc7dbde03066a6a2b1870da81bf56d3 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-http@npm:3.622.0": version: 3.622.0 resolution: "@aws-sdk/credential-provider-http@npm:3.622.0" @@ -8383,6 +8784,24 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-http@npm:^3.972.26": + version: 3.972.26 + resolution: "@aws-sdk/credential-provider-http@npm:3.972.26" + dependencies: + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/types": ^3.973.6 + "@smithy/fetch-http-handler": ^5.3.15 + "@smithy/node-http-handler": ^4.5.1 + "@smithy/property-provider": ^4.2.12 + "@smithy/protocol-http": ^5.3.12 + "@smithy/smithy-client": ^4.12.8 + "@smithy/types": ^4.13.1 + "@smithy/util-stream": ^4.5.21 + tslib: ^2.6.2 + checksum: d837caa15b8a22112fcdafd2c0c3fc1d22c5be25d6124d1a67900acd4e9c5b44b1101467a936f8ff2c3eaec99ade769d816e12833558572bb8feef98c0e3cff5 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-imds@npm:3.186.0": version: 3.186.0 resolution: "@aws-sdk/credential-provider-imds@npm:3.186.0" @@ -8607,6 +9026,28 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-ini@npm:^3.972.28": + version: 3.972.28 + resolution: "@aws-sdk/credential-provider-ini@npm:3.972.28" + dependencies: + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/credential-provider-env": ^3.972.24 + "@aws-sdk/credential-provider-http": ^3.972.26 + "@aws-sdk/credential-provider-login": ^3.972.28 + "@aws-sdk/credential-provider-process": ^3.972.24 + "@aws-sdk/credential-provider-sso": ^3.972.28 + "@aws-sdk/credential-provider-web-identity": ^3.972.28 + "@aws-sdk/nested-clients": ^3.996.18 + "@aws-sdk/types": ^3.973.6 + "@smithy/credential-provider-imds": ^4.2.12 + "@smithy/property-provider": ^4.2.12 + "@smithy/shared-ini-file-loader": ^4.4.7 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: 9bb052bdfcb36c3ff264b1aa00c74210c2f77ceb3b82fe650870020d42454bf0a658c6e31068e00073e1763a1c01c6e50e0e1318fb3b28e73d9514854a2dc6d7 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-login@npm:3.966.0": version: 3.966.0 resolution: "@aws-sdk/credential-provider-login@npm:3.966.0" @@ -8655,6 +9096,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-login@npm:^3.972.28": + version: 3.972.28 + resolution: "@aws-sdk/credential-provider-login@npm:3.972.28" + dependencies: + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/nested-clients": ^3.996.18 + "@aws-sdk/types": ^3.973.6 + "@smithy/property-provider": ^4.2.12 + "@smithy/protocol-http": ^5.3.12 + "@smithy/shared-ini-file-loader": ^4.4.7 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: d5d32d53ef8416c847b2e612c219f449bd2dce44768618add45d80b6024fc8de73da910163a720e1340cbcc1b28693b2305a585d2cc6d7d99e59f24b7e36c034 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-node@npm:3.186.0": version: 3.186.0 resolution: "@aws-sdk/credential-provider-node@npm:3.186.0" @@ -8849,6 +9306,26 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-node@npm:^3.972.29": + version: 3.972.29 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.29" + dependencies: + "@aws-sdk/credential-provider-env": ^3.972.24 + "@aws-sdk/credential-provider-http": ^3.972.26 + "@aws-sdk/credential-provider-ini": ^3.972.28 + "@aws-sdk/credential-provider-process": ^3.972.24 + "@aws-sdk/credential-provider-sso": ^3.972.28 + "@aws-sdk/credential-provider-web-identity": ^3.972.28 + "@aws-sdk/types": ^3.973.6 + "@smithy/credential-provider-imds": ^4.2.12 + "@smithy/property-provider": ^4.2.12 + "@smithy/shared-ini-file-loader": ^4.4.7 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: 699e05fd7a840454fe248f281076c023edaedcd3a0baf9f283f9f0dae54b4871a0b42a4b0823024beffd0f931cea3e8ed4e1616fb53559de9111af2d38c54614 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-process@npm:3.186.0": version: 3.186.0 resolution: "@aws-sdk/credential-provider-process@npm:3.186.0" @@ -8957,6 +9434,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-process@npm:^3.972.24": + version: 3.972.24 + resolution: "@aws-sdk/credential-provider-process@npm:3.972.24" + dependencies: + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/types": ^3.973.6 + "@smithy/property-provider": ^4.2.12 + "@smithy/shared-ini-file-loader": ^4.4.7 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: 4887a495b7fbd03b8b0dcd4c6105f2994b18aeafcfb8e8439fd82d38a75f8d478b2dfa54e155855434bcebdb4aa4681ddf316ef19187927cecdf01f83cab9e1f + languageName: node + linkType: hard + "@aws-sdk/credential-provider-sso@npm:3.186.0": version: 3.186.0 resolution: "@aws-sdk/credential-provider-sso@npm:3.186.0" @@ -9095,6 +9586,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-sso@npm:^3.972.28": + version: 3.972.28 + resolution: "@aws-sdk/credential-provider-sso@npm:3.972.28" + dependencies: + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/nested-clients": ^3.996.18 + "@aws-sdk/token-providers": 3.1021.0 + "@aws-sdk/types": ^3.973.6 + "@smithy/property-provider": ^4.2.12 + "@smithy/shared-ini-file-loader": ^4.4.7 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: 9b47f2a9ba09691181946cb7bc39d6df399eedf1d18462a5756ef90bb03a3e9d70c9c5ee5f7f2b3c9867fc68ce21b20e0ce6b69b8adf7804293ebffb9f950231 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-web-identity@npm:3.186.0": version: 3.186.0 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.186.0" @@ -9195,6 +9702,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-web-identity@npm:^3.972.28": + version: 3.972.28 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.28" + dependencies: + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/nested-clients": ^3.996.18 + "@aws-sdk/types": ^3.973.6 + "@smithy/property-provider": ^4.2.12 + "@smithy/shared-ini-file-loader": ^4.4.7 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: 4de71941f2149823533c8b1394e83895380e6a23f7dd41e9bab38988a07891e9098cf4a4cd21dfb2dbc87a138768866f59bca93be4c9b710968140a58e85882e + languageName: node + linkType: hard + "@aws-sdk/credential-providers@npm:3.721.0": version: 3.721.0 resolution: "@aws-sdk/credential-providers@npm:3.721.0" @@ -10031,6 +10553,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-recursion-detection@npm:^3.972.9": + version: 3.972.9 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.972.9" + dependencies: + "@aws-sdk/types": ^3.973.6 + "@aws/lambda-invoke-store": ^0.2.2 + "@smithy/protocol-http": ^5.3.12 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: 2aadef74ed279b4d2aeb15fed943702ddce7f7cd56cb0957a288ec0450110ef11715a760391324f772d9366bb2bf7cee5d544b006da4c0344cfbc7db5feb1acc + languageName: node + linkType: hard + "@aws-sdk/middleware-retry@npm:3.186.0": version: 3.186.0 resolution: "@aws-sdk/middleware-retry@npm:3.186.0" @@ -10413,6 +10948,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-user-agent@npm:^3.972.28": + version: 3.972.28 + resolution: "@aws-sdk/middleware-user-agent@npm:3.972.28" + dependencies: + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/types": ^3.973.6 + "@aws-sdk/util-endpoints": ^3.996.5 + "@smithy/core": ^3.23.13 + "@smithy/protocol-http": ^5.3.12 + "@smithy/types": ^4.13.1 + "@smithy/util-retry": ^4.2.13 + tslib: ^2.6.2 + checksum: 6905cb2e17ad48a5a72e11303f4c8fc112a1fc9304f4f9622818c9f33b9339c304021c246ee596a4b1fecbddc16a23ccc2674076f18c303e2cad34aa3a8b0675 + languageName: node + linkType: hard + "@aws-sdk/middleware-websocket@npm:^3.972.12": version: 3.972.12 resolution: "@aws-sdk/middleware-websocket@npm:3.972.12" @@ -10617,6 +11168,52 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/nested-clients@npm:^3.996.18": + version: 3.996.18 + resolution: "@aws-sdk/nested-clients@npm:3.996.18" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/middleware-host-header": ^3.972.8 + "@aws-sdk/middleware-logger": ^3.972.8 + "@aws-sdk/middleware-recursion-detection": ^3.972.9 + "@aws-sdk/middleware-user-agent": ^3.972.28 + "@aws-sdk/region-config-resolver": ^3.972.10 + "@aws-sdk/types": ^3.973.6 + "@aws-sdk/util-endpoints": ^3.996.5 + "@aws-sdk/util-user-agent-browser": ^3.972.8 + "@aws-sdk/util-user-agent-node": ^3.973.14 + "@smithy/config-resolver": ^4.4.13 + "@smithy/core": ^3.23.13 + "@smithy/fetch-http-handler": ^5.3.15 + "@smithy/hash-node": ^4.2.12 + "@smithy/invalid-dependency": ^4.2.12 + "@smithy/middleware-content-length": ^4.2.12 + "@smithy/middleware-endpoint": ^4.4.28 + "@smithy/middleware-retry": ^4.4.46 + "@smithy/middleware-serde": ^4.2.16 + "@smithy/middleware-stack": ^4.2.12 + "@smithy/node-config-provider": ^4.3.12 + "@smithy/node-http-handler": ^4.5.1 + "@smithy/protocol-http": ^5.3.12 + "@smithy/smithy-client": ^4.12.8 + "@smithy/types": ^4.13.1 + "@smithy/url-parser": ^4.2.12 + "@smithy/util-base64": ^4.3.2 + "@smithy/util-body-length-browser": ^4.2.2 + "@smithy/util-body-length-node": ^4.2.3 + "@smithy/util-defaults-mode-browser": ^4.3.44 + "@smithy/util-defaults-mode-node": ^4.2.48 + "@smithy/util-endpoints": ^3.3.3 + "@smithy/util-middleware": ^4.2.12 + "@smithy/util-retry": ^4.2.13 + "@smithy/util-utf8": ^4.2.2 + tslib: ^2.6.2 + checksum: af5dc319a47dfeddea7ecc824db69f950930db838e7934d1f8a9e5f3216b80b9f1e4d4b0bd0d3a47ef49b1a0b62f577b4615535574a4f316e4527095374deaa3 + languageName: node + linkType: hard + "@aws-sdk/node-config-provider@npm:3.186.0": version: 3.186.0 resolution: "@aws-sdk/node-config-provider@npm:3.186.0" @@ -10827,6 +11424,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/region-config-resolver@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/region-config-resolver@npm:3.972.10" + dependencies: + "@aws-sdk/types": ^3.973.6 + "@smithy/config-resolver": ^4.4.13 + "@smithy/node-config-provider": ^4.3.12 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: b385fa5be853c7bd5110c80eafd400890affe7c753c0fd1ebbc213d4943aa9cfac2b609ea36b1ac68f542c05b73586f314718c33180ffc75d4ca9bf7bb564d95 + languageName: node + linkType: hard + "@aws-sdk/region-config-resolver@npm:^3.972.9": version: 3.972.9 resolution: "@aws-sdk/region-config-resolver@npm:3.972.9" @@ -11027,6 +11637,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/token-providers@npm:3.1021.0": + version: 3.1021.0 + resolution: "@aws-sdk/token-providers@npm:3.1021.0" + dependencies: + "@aws-sdk/core": ^3.973.26 + "@aws-sdk/nested-clients": ^3.996.18 + "@aws-sdk/types": ^3.973.6 + "@smithy/property-provider": ^4.2.12 + "@smithy/shared-ini-file-loader": ^4.4.7 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: 8a645d9d9a2928768f3f10473912cc2e7e1f46ab71ce15de780e1ef4972b04cf354e0ebfb5e5c61a6f2c0227ea1f839872de415490bac76cd08e233e1e942669 + languageName: node + linkType: hard + "@aws-sdk/token-providers@npm:3.614.0": version: 3.614.0 resolution: "@aws-sdk/token-providers@npm:3.614.0" @@ -11840,6 +12465,25 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-user-agent-node@npm:^3.973.14": + version: 3.973.14 + resolution: "@aws-sdk/util-user-agent-node@npm:3.973.14" + dependencies: + "@aws-sdk/middleware-user-agent": ^3.972.28 + "@aws-sdk/types": ^3.973.6 + "@smithy/node-config-provider": ^4.3.12 + "@smithy/types": ^4.13.1 + "@smithy/util-config-provider": ^4.2.2 + tslib: ^2.6.2 + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 0e91cf15f001a479af169d5a63ff1fc74ac0152116ffbcff6f28cfc2797e0aa75a98aea004556ebef7238c53f6dc5f528ee4d1c7279b90c6fc68e04bdde658ec + languageName: node + linkType: hard + "@aws-sdk/util-utf8-browser@npm:3.186.0": version: 3.186.0 resolution: "@aws-sdk/util-utf8-browser@npm:3.186.0" @@ -11952,6 +12596,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/xml-builder@npm:^3.972.16": + version: 3.972.16 + resolution: "@aws-sdk/xml-builder@npm:3.972.16" + dependencies: + "@smithy/types": ^4.13.1 + fast-xml-parser: 5.5.8 + tslib: ^2.6.2 + checksum: 4a0c5dd7dca0eae3d33d18b1dd94921a2ae06937f92503918ed3c22182a728b7317836ca3a91ca2d26b371eb33b37f23e4b566a1db46f204f6fd31fd323e7464 + languageName: node + linkType: hard + "@aws/lambda-invoke-store@npm:^0.0.1": version: 0.0.1 resolution: "@aws/lambda-invoke-store@npm:0.0.1" @@ -19196,6 +19851,24 @@ __metadata: languageName: node linkType: hard +"@smithy/core@npm:^3.23.13": + version: 3.23.13 + resolution: "@smithy/core@npm:3.23.13" + dependencies: + "@smithy/protocol-http": ^5.3.12 + "@smithy/types": ^4.13.1 + "@smithy/url-parser": ^4.2.12 + "@smithy/util-base64": ^4.3.2 + "@smithy/util-body-length-browser": ^4.2.2 + "@smithy/util-middleware": ^4.2.12 + "@smithy/util-stream": ^4.5.21 + "@smithy/util-utf8": ^4.2.2 + "@smithy/uuid": ^1.1.2 + tslib: ^2.6.2 + checksum: c264c0a097cfe237b7a089ac245bbb323ed06cf019dd94c0722422ea7a771fe3617c7b4c0b4b04bb88386e4d7df5e8c0720caef8422fe8b7ff64c8d132c2d38b + languageName: node + linkType: hard + "@smithy/credential-provider-imds@npm:^3.2.0, @smithy/credential-provider-imds@npm:^3.2.8": version: 3.2.8 resolution: "@smithy/credential-provider-imds@npm:3.2.8" @@ -19616,6 +20289,22 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-endpoint@npm:^4.4.28": + version: 4.4.28 + resolution: "@smithy/middleware-endpoint@npm:4.4.28" + dependencies: + "@smithy/core": ^3.23.13 + "@smithy/middleware-serde": ^4.2.16 + "@smithy/node-config-provider": ^4.3.12 + "@smithy/shared-ini-file-loader": ^4.4.7 + "@smithy/types": ^4.13.1 + "@smithy/url-parser": ^4.2.12 + "@smithy/util-middleware": ^4.2.12 + tslib: ^2.6.2 + checksum: 4862f02ef3d6a0c5738957cadda976ac503849fa339aaee703e9e2023822a084a4440254570e037f61f5ccd4c892cdcbe54f0ce4a99879f3aab7cfb0b9bdf11d + languageName: node + linkType: hard + "@smithy/middleware-retry@npm:^3.0.14, @smithy/middleware-retry@npm:^3.0.15, @smithy/middleware-retry@npm:^3.0.31": version: 3.0.34 resolution: "@smithy/middleware-retry@npm:3.0.34" @@ -19667,6 +20356,23 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-retry@npm:^4.4.46": + version: 4.4.46 + resolution: "@smithy/middleware-retry@npm:4.4.46" + dependencies: + "@smithy/node-config-provider": ^4.3.12 + "@smithy/protocol-http": ^5.3.12 + "@smithy/service-error-classification": ^4.2.12 + "@smithy/smithy-client": ^4.12.8 + "@smithy/types": ^4.13.1 + "@smithy/util-middleware": ^4.2.12 + "@smithy/util-retry": ^4.2.13 + "@smithy/uuid": ^1.1.2 + tslib: ^2.6.2 + checksum: ed2e0d2b996cc26bd874fbe43a44cb699a762f9e0ab064599e6c0e9d99952b2102bb573beb5cb8439e15df23178db3344460f5d1859e10b803c33c05174f10e8 + languageName: node + linkType: hard + "@smithy/middleware-serde@npm:^3.0.11, @smithy/middleware-serde@npm:^3.0.3": version: 3.0.11 resolution: "@smithy/middleware-serde@npm:3.0.11" @@ -19700,6 +20406,18 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-serde@npm:^4.2.16": + version: 4.2.16 + resolution: "@smithy/middleware-serde@npm:4.2.16" + dependencies: + "@smithy/core": ^3.23.13 + "@smithy/protocol-http": ^5.3.12 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: 8b375acd4d54178c8195180c52fb757c85339feadc9de3b2bbf1e0bb12ca193f614f4d69b20f529496fa4c469e1b7e19762e0d98c345aa50530d6e6af2a04ec9 + languageName: node + linkType: hard + "@smithy/middleware-stack@npm:^3.0.11, @smithy/middleware-stack@npm:^3.0.3": version: 3.0.11 resolution: "@smithy/middleware-stack@npm:3.0.11" @@ -19805,6 +20523,18 @@ __metadata: languageName: node linkType: hard +"@smithy/node-http-handler@npm:^4.5.1": + version: 4.5.1 + resolution: "@smithy/node-http-handler@npm:4.5.1" + dependencies: + "@smithy/protocol-http": ^5.3.12 + "@smithy/querystring-builder": ^4.2.12 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: e0564c285be81c9dbbc53bf6c6c36568b0b0760c63bc6d28eeae1f5cde43925b52e739a45d9cfeae7c6014a8b31eb52ba931aea96a5b428735a5ea0641054b69 + languageName: node + linkType: hard + "@smithy/property-provider@npm:^3.1.11, @smithy/property-provider@npm:^3.1.3": version: 3.1.11 resolution: "@smithy/property-provider@npm:3.1.11" @@ -20078,6 +20808,21 @@ __metadata: languageName: node linkType: hard +"@smithy/smithy-client@npm:^4.12.8": + version: 4.12.8 + resolution: "@smithy/smithy-client@npm:4.12.8" + dependencies: + "@smithy/core": ^3.23.13 + "@smithy/middleware-endpoint": ^4.4.28 + "@smithy/middleware-stack": ^4.2.12 + "@smithy/protocol-http": ^5.3.12 + "@smithy/types": ^4.13.1 + "@smithy/util-stream": ^4.5.21 + tslib: ^2.6.2 + checksum: 5bb76a4465490c08e745d952b7c43bbddc57e3dc848a0ffd494d466917215f8878ae27139a1dca212c7edb05622fb289628fb116a196c8319bd490edb95be929 + languageName: node + linkType: hard + "@smithy/types@npm:^1.1.0": version: 1.2.0 resolution: "@smithy/types@npm:1.2.0" @@ -20299,6 +21044,18 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-browser@npm:^4.3.44": + version: 4.3.44 + resolution: "@smithy/util-defaults-mode-browser@npm:4.3.44" + dependencies: + "@smithy/property-provider": ^4.2.12 + "@smithy/smithy-client": ^4.12.8 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: 6465df7408f978c3c73a530c5ca840d663e134be76396a81af6fcbeba8d7eb66cbb0001d349dae514dbf0f88d9a56c2cc900e78d1f0fc5b33ec7aba255632474 + languageName: node + linkType: hard + "@smithy/util-defaults-mode-node@npm:^3.0.14, @smithy/util-defaults-mode-node@npm:^3.0.15, @smithy/util-defaults-mode-node@npm:^3.0.31": version: 3.0.34 resolution: "@smithy/util-defaults-mode-node@npm:3.0.34" @@ -20344,6 +21101,21 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-node@npm:^4.2.48": + version: 4.2.48 + resolution: "@smithy/util-defaults-mode-node@npm:4.2.48" + dependencies: + "@smithy/config-resolver": ^4.4.13 + "@smithy/credential-provider-imds": ^4.2.12 + "@smithy/node-config-provider": ^4.3.12 + "@smithy/property-provider": ^4.2.12 + "@smithy/smithy-client": ^4.12.8 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: e54f8d502eaa7fff9a5e6f53ca27573bfeaac0c659dc12f52ca8a53244ae2cfbc6865422773c1d09934d1dbc4050bc876d0ae0709d8b5db9170685611dca2474 + languageName: node + linkType: hard + "@smithy/util-endpoints@npm:^2.0.5, @smithy/util-endpoints@npm:^2.1.7": version: 2.1.7 resolution: "@smithy/util-endpoints@npm:2.1.7" @@ -20467,6 +21239,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-retry@npm:^4.2.13": + version: 4.2.13 + resolution: "@smithy/util-retry@npm:4.2.13" + dependencies: + "@smithy/service-error-classification": ^4.2.12 + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: 14c1a24d8db429ceb7364d2b13bb3294d891d90e34fb5a9fd4e59b0c2862c56854dd242303a3210cdfedbd086660ab3d2b0cf992ee012a30f1539aba7b35dae3 + languageName: node + linkType: hard + "@smithy/util-stream@npm:^3.1.3, @smithy/util-stream@npm:^3.3.2, @smithy/util-stream@npm:^3.3.4": version: 3.3.4 resolution: "@smithy/util-stream@npm:3.3.4" @@ -20515,6 +21298,22 @@ __metadata: languageName: node linkType: hard +"@smithy/util-stream@npm:^4.5.21": + version: 4.5.21 + resolution: "@smithy/util-stream@npm:4.5.21" + dependencies: + "@smithy/fetch-http-handler": ^5.3.15 + "@smithy/node-http-handler": ^4.5.1 + "@smithy/types": ^4.13.1 + "@smithy/util-base64": ^4.3.2 + "@smithy/util-buffer-from": ^4.2.2 + "@smithy/util-hex-encoding": ^4.2.2 + "@smithy/util-utf8": ^4.2.2 + tslib: ^2.6.2 + checksum: dc9b81e1f17c234f74a699e4d003cf99e07545c83f1d289cb8e64bcbfd180b64bce79b3c83a1c2eddc719b4e6224c4dc96d237641bab89a8b2ce1b26bacad6c7 + languageName: node + linkType: hard + "@smithy/util-uri-escape@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-uri-escape@npm:3.0.0" @@ -20595,6 +21394,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-waiter@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/util-waiter@npm:4.2.14" + dependencies: + "@smithy/types": ^4.13.1 + tslib: ^2.6.2 + checksum: eb81051930829d324ff57c48f3a789b2dff1875c42fbc41107979c3494b88c152bc9bf917e7db8630b65b37c42276b4a894805f4ee3bde3f03f8c4f3c675301a + languageName: node + linkType: hard + "@smithy/uuid@npm:^1.1.2": version: 1.1.2 resolution: "@smithy/uuid@npm:1.1.2" @@ -21589,7 +22398,7 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^29.0.0, @types/jest@npm:^29.5.1": +"@types/jest@npm:^29.0.0, @types/jest@npm:^29.5.0, @types/jest@npm:^29.5.1, @types/jest@npm:^29.5.14": version: 29.5.14 resolution: "@types/jest@npm:29.5.14" dependencies: @@ -21768,6 +22577,24 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^24.10.1": + version: 24.12.2 + resolution: "@types/node@npm:24.12.2" + dependencies: + undici-types: ~7.16.0 + checksum: 710050c42f89075c4479e4e1e4c2532486b0c41b1e2a8a13ad88641c88b88cdaea87414e19224f30028719737bd70e327edcaa184d50e86b9418941edd7eb02b + languageName: node + linkType: hard + +"@types/node@npm:^24.6.0": + version: 24.12.0 + resolution: "@types/node@npm:24.12.0" + dependencies: + undici-types: ~7.16.0 + checksum: 8b31c0af5b5474f13048a4e77c57f22cd4f8fe6e58c4b6fde9456b0c13f46a5bfaf5744ff88fd089581de9f0d6e99c584e022681de7acb26a58d258c654c4843 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -31538,6 +32365,24 @@ __metadata: languageName: node linkType: hard +"handlebars@npm:^4.7.9": + version: 4.7.9 + resolution: "handlebars@npm:4.7.9" + dependencies: + minimist: ^1.2.5 + neo-async: ^2.6.2 + source-map: ^0.6.1 + uglify-js: ^3.1.4 + wordwrap: ^1.0.0 + dependenciesMeta: + uglify-js: + optional: true + bin: + handlebars: bin/handlebars + checksum: 22f8105a7e68e81aff2662bb434edf05f757d21d850731d71cec886d69c10cd33d3c43e34b2892968ec62de8241611851d3d0674c8ef324ea3e01dc66262faa9 + languageName: node + linkType: hard + "har-schema@npm:^2.0.0": version: 2.0.0 resolution: "har-schema@npm:2.0.0" @@ -43132,6 +43977,46 @@ __metadata: languageName: node linkType: hard +"ts-jest@npm:^29.3.4": + version: 29.4.9 + resolution: "ts-jest@npm:29.4.9" + dependencies: + bs-logger: ^0.2.6 + fast-json-stable-stringify: ^2.1.0 + handlebars: ^4.7.9 + json5: ^2.2.3 + lodash.memoize: ^4.1.2 + make-error: ^1.3.6 + semver: ^7.7.4 + type-fest: ^4.41.0 + yargs-parser: ^21.1.1 + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/transform": ^29.0.0 || ^30.0.0 + "@jest/types": ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: ">=4.3 <7" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/transform": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + bin: + ts-jest: cli.js + checksum: 901eb382817d1f48fc56b6c9b82de989f176660295695ae1fcd55f06f71d2c107766e1413ab24a59fa964c2ef79a60dd23ac1f382b05ae04f2b454fb4eb5ad4f + languageName: node + linkType: hard + "ts-json-schema-generator@npm:~1.1.2": version: 1.1.2 resolution: "ts-json-schema-generator@npm:1.1.2" @@ -43286,7 +44171,7 @@ __metadata: languageName: node linkType: hard -"tsx@npm:^4.20.6, tsx@npm:^4.21.0": +"tsx@npm:^4.19.0, tsx@npm:^4.20.6, tsx@npm:^4.21.0": version: 4.21.0 resolution: "tsx@npm:4.21.0" dependencies: @@ -43729,6 +44614,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a + languageName: node + linkType: hard + "undici-types@npm:~7.18.0": version: 7.18.2 resolution: "undici-types@npm:7.18.2"