Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ coverage
package-lock.json
sam.json
sam.yaml
.kiro
7 changes: 7 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

---

### [6.0.0 - 6.0.1] 2025-11-28

- Updated to node >= 22
- Moved to native node test runner

---

## [5.0.2] 2025-11-10

### Changed
Expand Down
20 changes: 8 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
{
"name": "@architect/destroy",
"version": "6.0.0",
"version": "6.0.1",
"description": "Destroy projects created with Architect",
"main": "src/index.js",
"bin": {
"arc-destroy": "src/cli.js"
},
"scripts": {
"lint": "eslint . --fix",
"test": "npm run lint && npm run coverage",
"test:nolint": "npm run coverage",
"test:unit": "cross-env tape 'test/unit/**/*-test.js'",
"coverage": "cross-env nyc --reporter=lcov --reporter=text npm run test:unit",
"test:integration": "cross-env tape test/integration/**/*-test.js",
"test": "npm run lint && npm run test:unit",
"test:nolint": "npm run test:unit",
"test:unit": "node --test --test-reporter=spec test/unit/**/*-test.js",
"test:integration": "node --test --test-reporter=spec test/integration/**/*-test.js",
"coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info test/unit/**/*-test.js",
"rc": "npm version prerelease --preid RC"
},
"license": "Apache-2.0",
Expand All @@ -25,7 +25,7 @@
},
"dependencies": {
"@architect/inventory": "~6.0.0",
"@architect/utils": "~6.0.0",
"@architect/utils": "~6.0.1",
"@aws-lite/client": "^0.23.2",
"@aws-lite/cloudformation": "^0.1.3",
"@aws-lite/cloudwatch-logs": "^0.1.0",
Expand All @@ -35,10 +35,6 @@
"devDependencies": {
"@architect/deploy": "~7.0.0",
"@architect/eslint-config": "~3.0.0",
"cross-env": "~10.1.0",
"eslint": "~9.39.1",
"nyc": "~17.1.0",
"proxyquire": "~2.1.3",
"tape": "~5.9.0"
"eslint": "~9.39.1"
}
}
29 changes: 29 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@

Architect Destroy destroys Architect-generated projects. More specifically, it destroys your projects' CloudFormation Stacks, CloudWatch Log Groups, S3 bucket used during deployment, SSM Parameters added by [`arc env`](https://github.com/architect/env), and if called with `--force` (or the `force` param via API), destroys your DynamoDB (`@tables`) databases and S3 bucket housing your static assets (`@static`).

## Testing

This project uses Node.js's native test runner (`node:test`) for all testing. The test suite includes unit tests and integration tests.

### Running Tests

```bash
# Run linter and unit tests
npm test

# Run only unit tests
npm run test:unit

# Run only integration tests
npm run test:integration

# Run tests with coverage report
npm run coverage
```

### Test Structure

Tests are organized in the `test/` directory:
- `test/unit/` - Unit tests for individual modules
- `test/integration/` - Integration tests for end-to-end scenarios
- `test/helpers/` - Shared test utilities and mock helpers

For detailed information about writing tests, mock patterns, and testing best practices, see [TESTING.md](./TESTING.md).

## API

### `destroy({ appname, stackname, env, force, now, retries, credentials, quiet, region }, callback)`
Expand Down
136 changes: 136 additions & 0 deletions test/helpers/mocks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
let { mock } = require('node:test')

/**
* Create a mock AWS client compatible with @aws-lite/client API structure
* @param {object} mocks - Object containing mock implementations for AWS services
* @returns {object} Mock AWS client with CloudFormation, S3, SSM, and CloudWatch Logs services
*/
function createMockAwsClient (mocks = {}) {
let client = {
CloudFormation: {
DescribeStacks: mock.fn(mocks.CloudFormation?.DescribeStacks || (() => Promise.resolve({ Stacks: [] }))),
DeleteStack: mock.fn(mocks.CloudFormation?.DeleteStack || (() => Promise.resolve({}))),
DescribeStackResources: mock.fn(mocks.CloudFormation?.DescribeStackResources || (() => Promise.resolve({ StackResources: [] }))),
},
s3: {
HeadBucket: mock.fn(mocks.s3?.HeadBucket || (() => Promise.resolve({}))),
ListObjectsV2: mock.fn(mocks.s3?.ListObjectsV2 || (() => Promise.resolve({ Contents: [] }))),
DeleteObjects: mock.fn(mocks.s3?.DeleteObjects || (() => Promise.resolve({}))),
DeleteBucket: mock.fn(mocks.s3?.DeleteBucket || (() => Promise.resolve({}))),
},
ssm: {
GetParameter: mock.fn(mocks.ssm?.GetParameter || (() => Promise.resolve({}))),
GetParametersByPath: mock.fn(mocks.ssm?.GetParametersByPath || (() => Promise.resolve({ Parameters: [] }))),
DeleteParameters: mock.fn(mocks.ssm?.DeleteParameters || (() => Promise.resolve({}))),
},
cloudwatchlogs: {
DescribeLogGroups: mock.fn(mocks.cloudwatchlogs?.DescribeLogGroups || (() => Promise.resolve({ logGroups: [] }))),
DeleteLogGroup: mock.fn(mocks.cloudwatchlogs?.DeleteLogGroup || (() => Promise.resolve({}))),
},
}

// Support both capitalized and lowercase for compatibility with source code
client.cloudformation = client.CloudFormation

return client
}

/**
* Helper function to create a mock for static bucket scenario
* @param {string|boolean} url - Bucket URL or false if no static bucket exists
* @returns {function} Function that returns mock CloudFormation.DescribeStacks implementation
*/
function staticBucket (url) {
let describeCounter = 0
return (params) => {
describeCounter++
let stackName = params?.StackName || 'UnknownStack'
if (describeCounter > 1) {
// subsequent calls check for stack deletion status
return Promise.reject({ code: 'ValidationError', message: `Stack with id ${stackName} does not exist` })
}
else {
// first call checks for static bucket
return Promise.resolve({
Stacks: [ { Outputs: url ? [ { OutputKey: 'BucketURL', OutputValue: url } ] : [] } ],
})
}
}
}

/**
* Helper function to create a mock for DynamoDB tables scenario
* @param {Array<string>} tables - Array of table names
* @returns {function} Function that returns mock CloudFormation.DescribeStackResources implementation
*/
function dbTables (tables) {
return () => {
return Promise.resolve({
StackResources: tables.map(t => ({ ResourceType: 'AWS::DynamoDB::Table', Name: t })),
})
}
}

/**
* Helper function to create a mock for deployment bucket scenario
* @param {string|boolean} name - Deployment bucket name or false if it doesn't exist
* @returns {function} Function that returns mock SSM.GetParameter implementation
*/
function deployBucket (name) {
return () => {
if (name) {
return Promise.resolve({ Parameter: { Value: name } })
}
else {
// Return resolved promise with null value to match _ssm.js behavior
// When ParameterNotFound, _ssm.js calls callback() which causes waterfall issues
return Promise.resolve({ Parameter: { Value: null } })
}
}
}

/**
* Helper function to create a mock for SSM parameters scenario
* @param {Array<string>} params - Array of parameter names
* @returns {function} Function that returns mock SSM.GetParametersByPath implementation
*/
function ssmParams (params) {
return () => {
if (params.length) {
return Promise.resolve({ Parameters: params.map(p => ({ Name: p })) })
}
else {
// Return empty array instead of rejecting to match expected behavior
return Promise.resolve({ Parameters: [] })
}
}
}

/**
* Helper function to create a mock for CloudWatch log groups scenario
* @param {Array<string>} groups - Array of log group names
* @returns {function} Function that returns mock CloudWatchLogs.DescribeLogGroups implementation
*/
function cloudwatchLogs (groups) {
return () => {
return Promise.resolve({ logGroups: groups.map(g => ({ logGroupName: g })) })
}
}

/**
* Helper function to create a mock for CloudFormation DeleteStack
* @returns {function} Function that returns mock CloudFormation.DeleteStack implementation
*/
function deleteStack () {
return () => Promise.resolve({})
}

module.exports = {
createMockAwsClient,
staticBucket,
dbTables,
deployBucket,
ssmParams,
cloudwatchLogs,
deleteStack,
}
138 changes: 138 additions & 0 deletions test/helpers/mocks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
let { describe, it } = require('node:test')
let assert = require('node:assert/strict')
let {
createMockAwsClient,
staticBucket,
dbTables,
deployBucket,
ssmParams,
cloudwatchLogs,
deleteStack,
} = require('./mocks')

describe('Mock Helpers', () => {
describe('createMockAwsClient', () => {
it('should create a mock AWS client with default implementations', () => {
let aws = createMockAwsClient()

assert.ok(aws.CloudFormation, 'CloudFormation service exists')
assert.ok(aws.cloudformation, 'cloudformation service exists (lowercase)')
assert.ok(aws.s3, 's3 service exists')
assert.ok(aws.ssm, 'ssm service exists')
assert.ok(aws.cloudwatchlogs, 'cloudwatchlogs service exists')

assert.ok(typeof aws.CloudFormation.DescribeStacks === 'function', 'DescribeStacks is a function')
assert.ok(typeof aws.cloudformation.DescribeStacks === 'function', 'DescribeStacks is a function (lowercase)')
assert.ok(typeof aws.s3.ListObjectsV2 === 'function', 'ListObjectsV2 is a function')
assert.ok(typeof aws.ssm.GetParameter === 'function', 'GetParameter is a function')
assert.ok(typeof aws.cloudwatchlogs.DescribeLogGroups === 'function', 'DescribeLogGroups is a function')
})

it('should allow custom mock implementations', async () => {
let customMock = () => Promise.resolve({ custom: 'data' })
let aws = createMockAwsClient({
s3: {
ListObjectsV2: customMock,
},
})

let result = await aws.s3.ListObjectsV2()
assert.deepStrictEqual(result, { custom: 'data' })
})
})

describe('staticBucket', () => {
it('should return stack with bucket URL on first call', async () => {
let mockFn = staticBucket('http://my-bucket.s3.amazonaws.com')
let result = await mockFn()

assert.ok(result.Stacks, 'Stacks array exists')
assert.strictEqual(result.Stacks[0].Outputs[0].OutputKey, 'BucketURL')
assert.strictEqual(result.Stacks[0].Outputs[0].OutputValue, 'http://my-bucket.s3.amazonaws.com')
})

it('should reject with ValidationError on subsequent calls', async () => {
let mockFn = staticBucket('http://my-bucket.s3.amazonaws.com')
await mockFn() // first call succeeds

await assert.rejects(
async () => await mockFn(),
{ code: 'ValidationError' },
)
})

it('should return empty Outputs when url is false', async () => {
let mockFn = staticBucket(false)
let result = await mockFn()

assert.strictEqual(result.Stacks[0].Outputs.length, 0)
})
})

describe('dbTables', () => {
it('should return stack resources for tables', async () => {
let mockFn = dbTables([ 'table1', 'table2' ])
let result = await mockFn()

assert.strictEqual(result.StackResources.length, 2)
assert.strictEqual(result.StackResources[0].ResourceType, 'AWS::DynamoDB::Table')
assert.strictEqual(result.StackResources[0].Name, 'table1')
})
})

describe('deployBucket', () => {
it('should return parameter value when bucket exists', async () => {
let mockFn = deployBucket('my-deploy-bucket')
let result = await mockFn()

assert.strictEqual(result.Parameter.Value, 'my-deploy-bucket')
})

it('should reject with ParameterNotFound when bucket does not exist', async () => {
let mockFn = deployBucket(false)

await assert.rejects(
async () => await mockFn(),
{ code: 'ParameterNotFound' },
)
})
})

describe('ssmParams', () => {
it('should return parameters when they exist', async () => {
let mockFn = ssmParams([ 'param1', 'param2' ])
let result = await mockFn()

assert.strictEqual(result.Parameters.length, 2)
assert.strictEqual(result.Parameters[0].Name, 'param1')
})

it('should reject with ParameterNotFound when no params exist', async () => {
let mockFn = ssmParams([])

await assert.rejects(
async () => await mockFn(),
{ code: 'ParameterNotFound' },
)
})
})

describe('cloudwatchLogs', () => {
it('should return log groups', async () => {
let mockFn = cloudwatchLogs([ 'log-group-1', 'log-group-2' ])
let result = await mockFn()

assert.strictEqual(result.logGroups.length, 2)
assert.strictEqual(result.logGroups[0].logGroupName, 'log-group-1')
})
})

describe('deleteStack', () => {
it('should resolve successfully', async () => {
let mockFn = deleteStack()
let result = await mockFn()

assert.deepStrictEqual(result, {})
})
})
})
Loading