Compare commits
2 Commits
5384958aca
...
b094123518
| Author | SHA1 | Date | |
|---|---|---|---|
| b094123518 | |||
| 3f08b6428f |
278
Jenkinsfile
vendored
278
Jenkinsfile
vendored
@ -106,43 +106,116 @@ spec:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Quality Gate') {
|
stage('Run quality gate') {
|
||||||
steps {
|
steps {
|
||||||
container('node') {
|
container('node') {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
|
set +e
|
||||||
|
bash <<'GATE'
|
||||||
|
set -u
|
||||||
npm ci
|
npm ci
|
||||||
npm run lint
|
lint_rc=0
|
||||||
npm run test:ci
|
test_rc=0
|
||||||
npm run build
|
build_rc=0
|
||||||
|
npm run lint || lint_rc=$?
|
||||||
|
npm run test:ci || test_rc=$?
|
||||||
|
npm run build || build_rc=$?
|
||||||
|
|
||||||
mkdir -p build
|
mkdir -p build
|
||||||
APP_VERSION="$(node -p \"require('./package.json').version\")"
|
APP_VERSION="$(node -p "require('./package.json').version")"
|
||||||
echo "APP_VERSION=${APP_VERSION}" > build/version.env
|
echo "APP_VERSION=${APP_VERSION}" > build/version.env
|
||||||
|
export lint_rc test_rc build_rc
|
||||||
|
|
||||||
node <<'NODE'
|
node <<'NODE'
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
const junitPath = 'build/junit-typhon.xml';
|
const junitPath = 'build/junit-typhon.xml';
|
||||||
const coveragePath = 'coverage/coverage-summary.json';
|
const coveragePath = 'coverage/coverage-summary.json';
|
||||||
|
|
||||||
if (!fs.existsSync(junitPath)) {
|
const sourceRoots = ['src', 'tests', 'scripts'];
|
||||||
console.error('missing junit report:', junitPath);
|
const sourceExts = new Set(['.ts', '.js', '.cjs', '.mjs', '.sh']);
|
||||||
process.exit(1);
|
|
||||||
}
|
function unescapeXml(value) {
|
||||||
if (!fs.existsSync(coveragePath)) {
|
return String(value || '')
|
||||||
console.error('missing coverage summary:', coveragePath);
|
.replace(/"/g, '"')
|
||||||
process.exit(1);
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&');
|
||||||
}
|
}
|
||||||
|
|
||||||
const junit = fs.readFileSync(junitPath, 'utf8');
|
function attr(attrs, name) {
|
||||||
|
const match = attrs.match(new RegExp(`(?:^|\\s)${name}="([^"]*)"`));
|
||||||
|
return match ? unescapeXml(match[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSourceFiles(rootDir) {
|
||||||
|
const files = [];
|
||||||
|
const stack = [rootDir];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop();
|
||||||
|
if (!fs.existsSync(current)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const fullPath = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(fullPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sourceExts.has(path.extname(entry.name))) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryForClassname(classname) {
|
||||||
|
const normalized = String(classname || '').replace(/\\/g, '/');
|
||||||
|
const relative = normalized.includes('/tests/')
|
||||||
|
? normalized.slice(normalized.indexOf('/tests/') + '/tests/'.length)
|
||||||
|
: (normalized.startsWith('tests/') ? normalized.slice('tests/'.length) : normalized);
|
||||||
|
const parts = relative.split('/').filter(Boolean);
|
||||||
|
if (parts.length <= 1 || parts[0].endsWith('.test.ts')) {
|
||||||
|
return 'unit';
|
||||||
|
}
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTestCases(junit) {
|
||||||
|
const cases = [];
|
||||||
|
const re = /<testcase\b([^>]*)>([\s\S]*?)<\/testcase>|<testcase\b([^>]*)\/>/g;
|
||||||
|
let match;
|
||||||
|
while ((match = re.exec(junit)) !== null) {
|
||||||
|
const attrs = match[1] || match[3] || '';
|
||||||
|
const body = match[2] || '';
|
||||||
|
const classname = attr(attrs, 'classname') || 'typhon';
|
||||||
|
const title = attr(attrs, 'name') || classname;
|
||||||
|
const status = body.includes('<failure') || body.includes('<error')
|
||||||
|
? 'failed'
|
||||||
|
: body.includes('<skipped')
|
||||||
|
? 'skipped'
|
||||||
|
: 'passed';
|
||||||
|
cases.push({ category: categoryForClassname(classname), test: `${classname}::${title}`, status });
|
||||||
|
}
|
||||||
|
return cases;
|
||||||
|
}
|
||||||
|
|
||||||
|
const junit = fs.existsSync(junitPath) ? fs.readFileSync(junitPath, 'utf8') : '';
|
||||||
const tests = Number((junit.match(/tests="([0-9]+)"/) || [])[1] || 0);
|
const tests = Number((junit.match(/tests="([0-9]+)"/) || [])[1] || 0);
|
||||||
const failures = Number((junit.match(/failures="([0-9]+)"/) || [])[1] || 0);
|
const failures = Number((junit.match(/failures="([0-9]+)"/) || [])[1] || 0);
|
||||||
const errors = Number((junit.match(/errors="([0-9]+)"/) || [])[1] || 0);
|
const errors = Number((junit.match(/errors="([0-9]+)"/) || [])[1] || (junit ? 0 : 1));
|
||||||
const skipped = Number((junit.match(/skipped="([0-9]+)"/) || [])[1] || 0);
|
const skipped = Number((junit.match(/skipped="([0-9]+)"/) || [])[1] || 0);
|
||||||
|
|
||||||
const cov = JSON.parse(fs.readFileSync(coveragePath, 'utf8'));
|
const cov = fs.existsSync(coveragePath) ? JSON.parse(fs.readFileSync(coveragePath, 'utf8')) : { total: {} };
|
||||||
const total = cov.total || {};
|
const total = cov.total || {};
|
||||||
|
const sourceFiles = sourceRoots.flatMap((root) => collectSourceFiles(root));
|
||||||
|
const overLimitFiles = sourceFiles
|
||||||
|
.map((file) => ({ file, lines: fs.readFileSync(file, 'utf8').split(/\r?\n/).length }))
|
||||||
|
.filter((item) => item.lines > 500);
|
||||||
|
|
||||||
const report = {
|
const report = {
|
||||||
suite: 'typhon',
|
suite: 'typhon',
|
||||||
@ -154,21 +227,103 @@ const report = {
|
|||||||
branches: total.branches?.pct ?? 0
|
branches: total.branches?.pct ?? 0
|
||||||
},
|
},
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 85,
|
lines: 95,
|
||||||
statements: 85,
|
statements: 95,
|
||||||
functions: 85,
|
functions: 95,
|
||||||
branches: 75
|
branches: 75
|
||||||
}
|
},
|
||||||
|
hygiene: {
|
||||||
|
sourceFilesTotal: sourceFiles.length,
|
||||||
|
sourceLinesOver500: overLimitFiles.length
|
||||||
|
},
|
||||||
|
checks: {
|
||||||
|
tests: failures > 0 || errors > 0 || Number(process.env.test_rc || 0) !== 0 ? 'failed' : 'ok',
|
||||||
|
coverage: 'ok',
|
||||||
|
loc: overLimitFiles.length === 0 ? 'ok' : 'failed',
|
||||||
|
style: Number(process.env.lint_rc || 0) === 0 ? 'ok' : 'failed',
|
||||||
|
gate_glue: 'ok',
|
||||||
|
sonarqube: 'not_applicable',
|
||||||
|
supply_chain: 'not_applicable'
|
||||||
|
},
|
||||||
|
testCases: parseTestCases(junit)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
report.checks.coverage = (
|
||||||
|
report.coverage.lines >= report.thresholds.lines &&
|
||||||
|
report.coverage.statements >= report.thresholds.statements &&
|
||||||
|
report.coverage.functions >= report.thresholds.functions &&
|
||||||
|
report.coverage.branches >= report.thresholds.branches
|
||||||
|
) ? 'ok' : 'failed';
|
||||||
|
if (report.testCases.length === 0) {
|
||||||
|
report.testCases.push({ category: 'unit', test: '__no_test_cases__', status: junit ? 'skipped' : 'failed' });
|
||||||
|
}
|
||||||
|
|
||||||
fs.writeFileSync('build/quality-gate.json', JSON.stringify(report, null, 2));
|
fs.writeFileSync('build/quality-gate.json', JSON.stringify(report, null, 2));
|
||||||
|
if (
|
||||||
|
Number(process.env.lint_rc || 0) !== 0 ||
|
||||||
|
Number(process.env.test_rc || 0) !== 0 ||
|
||||||
|
Number(process.env.build_rc || 0) !== 0 ||
|
||||||
|
Object.values(report.checks).includes('failed')
|
||||||
|
) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
NODE
|
NODE
|
||||||
|
GATE
|
||||||
|
gate_rc=$?
|
||||||
|
set -e
|
||||||
|
printf '%s\n' "${gate_rc}" > build/quality-gate.rc
|
||||||
|
if [ ! -f build/quality-gate.json ]; then
|
||||||
|
mkdir -p build
|
||||||
|
cat > build/quality-gate.json <<'JSON'
|
||||||
|
{
|
||||||
|
"suite": "typhon",
|
||||||
|
"tests": {
|
||||||
|
"tests": 0,
|
||||||
|
"failures": 1,
|
||||||
|
"errors": 0,
|
||||||
|
"skipped": 0
|
||||||
|
},
|
||||||
|
"coverage": {
|
||||||
|
"lines": 0,
|
||||||
|
"statements": 0,
|
||||||
|
"functions": 0,
|
||||||
|
"branches": 0
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"lines": 95,
|
||||||
|
"statements": 95,
|
||||||
|
"functions": 95,
|
||||||
|
"branches": 75
|
||||||
|
},
|
||||||
|
"hygiene": {
|
||||||
|
"sourceFilesTotal": 0,
|
||||||
|
"sourceLinesOver500": 0
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"tests": "failed",
|
||||||
|
"coverage": "failed",
|
||||||
|
"loc": "ok",
|
||||||
|
"style": "failed",
|
||||||
|
"gate_glue": "failed",
|
||||||
|
"sonarqube": "not_applicable",
|
||||||
|
"supply_chain": "not_applicable"
|
||||||
|
},
|
||||||
|
"testCases": [
|
||||||
|
{
|
||||||
|
"category": "unit",
|
||||||
|
"test": "__no_test_cases__",
|
||||||
|
"status": "failed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
fi
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Publish Test Metrics') {
|
stage('Publish test metrics') {
|
||||||
steps {
|
steps {
|
||||||
container('node') {
|
container('node') {
|
||||||
sh '''
|
sh '''
|
||||||
@ -186,7 +341,20 @@ if (!fs.existsSync(qualityPath)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const quality = JSON.parse(fs.readFileSync(qualityPath, 'utf8'));
|
const quality = JSON.parse(fs.readFileSync(qualityPath, 'utf8'));
|
||||||
const status = quality.tests.failures > 0 || quality.tests.errors > 0 ? 'failed' : 'ok';
|
const status = quality.tests.failures > 0 || quality.tests.errors > 0 || Object.values(quality.checks || {}).includes('failed') ? 'failed' : 'ok';
|
||||||
|
const branch = process.env.BRANCH_NAME || process.env.GIT_BRANCH || 'unknown';
|
||||||
|
const buildNumber = process.env.BUILD_NUMBER || 'unknown';
|
||||||
|
const jenkinsJob = process.env.JOB_NAME || suite;
|
||||||
|
const sourceFilesTotal = Number(quality.hygiene?.sourceFilesTotal ?? 0);
|
||||||
|
const sourceLinesOver500 = Number(quality.hygiene?.sourceLinesOver500 ?? 0);
|
||||||
|
|
||||||
|
function esc(value) {
|
||||||
|
return String(value ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelString(labels) {
|
||||||
|
return `{${Object.entries(labels).map(([key, value]) => `${key}="${esc(value)}"`).join(',')}}`;
|
||||||
|
}
|
||||||
|
|
||||||
function fetchCounter(targetStatus) {
|
function fetchCounter(targetStatus) {
|
||||||
try {
|
try {
|
||||||
@ -205,34 +373,86 @@ let failed = fetchCounter('failed');
|
|||||||
if (status === 'ok') ok += 1;
|
if (status === 'ok') ok += 1;
|
||||||
if (status === 'failed') failed += 1;
|
if (status === 'failed') failed += 1;
|
||||||
|
|
||||||
const payload = [
|
const passed = Math.max(quality.tests.tests - quality.tests.failures - quality.tests.errors - quality.tests.skipped, 0);
|
||||||
|
const buildLabels = labelString({ suite, branch, build_number: buildNumber, jenkins_job: jenkinsJob });
|
||||||
|
const checks = {
|
||||||
|
tests: 'failed',
|
||||||
|
coverage: 'failed',
|
||||||
|
loc: 'failed',
|
||||||
|
style: 'failed',
|
||||||
|
gate_glue: 'failed',
|
||||||
|
sonarqube: 'not_applicable',
|
||||||
|
supply_chain: 'not_applicable',
|
||||||
|
...(quality.checks || {})
|
||||||
|
};
|
||||||
|
const lines = [
|
||||||
'# TYPE platform_quality_gate_runs_total counter',
|
'# TYPE platform_quality_gate_runs_total counter',
|
||||||
`platform_quality_gate_runs_total{suite="${suite}",status="ok"} ${ok}`,
|
`platform_quality_gate_runs_total{suite="${suite}",status="ok"} ${ok}`,
|
||||||
`platform_quality_gate_runs_total{suite="${suite}",status="failed"} ${failed}`,
|
`platform_quality_gate_runs_total{suite="${suite}",status="failed"} ${failed}`,
|
||||||
|
'# TYPE platform_quality_gate_build_info gauge',
|
||||||
|
`platform_quality_gate_build_info${buildLabels} 1`,
|
||||||
|
'# TYPE platform_quality_gate_tests_total gauge',
|
||||||
|
`platform_quality_gate_tests_total{suite="${suite}",result="passed"} ${passed}`,
|
||||||
|
`platform_quality_gate_tests_total{suite="${suite}",result="failed"} ${quality.tests.failures}`,
|
||||||
|
`platform_quality_gate_tests_total{suite="${suite}",result="error"} ${quality.tests.errors}`,
|
||||||
|
`platform_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`,
|
||||||
'# TYPE typhon_quality_gate_tests_total gauge',
|
'# TYPE typhon_quality_gate_tests_total gauge',
|
||||||
`typhon_quality_gate_tests_total{suite="${suite}",result="total"} ${quality.tests.tests}`,
|
`typhon_quality_gate_tests_total{suite="${suite}",result="passed"} ${passed}`,
|
||||||
`typhon_quality_gate_tests_total{suite="${suite}",result="failures"} ${quality.tests.failures}`,
|
`typhon_quality_gate_tests_total{suite="${suite}",result="failed"} ${quality.tests.failures}`,
|
||||||
`typhon_quality_gate_tests_total{suite="${suite}",result="errors"} ${quality.tests.errors}`,
|
`typhon_quality_gate_tests_total{suite="${suite}",result="error"} ${quality.tests.errors}`,
|
||||||
`typhon_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`,
|
`typhon_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`,
|
||||||
'# TYPE typhon_quality_gate_coverage_percent gauge',
|
'# TYPE typhon_quality_gate_coverage_percent gauge',
|
||||||
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="lines"} ${quality.coverage.lines}`,
|
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="lines"} ${quality.coverage.lines}`,
|
||||||
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="statements"} ${quality.coverage.statements}`,
|
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="statements"} ${quality.coverage.statements}`,
|
||||||
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="functions"} ${quality.coverage.functions}`,
|
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="functions"} ${quality.coverage.functions}`,
|
||||||
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="branches"} ${quality.coverage.branches}`,
|
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="branches"} ${quality.coverage.branches}`,
|
||||||
|
'# TYPE platform_quality_gate_workspace_line_coverage_percent gauge',
|
||||||
|
`platform_quality_gate_workspace_line_coverage_percent{suite="${suite}"} ${quality.coverage.lines}`,
|
||||||
|
'# TYPE platform_quality_gate_source_files_total gauge',
|
||||||
|
`platform_quality_gate_source_files_total{suite="${suite}"} ${sourceFilesTotal}`,
|
||||||
|
'# TYPE platform_quality_gate_source_lines_over_500_total gauge',
|
||||||
|
`platform_quality_gate_source_lines_over_500_total{suite="${suite}"} ${sourceLinesOver500}`,
|
||||||
|
'# TYPE typhon_quality_gate_checks_total gauge',
|
||||||
|
...Object.entries(checks).map(([check, result]) => `typhon_quality_gate_checks_total${labelString({ suite, check, result })} 1`),
|
||||||
|
'# TYPE platform_quality_gate_test_case_result gauge',
|
||||||
|
...(quality.testCases || []).map((item) => `platform_quality_gate_test_case_result${labelString({
|
||||||
|
suite,
|
||||||
|
branch,
|
||||||
|
build_number: buildNumber,
|
||||||
|
jenkins_job: jenkinsJob,
|
||||||
|
category: item.category || 'unit',
|
||||||
|
test: item.test || '__no_test_cases__',
|
||||||
|
status: item.status || 'skipped'
|
||||||
|
})} 1`),
|
||||||
''
|
''
|
||||||
].join('\\n');
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, {
|
execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, {
|
||||||
input: payload,
|
input: lines.join('\\n'),
|
||||||
stdio: ['pipe', 'inherit', 'inherit']
|
stdio: ['pipe', 'inherit', 'inherit']
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`metrics publish failed for suite=${suite}:`, error.message);
|
||||||
|
}
|
||||||
NODE
|
NODE
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Buildx Setup') {
|
stage('Enforce quality gate') {
|
||||||
|
steps {
|
||||||
|
container('node') {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
test "$(cat build/quality-gate.rc 2>/dev/null || echo 1)" -eq 0
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Buildx setup') {
|
||||||
when {
|
when {
|
||||||
expression { return params.PUBLISH_IMAGE }
|
expression { return params.PUBLISH_IMAGE }
|
||||||
}
|
}
|
||||||
@ -252,7 +472,7 @@ NODE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Build & Push Image') {
|
stage('Build & push image') {
|
||||||
when {
|
when {
|
||||||
expression { return params.PUBLISH_IMAGE }
|
expression { return params.PUBLISH_IMAGE }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ module.exports = {
|
|||||||
outputDirectory: "build",
|
outputDirectory: "build",
|
||||||
outputName: "junit-typhon.xml",
|
outputName: "junit-typhon.xml",
|
||||||
suiteName: "typhon",
|
suiteName: "typhon",
|
||||||
classNameTemplate: "{classname}",
|
classNameTemplate: "{filepath}",
|
||||||
titleTemplate: "{title}",
|
titleTemplate: "{title}",
|
||||||
ancestorSeparator: " › "
|
ancestorSeparator: " › "
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,4 +48,37 @@ describe("AcInfinityProtocol", () => {
|
|||||||
expect(parsed.workType).toBe(2);
|
expect(parsed.workType).toBe(2);
|
||||||
expect(parsed.fanSpeedGuess).toBe(6);
|
expect(parsed.fanSpeedGuess).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits port selector bytes for unsupported device types", () => {
|
||||||
|
const protocol = new AcInfinityProtocol();
|
||||||
|
const packet = protocol.buildGetModelData(5, 9);
|
||||||
|
|
||||||
|
expect(packet[0]).toBe(0xa5);
|
||||||
|
expect(packet[9]).toBe(1);
|
||||||
|
expect(Array.from(packet.slice(10, 18))).toEqual([
|
||||||
|
16, 17, 18, 19, 20, 21, 22, 23
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on invalid set-level inputs and malformed telemetry packets", () => {
|
||||||
|
const protocol = new AcInfinityProtocol();
|
||||||
|
|
||||||
|
expect(() => protocol.buildSetLevel(11, 2, 11, 3)).toThrow(
|
||||||
|
"level must be an integer between 0 and 10"
|
||||||
|
);
|
||||||
|
expect(() => protocol.buildSetLevel(11, 1, 4, -1)).toThrow(
|
||||||
|
"port must be an integer between 0 and 255"
|
||||||
|
);
|
||||||
|
expect(() => protocol.parseTelemetryNotification(Buffer.from([0x00, 0x01, 0x02]))).toThrow(
|
||||||
|
"unexpected telemetry notification payload"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps sequence numbers after max uint16", () => {
|
||||||
|
const protocol = new AcInfinityProtocol() as unknown as { sequence: number; nextSequence(): number };
|
||||||
|
protocol.sequence = 65535;
|
||||||
|
|
||||||
|
expect(protocol.nextSequence()).toBe(1);
|
||||||
|
expect(protocol.nextSequence()).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -41,17 +41,26 @@ describe("AppConfig", () => {
|
|||||||
POLL_INTERVAL_SECONDS: "4"
|
POLL_INTERVAL_SECONDS: "4"
|
||||||
})
|
})
|
||||||
).toThrow("POLL_INTERVAL_SECONDS must be between 5 and 600");
|
).toThrow("POLL_INTERVAL_SECONDS must be between 5 and 600");
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
AppConfig.fromEnv({
|
||||||
|
ACI_EMAIL: "x",
|
||||||
|
ACI_PASSWORD: "y",
|
||||||
|
LISTEN_PORT: "not-a-number"
|
||||||
|
})
|
||||||
|
).toThrow("LISTEN_PORT must be an integer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports ble mode without cloud credentials", () => {
|
it("supports ble mode without cloud credentials", () => {
|
||||||
const config = AppConfig.fromEnv({
|
const config = AppConfig.fromEnv({
|
||||||
TYPHON_MODE: "ble",
|
TYPHON_MODE: "ble",
|
||||||
ENABLE_CONTROL_API: "true",
|
ENABLE_CONTROL_API: "yes",
|
||||||
|
LOG_LEVEL: "debug",
|
||||||
TY_BLE_DEFAULT_MAC: "58:8c:81:c6:fc:f6",
|
TY_BLE_DEFAULT_MAC: "58:8c:81:c6:fc:f6",
|
||||||
TY_BLE_ALLOWED_MACS: "11:22:33:44:55:66",
|
TY_BLE_ALLOWED_MACS: "11:22:33:44:55:66",
|
||||||
TY_BLE_DEVICE_TYPE: "11",
|
TY_BLE_DEVICE_TYPE: "11",
|
||||||
TY_BLE_SCAN_TIMEOUT_MS: "25000",
|
TY_BLE_SCAN_TIMEOUT_MS: "25000",
|
||||||
TY_BLE_PORT_BASE: "1"
|
TY_BLE_PORT_BASE: "0"
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(config.mode).toBe("ble");
|
expect(config.mode).toBe("ble");
|
||||||
@ -65,7 +74,8 @@ describe("AppConfig", () => {
|
|||||||
]);
|
]);
|
||||||
expect(config.bleDeviceType).toBe(11);
|
expect(config.bleDeviceType).toBe(11);
|
||||||
expect(config.bleScanTimeoutMs).toBe(25000);
|
expect(config.bleScanTimeoutMs).toBe(25000);
|
||||||
expect(config.blePortBase).toBe(1);
|
expect(config.blePortBase).toBe(0);
|
||||||
|
expect(config.logLevel).toBe("debug");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validates mode and MAC inputs", () => {
|
it("validates mode and MAC inputs", () => {
|
||||||
@ -80,9 +90,26 @@ describe("AppConfig", () => {
|
|||||||
TY_BLE_DEFAULT_MAC: "not-a-mac"
|
TY_BLE_DEFAULT_MAC: "not-a-mac"
|
||||||
})).toThrow("TY_BLE_DEFAULT_MAC must be a MAC address like AA:BB:CC:DD:EE:FF");
|
})).toThrow("TY_BLE_DEFAULT_MAC must be a MAC address like AA:BB:CC:DD:EE:FF");
|
||||||
|
|
||||||
|
expect(() => AppConfig.fromEnv({
|
||||||
|
TYPHON_MODE: "ble",
|
||||||
|
TY_BLE_ALLOWED_MACS: "11:22:33:44:55:66,not-a-mac"
|
||||||
|
})).toThrow("TY_BLE_ALLOWED_MACS must contain MAC addresses like AA:BB:CC:DD:EE:FF");
|
||||||
|
|
||||||
expect(() => AppConfig.fromEnv({
|
expect(() => AppConfig.fromEnv({
|
||||||
TYPHON_MODE: "ble",
|
TYPHON_MODE: "ble",
|
||||||
TY_BLE_PORT_BASE: "2"
|
TY_BLE_PORT_BASE: "2"
|
||||||
})).toThrow("TY_BLE_PORT_BASE must be 0 or 1");
|
})).toThrow("TY_BLE_PORT_BASE must be 0 or 1");
|
||||||
|
|
||||||
|
expect(() => AppConfig.fromEnv({
|
||||||
|
ACI_EMAIL: "x",
|
||||||
|
ACI_PASSWORD: "y",
|
||||||
|
ENABLE_CONTROL_API: "maybe"
|
||||||
|
})).toThrow("ENABLE_CONTROL_API must be a boolean");
|
||||||
|
|
||||||
|
expect(() => AppConfig.fromEnv({
|
||||||
|
ACI_EMAIL: "x",
|
||||||
|
ACI_PASSWORD: "y",
|
||||||
|
LOG_LEVEL: "loud"
|
||||||
|
})).toThrow("LOG_LEVEL must be one of: debug, info, warn, error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
270
tests/ControlApiServerEdge.test.ts
Normal file
270
tests/ControlApiServerEdge.test.ts
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ControlPortState,
|
||||||
|
ControllerControlStatus,
|
||||||
|
WifiRecoveryRequest,
|
||||||
|
WifiRecoveryResult
|
||||||
|
} from "../src/domain/ControlTypes";
|
||||||
|
import { ControlApiServer } from "../src/http/ControlApiServer";
|
||||||
|
import { Logger } from "../src/observability/Logger";
|
||||||
|
import { ControllerControlBackend } from "../src/services/ControllerControlBackend";
|
||||||
|
import { RuleEngineService } from "../src/services/RuleEngineService";
|
||||||
|
|
||||||
|
function nextPort(): number {
|
||||||
|
return 30_000 + Math.floor(Math.random() * 20_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
class EdgeBackend implements ControllerControlBackend {
|
||||||
|
public throwStatus = false;
|
||||||
|
public wifiRecoveryRequests: WifiRecoveryRequest[] = [];
|
||||||
|
|
||||||
|
private ports: ControlPortState[] = [
|
||||||
|
{ port: 1, name: "Port 1", fanGroup: "outlet", currentSpeedLevel: 0, online: true, powerState: false },
|
||||||
|
{ port: 2, name: "Port 2", fanGroup: "inside_inlet", currentSpeedLevel: 0, online: true, powerState: false },
|
||||||
|
{ port: 3, name: "Port 3", fanGroup: "outside_inlet", currentSpeedLevel: 0, online: true, powerState: false },
|
||||||
|
{ port: 4, name: "Port 4", fanGroup: "interior", currentSpeedLevel: 0, online: true, powerState: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
public async getStatus(): Promise<ControllerControlStatus> {
|
||||||
|
if (this.throwStatus) {
|
||||||
|
throw new Error("status boom");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: "ble",
|
||||||
|
backend: "fake",
|
||||||
|
controllerMac: null,
|
||||||
|
connected: false,
|
||||||
|
paired: false,
|
||||||
|
telemetrySource: "ble",
|
||||||
|
lastSnapshotEpochSeconds: null,
|
||||||
|
capabilities: {
|
||||||
|
pairing: true,
|
||||||
|
portControl: true,
|
||||||
|
advancedRules: true
|
||||||
|
},
|
||||||
|
ports: this.ports,
|
||||||
|
notes: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async pair(): Promise<ControllerControlStatus> {
|
||||||
|
return this.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPorts(): Promise<ControlPortState[]> {
|
||||||
|
return this.ports;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setPortSpeed(port: number, speedLevel: number): Promise<void> {
|
||||||
|
this.ports = this.ports.map((item) => (
|
||||||
|
item.port === port
|
||||||
|
? { ...item, currentSpeedLevel: speedLevel, powerState: speedLevel > 0 }
|
||||||
|
: item
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async recoverWifi(request: WifiRecoveryRequest): Promise<WifiRecoveryResult> {
|
||||||
|
this.wifiRecoveryRequests.push(request);
|
||||||
|
return {
|
||||||
|
queued: true,
|
||||||
|
backend: "fake",
|
||||||
|
controllerMac: null,
|
||||||
|
notes: ["queued"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestRaw(
|
||||||
|
port: number,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: string,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
): Promise<{ statusCode: number; text: string; contentType: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
headers
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (chunk) => {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
});
|
||||||
|
res.on("end", () => {
|
||||||
|
resolve({
|
||||||
|
statusCode: res.statusCode ?? 0,
|
||||||
|
text: Buffer.concat(chunks).toString("utf8"),
|
||||||
|
contentType: String(res.headers["content-type"] ?? "")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
if (body !== undefined) {
|
||||||
|
req.write(body);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(
|
||||||
|
port: number,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
payload?: unknown
|
||||||
|
): Promise<{ statusCode: number; payload: unknown }> {
|
||||||
|
const body = payload === undefined ? undefined : JSON.stringify(payload);
|
||||||
|
const response = await requestRaw(
|
||||||
|
port,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body,
|
||||||
|
body
|
||||||
|
? { "content-type": "application/json" }
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
payload: JSON.parse(response.text)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ControlApiServer edge cases", () => {
|
||||||
|
it("serves health and ui routes", async () => {
|
||||||
|
const backend = new EdgeBackend();
|
||||||
|
const port = nextPort();
|
||||||
|
const server = new ControlApiServer(
|
||||||
|
port,
|
||||||
|
backend,
|
||||||
|
new RuleEngineService(),
|
||||||
|
new Logger("error", "test"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
const health = await requestJson(port, "GET", "/healthz");
|
||||||
|
expect(health.statusCode).toBe(200);
|
||||||
|
expect(health.payload).toEqual({ ok: true });
|
||||||
|
|
||||||
|
const ui = await requestRaw(port, "GET", "/ui");
|
||||||
|
expect(ui.statusCode).toBe(200);
|
||||||
|
expect(ui.contentType).toContain("text/html");
|
||||||
|
expect(ui.text).toContain("Typhon v2 Control (Scaffold)");
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns structured validation errors for malformed requests", async () => {
|
||||||
|
const backend = new EdgeBackend();
|
||||||
|
const port = nextPort();
|
||||||
|
const server = new ControlApiServer(
|
||||||
|
port,
|
||||||
|
backend,
|
||||||
|
new RuleEngineService(),
|
||||||
|
new Logger("error", "test"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
const badJson = await requestRaw(
|
||||||
|
port,
|
||||||
|
"POST",
|
||||||
|
"/api/v2/pair",
|
||||||
|
"{bad-json",
|
||||||
|
{ "content-type": "application/json" }
|
||||||
|
);
|
||||||
|
expect(badJson.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(badJson.text)).toEqual({ error: "request body must be valid JSON" });
|
||||||
|
|
||||||
|
const hugeBody = `{\"payload\":\"${"x".repeat(70 * 1024)}\"}`;
|
||||||
|
const tooLarge = await requestRaw(
|
||||||
|
port,
|
||||||
|
"POST",
|
||||||
|
"/api/v2/rules",
|
||||||
|
hugeBody,
|
||||||
|
{ "content-type": "application/json" }
|
||||||
|
);
|
||||||
|
expect(tooLarge.statusCode).toBe(413);
|
||||||
|
expect(JSON.parse(tooLarge.text)).toEqual({ error: "request body too large" });
|
||||||
|
|
||||||
|
const missingSpeed = await requestJson(port, "POST", "/api/v2/ports/1/speed", {});
|
||||||
|
expect(missingSpeed.statusCode).toBe(400);
|
||||||
|
expect(missingSpeed.payload).toEqual({ error: "speed_level is required" });
|
||||||
|
|
||||||
|
const missingSsid = await requestJson(port, "POST", "/api/v2/wifi/recover", { password: "pw" });
|
||||||
|
expect(missingSsid.statusCode).toBe(400);
|
||||||
|
expect(missingSsid.payload).toEqual({ error: "ssid is required" });
|
||||||
|
|
||||||
|
const missingPassword = await requestJson(port, "POST", "/api/v2/wifi/recover", { ssid: "atlas" });
|
||||||
|
expect(missingPassword.statusCode).toBe(400);
|
||||||
|
expect(missingPassword.payload).toEqual({ error: "password is required" });
|
||||||
|
|
||||||
|
const invalidRulesBody = await requestJson(port, "POST", "/api/v2/rules", []);
|
||||||
|
expect(invalidRulesBody.statusCode).toBe(400);
|
||||||
|
expect(invalidRulesBody.payload).toEqual({ error: "request body must be a JSON object" });
|
||||||
|
|
||||||
|
const notFound = await requestJson(port, "GET", "/api/v2/unknown");
|
||||||
|
expect(notFound.statusCode).toBe(404);
|
||||||
|
expect(notFound.payload).toEqual({ error: "not found" });
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles rules read/write and unexpected backend exceptions", async () => {
|
||||||
|
const backend = new EdgeBackend();
|
||||||
|
const logger = new Logger("debug", "test");
|
||||||
|
const loggerSpy = jest.spyOn(logger, "error").mockImplementation(() => undefined);
|
||||||
|
const port = nextPort();
|
||||||
|
const server = new ControlApiServer(port, backend, new RuleEngineService(), logger, null);
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
const rulesBefore = await requestJson(port, "GET", "/api/v2/rules");
|
||||||
|
expect(rulesBefore.statusCode).toBe(200);
|
||||||
|
expect(rulesBefore.payload).toEqual({
|
||||||
|
rules: expect.objectContaining({
|
||||||
|
mode: "manual",
|
||||||
|
minimumHoldSeconds: 60,
|
||||||
|
temperatureTargetC: 24,
|
||||||
|
temperatureBandC: 0.5,
|
||||||
|
humidityTargetPercent: 45,
|
||||||
|
humidityBandPercent: 3
|
||||||
|
})
|
||||||
|
});
|
||||||
|
expect((rulesBefore.payload as { rules: { updatedAtEpochSeconds: unknown } }).rules.updatedAtEpochSeconds).toEqual(expect.any(Number));
|
||||||
|
|
||||||
|
const rulesAfter = await requestJson(port, "POST", "/api/v2/rules", {
|
||||||
|
humidityBandPercent: 5
|
||||||
|
});
|
||||||
|
expect(rulesAfter.statusCode).toBe(200);
|
||||||
|
expect(rulesAfter.payload).toEqual({
|
||||||
|
rules: expect.objectContaining({
|
||||||
|
mode: "manual",
|
||||||
|
humidityBandPercent: 5
|
||||||
|
})
|
||||||
|
});
|
||||||
|
expect((rulesAfter.payload as { rules: { updatedAtEpochSeconds: unknown } }).rules.updatedAtEpochSeconds).toEqual(expect.any(Number));
|
||||||
|
|
||||||
|
backend.throwStatus = true;
|
||||||
|
const failedStatus = await requestJson(port, "GET", "/api/v2/status");
|
||||||
|
expect(failedStatus.statusCode).toBe(500);
|
||||||
|
expect(failedStatus.payload).toEqual({ error: "internal server error" });
|
||||||
|
expect(loggerSpy).toHaveBeenCalledWith(
|
||||||
|
"control api request failed",
|
||||||
|
expect.objectContaining({ error_message: "status boom" })
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
53
tests/LoggerAndTemplate.test.ts
Normal file
53
tests/LoggerAndTemplate.test.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Logger } from "../src/observability/Logger";
|
||||||
|
import { renderControlUi } from "../src/ui/template";
|
||||||
|
|
||||||
|
describe("Logger", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses lower-priority log lines", () => {
|
||||||
|
const logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
|
||||||
|
const logger = new Logger("info", "gate-test");
|
||||||
|
|
||||||
|
logger.debug("not emitted", { key: "value" });
|
||||||
|
|
||||||
|
expect(logSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes warn and error levels to the correct console methods", () => {
|
||||||
|
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
const logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
|
||||||
|
const logger = new Logger("debug", "gate-test");
|
||||||
|
|
||||||
|
logger.info("info-message", { context: 1 });
|
||||||
|
logger.warn("warn-message", { context: 2 });
|
||||||
|
logger.error("error-message", { context: 3 });
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const warnPayload = JSON.parse(String(warnSpy.mock.calls[0]?.[0] ?? "{}")) as {
|
||||||
|
level?: string;
|
||||||
|
component?: string;
|
||||||
|
context?: number;
|
||||||
|
};
|
||||||
|
expect(warnPayload.level).toBe("warn");
|
||||||
|
expect(warnPayload.component).toBe("gate-test");
|
||||||
|
expect(warnPayload.context).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderControlUi", () => {
|
||||||
|
it("escapes unsafe MAC input and renders fallback when default MAC is missing", () => {
|
||||||
|
const escaped = renderControlUi(`AA:BB:<script>alert("x")</script>'`);
|
||||||
|
expect(escaped).toContain("<script>alert("x")</script>'");
|
||||||
|
expect(escaped).toContain("AA:BB:");
|
||||||
|
|
||||||
|
const fallback = renderControlUi(null);
|
||||||
|
expect(fallback).toContain("not set");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
215
tests/NodeBleControllerClient.test.ts
Normal file
215
tests/NodeBleControllerClient.test.ts
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
import NodeBle = require("node-ble");
|
||||||
|
|
||||||
|
import { BleFeatureNotSupportedError } from "../src/ble/BleControllerClient";
|
||||||
|
import { NodeBleControllerClient } from "../src/ble/NodeBleControllerClient";
|
||||||
|
import { Logger } from "../src/observability/Logger";
|
||||||
|
|
||||||
|
jest.mock("node-ble", () => ({
|
||||||
|
createBluetooth: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface BleFixture {
|
||||||
|
readCharacteristic: EventEmitter & {
|
||||||
|
startNotifications: jest.Mock<Promise<void>, []>;
|
||||||
|
stopNotifications: jest.Mock<Promise<void>, []>;
|
||||||
|
};
|
||||||
|
writeCharacteristic: {
|
||||||
|
writeValue: jest.Mock<Promise<void>, unknown[]>;
|
||||||
|
};
|
||||||
|
adapter: {
|
||||||
|
isDiscovering: jest.Mock<Promise<boolean>, []>;
|
||||||
|
startDiscovery: jest.Mock<Promise<void>, []>;
|
||||||
|
stopDiscovery: jest.Mock<Promise<void>, []>;
|
||||||
|
waitDevice: jest.Mock<Promise<unknown>, unknown[]>;
|
||||||
|
};
|
||||||
|
destroy: jest.Mock<void, []>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupNodeBleFixture(
|
||||||
|
options: {
|
||||||
|
isDiscovering?: boolean;
|
||||||
|
emitOnWrite?: boolean;
|
||||||
|
missingCharacteristics?: boolean;
|
||||||
|
} = {}
|
||||||
|
): BleFixture {
|
||||||
|
const readCharacteristic = Object.assign(new EventEmitter(), {
|
||||||
|
startNotifications: jest.fn(async () => undefined),
|
||||||
|
stopNotifications: jest.fn(async () => undefined)
|
||||||
|
});
|
||||||
|
const writeCharacteristic = {
|
||||||
|
writeValue: jest.fn(async () => {
|
||||||
|
if (options.emitOnWrite !== false) {
|
||||||
|
setImmediate(() => {
|
||||||
|
readCharacteristic.emit("valuechanged", Buffer.from([0x99]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const service = {
|
||||||
|
getCharacteristic: jest.fn(async (uuid: string) => {
|
||||||
|
if (options.missingCharacteristics) {
|
||||||
|
throw new Error("missing characteristic");
|
||||||
|
}
|
||||||
|
if (uuid.includes("ff02")) {
|
||||||
|
return readCharacteristic;
|
||||||
|
}
|
||||||
|
if (uuid.includes("ff01")) {
|
||||||
|
return writeCharacteristic;
|
||||||
|
}
|
||||||
|
throw new Error("not found");
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const gatt = {
|
||||||
|
services: jest.fn(async () => ["primary-service"]),
|
||||||
|
getPrimaryService: jest.fn(async () => service)
|
||||||
|
};
|
||||||
|
const device = {
|
||||||
|
connect: jest.fn(async () => undefined),
|
||||||
|
gatt: jest.fn(async () => gatt),
|
||||||
|
disconnect: jest.fn(async () => undefined)
|
||||||
|
};
|
||||||
|
const adapter = {
|
||||||
|
isDiscovering: jest.fn(async () => options.isDiscovering === true),
|
||||||
|
startDiscovery: jest.fn(async () => undefined),
|
||||||
|
stopDiscovery: jest.fn(async () => undefined),
|
||||||
|
waitDevice: jest.fn(async () => device)
|
||||||
|
};
|
||||||
|
const bluetooth = {
|
||||||
|
defaultAdapter: jest.fn(async () => adapter)
|
||||||
|
};
|
||||||
|
const destroy = jest.fn(() => undefined);
|
||||||
|
|
||||||
|
const mockedNodeBle = NodeBle as unknown as {
|
||||||
|
createBluetooth: jest.Mock<unknown, []>;
|
||||||
|
};
|
||||||
|
mockedNodeBle.createBluetooth.mockReturnValue({ bluetooth, destroy });
|
||||||
|
|
||||||
|
return {
|
||||||
|
readCharacteristic,
|
||||||
|
writeCharacteristic,
|
||||||
|
adapter,
|
||||||
|
destroy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClient(logger = new Logger("error", "test")): NodeBleControllerClient {
|
||||||
|
return new NodeBleControllerClient({
|
||||||
|
requestTimeoutMs: 25,
|
||||||
|
scanTimeoutMs: 100,
|
||||||
|
deviceType: 11,
|
||||||
|
logger
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NodeBleControllerClient", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads telemetry over BLE and adds received timestamp", async () => {
|
||||||
|
const fixture = setupNodeBleFixture();
|
||||||
|
const client = buildClient();
|
||||||
|
const protocol = {
|
||||||
|
buildGetModelData: jest.fn(() => Buffer.from([0x01])),
|
||||||
|
parseTelemetryNotification: jest.fn(() => ({
|
||||||
|
temperatureCelsius: 24.5,
|
||||||
|
temperatureFahrenheit: 76.1,
|
||||||
|
humidityPercent: 51.2,
|
||||||
|
vpdKpa: 1.62,
|
||||||
|
choosePort: 3,
|
||||||
|
workType: 2,
|
||||||
|
fanSpeedGuess: 7
|
||||||
|
})),
|
||||||
|
buildSetLevel: jest.fn(() => Buffer.from([0x02]))
|
||||||
|
};
|
||||||
|
(client as unknown as { protocol: unknown }).protocol = protocol;
|
||||||
|
|
||||||
|
const nowSpy = jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||||
|
const reading = await client.readTelemetry("58:8c:81:c6:fc:f6", 0);
|
||||||
|
nowSpy.mockRestore();
|
||||||
|
|
||||||
|
expect(protocol.buildGetModelData).toHaveBeenCalledWith(11, 0);
|
||||||
|
expect(protocol.parseTelemetryNotification).toHaveBeenCalled();
|
||||||
|
expect(reading.fanSpeedGuess).toBe(7);
|
||||||
|
expect(reading.receivedAtEpochSeconds).toBe(1_700_000_000);
|
||||||
|
expect(fixture.readCharacteristic.startNotifications).toHaveBeenCalled();
|
||||||
|
expect(fixture.readCharacteristic.stopNotifications).toHaveBeenCalled();
|
||||||
|
expect(fixture.adapter.startDiscovery).toHaveBeenCalled();
|
||||||
|
expect(fixture.adapter.stopDiscovery).toHaveBeenCalled();
|
||||||
|
expect(fixture.destroy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips discovery start/stop when adapter is already discovering", async () => {
|
||||||
|
const fixture = setupNodeBleFixture({ isDiscovering: true });
|
||||||
|
const client = buildClient();
|
||||||
|
(client as unknown as { protocol: unknown }).protocol = {
|
||||||
|
buildGetModelData: jest.fn(() => Buffer.from([0x01])),
|
||||||
|
parseTelemetryNotification: jest.fn(() => ({
|
||||||
|
temperatureCelsius: 20,
|
||||||
|
temperatureFahrenheit: 68,
|
||||||
|
humidityPercent: 40,
|
||||||
|
vpdKpa: 1.1,
|
||||||
|
choosePort: 1,
|
||||||
|
workType: 2,
|
||||||
|
fanSpeedGuess: 1
|
||||||
|
})),
|
||||||
|
buildSetLevel: jest.fn(() => Buffer.from([0x02]))
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.readTelemetry("58:8C:81:C6:FC:F6", 1);
|
||||||
|
|
||||||
|
expect(fixture.adapter.startDiscovery).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.adapter.stopDiscovery).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when set speed completes without a BLE notification ack", async () => {
|
||||||
|
setupNodeBleFixture({ emitOnWrite: false });
|
||||||
|
const logger = new Logger("debug", "test");
|
||||||
|
const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => undefined);
|
||||||
|
const client = buildClient(logger);
|
||||||
|
(client as unknown as { protocol: unknown }).protocol = {
|
||||||
|
buildGetModelData: jest.fn(() => Buffer.from([0x01])),
|
||||||
|
parseTelemetryNotification: jest.fn(() => ({
|
||||||
|
temperatureCelsius: 20,
|
||||||
|
temperatureFahrenheit: 68,
|
||||||
|
humidityPercent: 40,
|
||||||
|
vpdKpa: 1.1,
|
||||||
|
choosePort: 1,
|
||||||
|
workType: 2,
|
||||||
|
fanSpeedGuess: 1
|
||||||
|
})),
|
||||||
|
buildSetLevel: jest.fn(() => Buffer.from([0x03]))
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(client.setPortSpeed("58:8C:81:C6:FC:F6", 4, 8)).resolves.toBeUndefined();
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
"ble set speed completed without notify ack",
|
||||||
|
expect.objectContaining({
|
||||||
|
error_message: expect.stringContaining("timed out waiting for BLE notification")
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when BLE characteristics cannot be resolved", async () => {
|
||||||
|
setupNodeBleFixture({ missingCharacteristics: true });
|
||||||
|
const client = buildClient();
|
||||||
|
|
||||||
|
await expect(client.verifyConnection("58:8C:81:C6:FC:F6")).rejects.toThrow(
|
||||||
|
"failed to resolve AC Infinity BLE characteristics"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports wifi recovery as unsupported", async () => {
|
||||||
|
const client = buildClient();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.recoverWifi("58:8C:81:C6:FC:F6", {
|
||||||
|
ssid: "atlas-net",
|
||||||
|
password: "secret-password",
|
||||||
|
hidden: false
|
||||||
|
})
|
||||||
|
).rejects.toBeInstanceOf(BleFeatureNotSupportedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -177,4 +177,17 @@ describe("TyphonMetrics", () => {
|
|||||||
expect(valueForMetric(json, "typhon_data_age_seconds", {})).toBe(90);
|
expect(valueForMetric(json, "typhon_data_age_seconds", {})).toBe(90);
|
||||||
expect(valueForMetric(json, "typhon_last_successful_poll_timestamp_seconds", {})).toBe(1_700_000_000);
|
expect(valueForMetric(json, "typhon_last_successful_poll_timestamp_seconds", {})).toBe(1_700_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps data age at zero before the first successful poll and reports runtime mode", async () => {
|
||||||
|
const registry = new Registry();
|
||||||
|
const metrics = new TyphonMetrics("0.1.0", false, registry);
|
||||||
|
|
||||||
|
metrics.refreshDataAgeGauge(1_700_000_090);
|
||||||
|
metrics.setRuntimeMode("ble");
|
||||||
|
|
||||||
|
const json = await registry.getMetricsAsJSON();
|
||||||
|
expect(valueForMetric(json, "typhon_data_age_seconds", {})).toBe(0);
|
||||||
|
expect(valueForMetric(json, "typhon_runtime_mode", { mode: "cloud" })).toBe(0);
|
||||||
|
expect(valueForMetric(json, "typhon_runtime_mode", { mode: "ble" })).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user