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
2 changes: 2 additions & 0 deletions .github/workflows/verify-codegen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
- xmlschema
- openapi
- avro
- plantuml
- vocabulary

steps:
- name: Harden the runner (Audit all outbound calls)
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ verification/work
verification/work-*
verification/templates/jackson-annotations-*.jar
verification/templates/avro-tools-*.jar
verification/templates/plantuml-*.jar

# Runtime data
pids
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
"verify:docker:xmlschema": "node scripts/verification/docker-run.js xmlschema",
"verify:docker:openapi": "node scripts/verification/docker-run.js openapi",
"verify:docker:avro": "node scripts/verification/docker-run.js avro",
"verify:docker:plantuml": "node scripts/verification/docker-run.js plantuml",
"verify:docker:vocabulary": "node scripts/verification/docker-run.js vocabulary",
"test:updateSnapshots": "nyc mocha --updateSnapshot --recursive -t 10000",
"test:watch": "nyc mocha --watch --recursive -t 10000",
"mocha": "mocha --recursive -t 10000",
Expand Down
2 changes: 1 addition & 1 deletion scripts/verification/docker-run.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const path = require('path');

const ROOT = path.join(__dirname, '../..');
const BASE_IMAGE = 'concerto-verify-base:local';
const TARGETS = ['typescript', 'jsonschema', 'graphql', 'protobuf', 'csharp', 'rust', 'java', 'odata', 'mermaid', 'xmlschema', 'openapi', 'avro'];
const TARGETS = ['typescript', 'jsonschema', 'graphql', 'protobuf', 'csharp', 'rust', 'java', 'odata', 'mermaid', 'xmlschema', 'openapi', 'avro', 'plantuml', 'vocabulary'];

/**
* Run a command synchronously with inherited stdio.
Expand Down
2 changes: 1 addition & 1 deletion test/verification/cases.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const MODEL_DIR = path.join(__dirname, '../codegen/fromcto/data/model');
* Each case loads one fixture (or valid combination) into its own ModelManager.
* Do not combine unrelated CTO files — some share namespaces or have import deps.
*
* `skip` may be a string (all targets) or `{ typescript: '...', jsonschema: '...', protobuf: '...', graphql: '...', csharp: '...', rust: '...', java: '...', xmlschema: '...', openapi: '...', avro: '...' }`.
* `skip` may be a string (all targets) or `{ typescript: '...', jsonschema: '...', protobuf: '...', graphql: '...', csharp: '...', rust: '...', java: '...', xmlschema: '...', openapi: '...', avro: '...', plantuml: '...', vocabulary: '...' }`.
*/
const CASES = [
{
Expand Down
169 changes: 169 additions & 0 deletions test/verification/plantuml.validate.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* 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 { execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const https = require('https');
const path = require('path');
const { dir } = require('tmp-promise');

const { FileWriter } = require('@accordproject/concerto-util');
const PlantUMLVisitor = require('../../lib/codegen/fromcto/plantuml/plantumlvisitor.js');

const {
CASES,
getSkipReason,
createModelManager,
applyVerificationEnv,
} = require('./cases.js');

const PLANTUML_VERSION = '1.2024.7';
const PLANTUML_JAR = process.env.PLANTUML_JAR || path.join(
__dirname,
'../../verification/templates',
`plantuml-${PLANTUML_VERSION}.jar`
);
const PLANTUML_URL = `https://github.com/plantuml/plantuml/releases/download/v${PLANTUML_VERSION}/plantuml-${PLANTUML_VERSION}.jar`;

/**
* Download the official PlantUML JAR used to syntax-check generated diagrams.
* @param {string} [url] URL to fetch, following redirects as needed
* @param {number} [redirects] remaining redirects to follow
* @returns {Promise<void>} resolves when the JAR has been written to disk
*/
function downloadPlantumlJar(url = PLANTUML_URL, redirects = 5) {
return new Promise((resolve, reject) => {
https.get(url, (response) => {
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
if (redirects === 0) {
reject(new Error('Too many redirects while downloading plantuml.jar'));
return;
}
response.resume();
resolve(downloadPlantumlJar(response.headers.location, redirects - 1));
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Failed to download plantuml.jar (${response.statusCode})`));
return;
}
fs.mkdirSync(path.dirname(PLANTUML_JAR), { recursive: true });
const file = fs.createWriteStream(PLANTUML_JAR);
response.pipe(file);
file.on('finish', () => {
file.close(resolve);
});
}).on('error', reject);
});
}

/**
* Return the path to the plantuml JAR, downloading it if needed.
* @returns {Promise<string>} absolute path to the JAR
*/
async function ensurePlantumlJar() {
if (!fs.existsSync(PLANTUML_JAR)) {
await downloadPlantumlJar();
}
return PLANTUML_JAR;
}

/**
* Return a skip reason when java is not available.
* @returns {string|null} skip reason or null when java is available
*/
function getJavaSkipReason() {
const version = spawnSync('java', ['-version'], { encoding: 'utf-8' });
if (version.error || version.status !== 0) {
return 'java (JRE) not installed';
}
return null;
}

const JAVA_SKIP_REASON = getJavaSkipReason();
let plantumlJar;

/**
* Generate PlantUML from a model and verify each .puml has valid syntax
* using `java -jar plantuml.jar -syntax`.
* @param {ModelManager} modelManager populated model manager
* @param {object} [visitorOptions] options passed to PlantUMLVisitor
*/
async function verifyPlantUmlSyntax(modelManager, visitorOptions = {}) {
const { path: outputDir, cleanup } = await dir({ unsafeCleanup: true });

try {
modelManager.accept(new PlantUMLVisitor(), {
fileWriter: new FileWriter(outputDir),
...visitorOptions,
});

const pumlFiles = fs.readdirSync(outputDir)
.filter((name) => name.endsWith('.puml'))
.map((name) => path.join(outputDir, name));

if (pumlFiles.length === 0) {
throw new Error('PlantUMLVisitor produced no .puml files');
}

for (const puml of pumlFiles) {
const input = fs.readFileSync(puml, 'utf-8');
let result;
try {
result = execFileSync('java', ['-jar', plantumlJar, '-syntax'], {
input,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (err) {
const details = [err.stdout, err.stderr].filter(Boolean).join('\n').trim();
throw new Error(`${path.basename(puml)}: ${details || err.message}`);
}
if (/^ERROR/m.test(result)) {
throw new Error(`${path.basename(puml)}: ${result.trim()}`);
}
}
} finally {
await cleanup();
}
}

describe('verification', function () {
this.timeout(120000);

before(async function () {
applyVerificationEnv();
if (JAVA_SKIP_REASON) {
// eslint-disable-next-line no-console
console.warn(`Skipping PlantUML verification tests: ${JAVA_SKIP_REASON}`);
return;
}
plantumlJar = await ensurePlantumlJar();
});

CASES.forEach(function (testCase) {
const skipReason = getSkipReason(testCase, 'plantuml') || JAVA_SKIP_REASON;
const title = skipReason
? `generated PlantUML from ${testCase.name} has valid syntax (pending: ${skipReason})`
: `generated PlantUML from ${testCase.name} has valid syntax`;
const run = skipReason ? it.skip : it;

run(title, async function () {
const modelManager = createModelManager(testCase);
await verifyPlantUmlSyntax(modelManager, testCase.visitorOptions || {});
});
});
});
87 changes: 87 additions & 0 deletions test/verification/vocabulary.validate.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* 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 { dir } = require('tmp-promise');

const { FileWriter } = require('@accordproject/concerto-util');
const VocabularyVisitor = require('../../lib/codegen/fromcto/vocabulary/vocabularyvisitor.js');
const { validateVocabulary } = require('../../verification/docker/vocabulary/validate.js');

const {
CASES,
getSkipReason,
createModelManager,
applyVerificationEnv,
} = require('./cases.js');

/**
* Generate Vocabulary YAML and verify each .voc file has a valid vocabulary
* structure using @accordproject/concerto-vocabulary's VocabularyManager.
* @param {ModelManager} modelManager populated model manager
* @param {object} [visitorOptions] options passed to VocabularyVisitor
*/
async function verifyVocabularyValidates(modelManager, visitorOptions = {}) {
const { path: outputDir, cleanup } = await dir({ unsafeCleanup: true });

try {
modelManager.accept(new VocabularyVisitor(), {
fileWriter: new FileWriter(outputDir),
...visitorOptions,
});

const vocFiles = fs.readdirSync(outputDir)
.filter((name) => name.endsWith('.voc'))
.map((name) => path.join(outputDir, name));

if (vocFiles.length === 0) {
throw new Error('VocabularyVisitor produced no .voc files');
}

for (const voc of vocFiles) {
const text = fs.readFileSync(voc, 'utf-8');
try {
validateVocabulary(text);
} catch (err) {
throw new Error(`${path.basename(voc)}: ${err.message}`);
}
}
} finally {
await cleanup();
}
}

describe('verification', function () {
this.timeout(60000);

before(function () {
applyVerificationEnv();
});

CASES.forEach(function (testCase) {
const skipReason = getSkipReason(testCase, 'vocabulary');
const title = skipReason
? `generated Vocabulary from ${testCase.name} validates (pending: ${skipReason})`
: `generated Vocabulary from ${testCase.name} validates`;
const run = skipReason ? it.skip : it;

run(title, async function () {
const modelManager = createModelManager(testCase);
await verifyVocabularyValidates(modelManager, testCase.visitorOptions || {});
});
});
});
14 changes: 14 additions & 0 deletions verification/docker/plantuml/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ARG BASE_IMAGE=concerto-verify-base:local
FROM ${BASE_IMAGE}

# Syntax-check generated PlantUML (.puml) via the official PlantUML jar.
# Use the full JRE (not headless): PlantUML loads fontmanager even for -syntax.
# fontconfig + DejaVu satisfy the native font path on Alpine.
RUN apk add --no-cache openjdk17-jre fontconfig ttf-dejavu curl \
&& curl -fsSL -o /usr/local/share/plantuml.jar \
https://github.com/plantuml/plantuml/releases/download/v1.2024.7/plantuml-1.2024.7.jar

COPY verification/docker/plantuml/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
31 changes: 31 additions & 0 deletions verification/docker/plantuml/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/bin/sh
# Generate PlantUML from the corpus and verify each .puml has valid syntax
# using `java -jar plantuml.jar -syntax`.
set -eu

CLI_TARGET=PlantUML
TARGET_KEY=plantuml
PLANTUML_JAR=/usr/local/share/plantuml.jar

for case_name in $(jq -r '.cases[].name' "${CORPUS_DIR}/manifest.json"); do
out="${WORK_DIR}/${case_name}"
mkdir -p "$out"

run-case.sh "$case_name" "$CLI_TARGET" "$TARGET_KEY" "$out"

puml_files=$(find "$out" -name '*.puml' | sort)
if [ -z "$puml_files" ]; then
continue
fi

echo "==> VERIFY $case_name with plantuml -syntax"
# shellcheck disable=SC2086
for puml in $puml_files; do
echo " $puml"
result=$(java -jar "$PLANTUML_JAR" -syntax < "$puml")
if echo "$result" | grep -q '^ERROR'; then
echo "$result" >&2
exit 1
fi
done
done
12 changes: 12 additions & 0 deletions verification/docker/targets.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,17 @@
"baseImage": "node:20-alpine",
"tool": "avro-tools idl",
"type": "schema"
},
"plantuml": {
"cli": "PlantUML",
"baseImage": "openjdk:17-alpine",
"tool": "plantuml -syntax",
"type": "schema"
},
"vocabulary": {
"cli": "Vocabulary",
"baseImage": "node:20-alpine",
"tool": "concerto-vocabulary VocabularyManager",
"type": "schema"
}
}
12 changes: 12 additions & 0 deletions verification/docker/vocabulary/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ARG BASE_IMAGE=concerto-verify-base:local
FROM ${BASE_IMAGE}

# Validate generated Vocabulary YAML (.voc) files with the official
# @accordproject/concerto-vocabulary package, already installed in the base
# image, which parses and validates the locale/namespace/declarations shape.
# validate.js is baked into /opt/concerto-codegen by the base image's
# `COPY . .`, so it resolves node_modules there - no extra COPY needed.
COPY verification/docker/vocabulary/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
Loading
Loading