From ce4e4d7a7fdd05f9c5d9545ec97ad10b6bb09c6f Mon Sep 17 00:00:00 2001 From: Felipe Lalanne Date: Fri, 13 Dec 2024 15:43:29 -0300 Subject: [PATCH] Improve testing of contracts - Migrates contracts to typescript - Parallelizes scanning of folders and loading contracts - Adds validation for internal consistency of the contract universe Change-type: patch --- .eslintrc.yml | 7 --- .husky/pre-commit | 4 ++ .lintstagedrc | 5 ++ .npmrc | 1 + .prettierrc.js | 8 +++ package.json | 59 ++++++++++++-------- scripts/check-contracts.js | 34 ------------ scripts/utils.js | 43 --------------- tests/chai.ts | 8 +++ tests/validate.spec.ts | 110 +++++++++++++++++++++++++++++++++++++ tsconfig.json | 19 +++++++ 11 files changed, 190 insertions(+), 108 deletions(-) delete mode 100644 .eslintrc.yml create mode 100644 .husky/pre-commit create mode 100644 .lintstagedrc create mode 100644 .npmrc create mode 100644 .prettierrc.js delete mode 100644 scripts/check-contracts.js delete mode 100644 scripts/utils.js create mode 100644 tests/chai.ts create mode 100644 tests/validate.spec.ts create mode 100644 tsconfig.json diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index 85161694..00000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,7 +0,0 @@ -env: - commonjs: true - es6: true - node: true -extends: 'standard' -parserOptions: - sourceType: 'script' diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..41ae0d5c --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no lint-staged diff --git a/.lintstagedrc b/.lintstagedrc new file mode 100644 index 00000000..56513fbc --- /dev/null +++ b/.lintstagedrc @@ -0,0 +1,5 @@ +{ + "*.ts": [ + "balena-lint --fix" + ], +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..43c97e71 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..29e5d9bb --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,8 @@ +const fs = require('fs'); + +module.exports = JSON.parse( + fs.readFileSync( + __dirname + '/node_modules/@balena/lint/config/.prettierrc', + 'utf8', + ), +); diff --git a/package.json b/package.json index 595c8578..880bc8ab 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,37 @@ { - "name": "contracts", - "version": "2.0.118", - "description": "Balena Base Contracts", - "repository": { - "type": "git", - "url": "git@github.com:balena-io/contracts.git" - }, - "private": true, - "scripts": { - "test": "eslint scripts && node scripts/check-contracts.js" - }, - "author": "Balena Inc. ", - "license": "Apache-2.0", - "devDependencies": { - "eslint": "^4.8.0", - "eslint-config-standard": "^10.2.1", - "eslint-plugin-import": "^2.7.0", - "eslint-plugin-node": "^5.2.0", - "eslint-plugin-promise": "^3.5.0", - "eslint-plugin-standard": "^3.0.1" - }, - "versionist": { - "publishedAt": "2024-12-04T16:14:01.315Z" - } + "name": "contracts", + "version": "2.0.118", + "description": "Balena Base Contracts", + "repository": { + "type": "git", + "url": "git@github.com:balena-io/contracts.git" + }, + "private": true, + "scripts": { + "test": "npm run lint && npm run test:node", + "lint": "balena-lint tests", + "lint-fix": "balena-lint --fix tests", + "test:node": "mocha -r ts-node/register --reporter spec tests/**/*.spec.ts" + }, + "author": "Balena Inc. ", + "license": "Apache-2.0", + "devDependencies": { + "@balena/contrato": "^0.9.4", + "@balena/lint": "^9.1.3", + "@types/chai-as-promised": "^7.1.4", + "@types/mocha": "^10.0.10", + "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", + "husky": "^9.1.7", + "lint-staged": "^15.2.11", + "mocha": "^11.0.1", + "ts-node": "^10.9.2", + "typescript": "^5.7.2" + }, + "versionist": { + "publishedAt": "2024-12-04T16:14:01.315Z" + }, + "dependencies": { + "@types/chai": "^4.2.18" + } } diff --git a/scripts/check-contracts.js b/scripts/check-contracts.js deleted file mode 100644 index 6e648820..00000000 --- a/scripts/check-contracts.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2019 Balena - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict' - -const path = require('path') -const utils = require('./utils') -const CONTRACTS_PATH = path.join(__dirname, '..', 'contracts') -let success = true - -for (const contract of utils.readContracts(CONTRACTS_PATH)) { - if (contract.source.type !== contract.type) { - success = false - console.error(contract.path) - console.error(` The contract type is ${contract.source.type}, but it lives inside ${contract.type}`) - } -} - -if (!success) { - process.exit(1) -} diff --git a/scripts/utils.js b/scripts/utils.js deleted file mode 100644 index 6b02c04c..00000000 --- a/scripts/utils.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2019 Balena - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict' - -const fs = require('fs') -const path = require('path') - -const getAllFiles = dir => - fs.readdirSync(dir).reduce((files, file) => { - const name = path.join(dir, file) - const isDirectory = fs.statSync(name).isDirectory() - return isDirectory ? [...files, ...getAllFiles(name)] : [...files, name] - }, []) - -exports.readContracts = (dir) => { - const allFiles = getAllFiles(dir) - let contracts = [] - - allFiles.forEach((file) => { - if (path.extname(file) === '.json') { - contracts.push({ - type: path.basename(path.dirname(path.dirname(file))), - source: require(file), - path: file - }) - } - }) - return contracts -} diff --git a/tests/chai.ts b/tests/chai.ts new file mode 100644 index 00000000..8ca61947 --- /dev/null +++ b/tests/chai.ts @@ -0,0 +1,8 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +chai.use(chaiAsPromised); + +export default chai; + +export const { expect } = chai; diff --git a/tests/validate.spec.ts b/tests/validate.spec.ts new file mode 100644 index 00000000..0c8fe5e7 --- /dev/null +++ b/tests/validate.spec.ts @@ -0,0 +1,110 @@ +import { expect } from './chai'; +import * as path from 'path'; +import { promises as fs } from 'fs'; +import type { ContractObject } from '@balena/contrato'; +import { Contract } from '@balena/contrato'; + +const CONTRACTS_PATH = path.join(__dirname, '..', 'contracts'); + +async function findFiles( + dir: string, + filter: (fileName: string, filePath: string) => boolean = () => true, +): Promise { + const dirFiles = await fs.readdir(dir); + + const filePaths: string[] = []; + const dirPaths: string[] = []; + for (const fileName of dirFiles) { + const filePath = path.join(dir, fileName); + + const stat = await fs.stat(filePath); + if (stat.isDirectory()) { + dirPaths.push(filePath); + } else if (filter(fileName, filePath)) { + filePaths.push(filePath); + } + } + + const allFiles = await Promise.all(dirPaths.map((d) => findFiles(d, filter))); + return filePaths.concat(...allFiles); +} + +async function concurrentForEach( + it: IterableIterator, + fn: (t: T) => Promise, + concurrency = 1, +) { + const run = async () => { + const next = it.next(); + if (next.value && !next.done) { + await fn(next.value); + await run(); + } + }; + const runs = []; + for (let i = 0; i < concurrency; i++) { + runs.push(run()); + } + await Promise.all(runs); +} + +type ContractMeta = { + type: string; + source: ContractObject; + path: string; +}; + +async function readContracts(dir: string): Promise { + const allFiles = await findFiles( + dir, + (fileName) => path.extname(fileName) === '.json', + ); + + const meta: ContractMeta[] = []; + await concurrentForEach( + allFiles.values(), + async (file) => { + const contents = await fs.readFile(file, { encoding: 'utf8' }); + const source = JSON.parse(contents); + meta.push({ + type: path.basename(path.dirname(path.dirname(file))), + source, + path: file, + }); + }, + 10, + ); + + return meta; +} + +describe('contract validation', function () { + let allContractsMeta: ContractMeta[]; + + before(async () => { + allContractsMeta = await readContracts(CONTRACTS_PATH); + }); + + it('contracts are stored in the correct folder', function () { + for (const contractMeta of allContractsMeta) { + expect( + contractMeta.source.type, + `the contract type '${contractMeta.source.type}' does not match its parent folder '${contractMeta.type}'`, + ).to.equal(contractMeta.type); + } + }); + + it('contracts universe is internally consistent', function () { + const allContracts = allContractsMeta + .map(({ source }) => Contract.build(source)) + .flat(); + + const universe = new Contract({ type: 'meta.universe' }); + universe.addChildren(allContracts); + + // The contracts universe is internally consistent + // if all the children requirements are satisfied + expect(universe.getAllNotSatisfiedChildRequirements()).to.equal([]); + expect(universe.areChildrenSatisfied()).to.be.true; + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..bcc0e768 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "Node16", + "moduleResolution": "node16", + "outDir": "build", + "noUnusedParameters": true, + "noUnusedLocals": true, + "removeComments": true, + "sourceMap": true, + "strict": true, + "target": "es2022", + "declaration": true, + "skipLibCheck": true + }, + "include": [ + "lib/**/*.ts", + "tests/**/*.ts" + ] +}