Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve testing of contracts #450

Merged
merged 2 commits into from
Feb 5, 2025
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
7 changes: 0 additions & 7 deletions .eslintrc.yml

This file was deleted.

1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npx --no lint-staged
5 changes: 5 additions & 0 deletions .lintstagedrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"*.ts": [
"balena-lint --fix"
],
}
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
8 changes: 8 additions & 0 deletions .prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const fs = require('fs');

module.exports = JSON.parse(
fs.readFileSync(
__dirname + '/node_modules/@balena/lint/config/.prettierrc',
'utf8',
),
);
6 changes: 0 additions & 6 deletions contracts/sw.os-image/balenaos/contract.json

This file was deleted.

3 changes: 1 addition & 2 deletions contracts/sw.os/balenaos/contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
{ "type": "arch.sw", "slug": "i386" },
{ "type": "arch.sw", "slug": "amd64" }
]
},
{ "type": "sw.os-image", "slug": "balena-image" }
}
]
}
4 changes: 0 additions & 4 deletions contracts/sw.os/resinos/contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@
{
"type": "arch.sw",
"slug": "armv7hf"
},
{
"type": "sw.os-image",
"slug": "resinos"
}
]
}
24 changes: 17 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,27 @@
},
"private": true,
"scripts": {
"test": "eslint scripts && node scripts/check-contracts.js"
"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 --timeout 30000"
},
"author": "Balena Inc. <[email protected]>",
"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"
"@balena/contrato": "^0.12.0",
"@balena/lint": "^9.1.3",
"@types/chai": "^4.3.20",
"@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",
"p-map": "^7.0.3",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
},
"versionist": {
"publishedAt": "2025-01-28T17:12:51.985Z"
Expand Down
34 changes: 0 additions & 34 deletions scripts/check-contracts.js

This file was deleted.

43 changes: 0 additions & 43 deletions scripts/utils.js

This file was deleted.

8 changes: 8 additions & 0 deletions tests/chai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import chai from 'chai';
import chaiAsPromised from 'chai-as-promised';

chai.use(chaiAsPromised);

export default chai;

export const { expect } = chai;
162 changes: 162 additions & 0 deletions tests/contracts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { expect } from './chai';
import * as path from 'path';
import { promises as fs } from 'fs';
import type { ContractObject } from '@balena/contrato';
import { Contract, Universe, query } from '@balena/contrato';

const CONTRACTS_PATH = path.join(__dirname, '..', 'contracts');

async function findFiles(
dir: string,
filter: (filePath: string) => boolean = () => true,
): Promise<string[]> {
const allFiles = await fs.readdir(dir, { recursive: true });
const filePaths: string[] = [];
for (const fileName of allFiles) {
const filePath = path.join(dir, fileName);
const stat = await fs.stat(filePath);
if (!stat.isDirectory() && filter(filePath)) {
filePaths.push(filePath);
}
}

return filePaths;
}

type ContractMeta = {
type: string;
source: ContractObject;
path: string;
};

async function readContracts(dir: string): Promise<ContractMeta[]> {
const allFiles = await findFiles(
dir,
(fileName) => path.extname(fileName) === '.json',
);

const { default: pMap } = await import('p-map');

return (
await pMap(
allFiles.values(),
async (file) => {
const contents = await fs.readFile(file, { encoding: 'utf8' });
const source = JSON.parse(contents);
return {
type: path.basename(path.dirname(path.dirname(file))),
source,
path: file,
};
},

{ concurrency: 10 },
)
).flat();
}

describe('Balena Base Contracts', function () {
let allContractsMeta: ContractMeta[];

before(async () => {
allContractsMeta = await readContracts(CONTRACTS_PATH);
});

it('contracts are stored in the right 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('all children in the contract universe are satisfied', function () {
const allContracts = allContractsMeta.flatMap(({ source }) =>
Contract.build(source),
);

const universe = new Universe();
universe.addChildren(allContracts);

// The contracts universe is internally consistent
// if all the children requirements are satisfied
expect(universe.getAllNotSatisfiedChildRequirements()).to.deep.equal([]);
});

it('os/arch/device contract combinations are satisfied', function () {
const allContracts = allContractsMeta.flatMap(({ source }) =>
Contract.build(source),
);

const universe = new Universe();
universe.addChildren(allContracts);

const contexts = query(
universe,
{
'sw.os': {
cardinality: 1,
filter: {
type: 'object',
properties: { slug: { not: { enum: ['balena-os', 'resinos'] } } },
},
},
'arch.sw': 1,
'sw.blob': '1+',
'sw.stack-variant': 1,
'hw.device-type': 1,
},
{ type: 'meta.context' },
);

for (const context of contexts) {
const unmet = context.getAllNotSatisfiedChildRequirements();
expect(
unmet,
'Unsatisfied requirements for context: ' +
JSON.stringify(context, null, 2),
).to.deep.equal([]);
}
});

// Skipped as it takes too much time to calculate all combinations
// TODO: unskip this once we can improve the efficiency
// of contrato
it.skip('os/arch/device/stack contract combinations are satisfied', function () {
const allContracts = allContractsMeta.flatMap(({ source }) =>
Contract.build(source),
);

const universe = new Universe();
universe.addChildren(allContracts);

const contexts = query(
universe,
{
'sw.os': {
cardinality: 1,
filter: {
type: 'object',
properties: { slug: { not: { enum: ['balena-os', 'resinos'] } } },
},
},
'arch.sw': 1,
'sw.blob': '1+',
'sw.stack': 1,
'sw.stack-variant': 1,
'hw.device-type': 1,
},
{ type: 'meta.context' },
);

for (const context of contexts) {
const unmet = context.getAllNotSatisfiedChildRequirements();
expect(
unmet,
'Unsatisfied requirements for context: ' +
JSON.stringify(context, null, 2),
).to.deep.equal([]);
}
});
});
18 changes: 18 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "Node16",
"moduleResolution": "node16",
"outDir": "build",
"noUnusedParameters": true,
"noUnusedLocals": true,
"sourceMap": true,
"strict": true,
"target": "es2022",
"declaration": true,
"skipLibCheck": true
},
"include": [
"lib/**/*.ts",
"tests/**/*.ts"
]
}