diff --git a/README.md b/README.md index 0db30d6..c7bd126 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,17 @@ Configuration settings are set in a JSON configuration file. The file `conf/loca "CqlOperation": "$cql" }, "Build": { - "CqlFileVersion": "1.0.000", - "CqlOutputPath": "./cql", - "testsRunDescription": '', - "testsRunDescription": "Local host test run", - "cqlTranslator": "Java CQFramework Translator", - "cqlTranslatorVersion": "Unknown", - "cqlEngine": "Java CQFramework Engine", - "cqlEngineVersion": "4.1.0" - }, + "CqlFileVersion": "1.0.000", + "CqlOutputPath": "./cql", + "CqlVersion": "1.5", + "testsRunDescription": "Local host test run", + "cqlTranslator": "Java CQFramework Translator", + "cqlTranslatorVersion": "Unknown", + "cqlEngine": "Java CQFramework Engine", + "cqlEngineVersion": "4.1.0", + "SERVER_OFFSET_ISO": "-06:00", + "TimeZoneOffsetPolicy": "timezone-offset-policy.default-server-offset" + }, "Tests": { "ResultsPath": "./results", "SkipList": [] @@ -56,6 +58,171 @@ Configuration settings are set in a JSON configuration file. The file `conf/loca Create your own configuration file and reference it when running the commands. You can use `conf/localhost.json` as a template for a new configuration with your own settings. +### Time Zone Configuration + +The CQL Tests Runner uses two settings to control how **DateTime** values are evaluated: + +- `SERVER_OFFSET_ISO` +- `TimeZoneOffsetPolicy` + +These settings are required because CQL allows **DateTime values without a timezone offset**, and different engines interpret those values differently. Without explicitly setting these, tests involving DateTime comparison and extraction may produce inconsistent results (pass, fail, or null). + +Reference: http://cql.hl7.org/CodeSystem/cql-language-capabilities + +--- + +#### TimeZoneOffsetPolicy (what it means) + +# πŸ”„ Timezone Offset Policy + +## Background + +CQL allows `DateTime` literals to be specified **with or without a timezone offset**. + +For example: + +``` +@2012-04-01T00:00 +``` + +This value does **not include an explicit timezone offset**. + +--- + +## What the CQL Specification Says + +> If no timezone offset is specified, the timezone offset of the evaluation request timestamp is used. + +This means: + +- A `DateTime` literal may omit an offset in its **source representation** +- But at **evaluation time**, the engine must apply an offset +- That offset comes from the **evaluation request timestamp** + +πŸ‘‰ In other words, under CQL semantics: + +- DateTimes are **always evaluated with an effective timezone offset** +- The only question is **which offset is applied** + +--- + +## Why This Capability Exists + +Although the specification is clear, implementations have historically differed in how they handle `DateTime` values without explicit offsets. + +Observed variations include: + +- Applying the evaluation request timestamp offset (spec-compliant behavior) +- Treating the value as offset-less in some operations +- Returning `null` for operations like `timezoneoffset(...)` +- Applying server-local or implicit defaults inconsistently + +This capability exists to explicitly test and document how an engine behaves in these scenarios. + +--- + +## What Is Being Tested + +This capability evaluates how an engine handles `DateTime` values that omit a timezone offset, including: + +- Whether the engine applies the evaluation request timestamp offset +- How functions like `timezoneoffset(...)` behave +- Whether comparisons and arithmetic treat the value consistently + +--- + +## Clarification + +This capability does **not** test whether a `DateTime` β€œhas a timezone or not.” + +Per the CQL specification: + +- A `DateTime` without an explicit offset is still evaluated **as if it has one** +- The offset is derived from the evaluation context (evaluation request timestamp) + +πŸ‘‰ The purpose of this capability is to verify that engines implement this behavior **correctly and consistently**. + +--- + +## Suggested Terminology + +- ❌ β€œDoes the DateTime have a timezone?” +- βœ… β€œHow does the engine determine the effective timezone offset for DateTimes without an explicit offset?” + +--- + +## Key Clarification + +> This capability is testing **implementation consistency**, not specification ambiguity. + + +--- + +# πŸ”— CapabilityTests Alignment & Mapping + +## Capability Definition + +- Capability: `timezone-offset-policy` +- Focus: Handling of DateTime values without explicit timezone offsets + +--- + +## Test Mapping + +### 1. Default Offset Application +- Tests: `timezone-offset-default` +- Verifies: + - Missing offset uses evaluation request timestamp + - No null offset produced + +--- + +### 2. timezoneoffset(...) Behavior +- Tests: `timezoneoffset-from-datetime` +- Verifies: + - Returns evaluation offset when not explicitly provided + +--- + +### 3. Equality / Comparison Consistency +- Tests: `datetime-equality-offset` +- Verifies: + - `@2012-04-01T00:00` equals equivalent explicit-offset value + +--- + +### 4. Arithmetic Behavior +- Tests: `datetime-arithmetic-offset` +- Verifies: + - Duration calculations respect derived offset + +--- + +## Reviewer Traceability + +Spec β†’ Behavior β†’ Test + +- Spec: Offset defaults to evaluation timestamp +- Behavior: Engine applies offset consistently +- Tests: Confirm consistency across functions and operators + +--- + +## Summary + +This capability ensures engines: + +- Correctly apply implicit timezone offsets +- Do not treat DateTimes as offset-less at runtime +- Maintain consistency across operations + + +#### Notes + +- These settings do not change server behavior; they only control how the runner evaluates tests +- If the server declares a policy in metadata, it overrides configuration +- `SERVER_OFFSET_ISO` is only used where explicitly referenced in test expressions + ### Running the tests The CLI now requires a configuration file path as an argument. Run the tests with the following commands: diff --git a/assets/schema/cql-test-configuration.schema.json b/assets/schema/cql-test-configuration.schema.json index 51ea5dc..121416b 100644 --- a/assets/schema/cql-test-configuration.schema.json +++ b/assets/schema/cql-test-configuration.schema.json @@ -31,41 +31,49 @@ "type": "object", "description": "Build configuration settings", "properties": { - "CqlFileVersion": { - "type": "string", - "pattern": "^\\d+\\.\\d+\\.\\d+$", - "description": "CQL file version in semantic versioning format" - }, - "CqlOutputPath": { - "type": "string", - "description": "Path where CQL files are output" - }, - "CqlVersion": { - "type": "string", - "pattern": "^\\d+\\.\\d+(\\.\\d+)?$", - "description": "CQL engine version for version-based test filtering (optional)" - }, - "testsRunDescription":{ - "type": "string", - "description": "Information about this test run" - }, - "cqlTranslator": { - "type": "string", - "description": "Which CQL Translator is used in this test run." - }, - "cqlTranslatorVersion": { - "type": "string", - "description": "Which CQL translator version is used in this test run" - }, - "cqlEngine": { - "type": "string", - "description": "Which CQL engine is used in this test run" - }, - "cqlEngineVersion": { - "type": "string", - "description": "Which version of the CQL engine is used in this test run" - } - }, + "CqlFileVersion": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "CQL file version in semantic versioning format" + }, + "CqlOutputPath": { + "type": "string", + "description": "Path where CQL files are output" + }, + "CqlVersion": { + "type": "string", + "pattern": "^\\d+\\.\\d+(\\.\\d+)?$", + "description": "CQL engine version for version-based test filtering (optional)" + }, + "testsRunDescription": { + "type": "string", + "description": "Information about this test run" + }, + "cqlTranslator": { + "type": "string", + "description": "Which CQL Translator is used in this test run." + }, + "cqlTranslatorVersion": { + "type": "string", + "description": "Which CQL translator version is used in this test run" + }, + "cqlEngine": { + "type": "string", + "description": "Which CQL engine is used in this test run" + }, + "cqlEngineVersion": { + "type": "string", + "description": "Which version of the CQL engine is used in this test run" + }, + "SERVER_OFFSET_ISO": { + "type": "string", + "description": "ISO 8601 formatted date string to offset the server start time" + }, + "TimeZoneOffsetPolicy": { + "type": "string", + "description": "Which timezone offset policy is used in this test run." + } + }, "required": ["CqlFileVersion", "CqlOutputPath"], "additionalProperties": false }, diff --git a/conf/localhost.json b/conf/localhost.json index 1b711a0..2b54a2e 100644 --- a/conf/localhost.json +++ b/conf/localhost.json @@ -4,14 +4,16 @@ "CqlOperation": "$cql" }, "Build": { - "CqlFileVersion": "1.0.000", - "CqlOutputPath": "./cql", - "CqlVersion": "1.5", - "testsRunDescription": "Local host test run", - "cqlTranslator": "Java CQFramework Translator", - "cqlTranslatorVersion": "Unknown", - "cqlEngine": "Java CQFramework Engine", - "cqlEngineVersion": "4.1.0" + "CqlFileVersion": "1.0.000", + "CqlOutputPath": "./cql", + "CqlVersion": "1.5", + "testsRunDescription": "Local host test run", + "cqlTranslator": "Java CQFramework Translator", + "cqlTranslatorVersion": "Unknown", + "cqlEngine": "Java CQFramework Engine", + "cqlEngineVersion": "4.1.0", + "SERVER_OFFSET_ISO": "-06:00", + "TimeZoneOffsetPolicy": "timezone-offset-policy.default-server-offset" }, "Debug": { "QuickTest": false diff --git a/package.json b/package.json index 334f53c..7d474e9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "dev": "tsc --watch", "test": "vitest run test/", "test:cql": "node --import tsx src/bin/cql-tests.ts run-tests conf/localhost.json ./results", + "cql-run-tests:dev": "tsx src/bin/cql-tests.ts run-tests conf/development.json ./results", + "cql-run-tests:local": "tsx src/bin/cql-tests.ts run-tests conf/localhost.json ./local_results", "build-libs": "npm run build && node dist/bin/cql-tests.js build-cql conf/localhost.json ./cql", "unit-tests": "vitest run test/", "clean": "rm -rf dist", @@ -51,4 +53,4 @@ "typescript": "^5.9.3", "vitest": "^4.0.18" } -} \ No newline at end of file +} diff --git a/src/bin/cql-tests.ts b/src/bin/cql-tests.ts index db035a5..542d6d0 100644 --- a/src/bin/cql-tests.ts +++ b/src/bin/cql-tests.ts @@ -43,7 +43,8 @@ program ); process.exit(1); } - throw error; + console.error(error); + process.exit(1); } }); @@ -73,7 +74,8 @@ program ); process.exit(1); } - throw error; + console.error(error); + process.exit(1); } }); @@ -92,4 +94,7 @@ program await serverCommand.start(); }); -program.parse(); +program.parseAsync().catch((error: any) => { + console.error(error); + process.exit(1); +}); diff --git a/src/commands/run-tests-command.ts b/src/commands/run-tests-command.ts index 803e57c..3b601f4 100644 --- a/src/commands/run-tests-command.ts +++ b/src/commands/run-tests-command.ts @@ -1,4 +1,4 @@ -import { TestRunner } from '../services/test-runner.js'; +import { TestRunner } from '../services/test-runner'; import { ConfigLoader } from '../conf/config-loader.js'; // Type declaration for CVL loader @@ -54,7 +54,9 @@ export class RunCommand { cqlTranslator: config.Build?.cqlTranslator, cqlTranslatorVersion: config.Build?.cqlTranslatorVersion, cqlEngine: config.Build?.cqlEngine, - cqlEngineVersion: config.Build?.cqlEngineVersion + cqlEngineVersion: config.Build?.cqlEngineVersion, + SERVER_OFFSET_ISO: config.Build?.SERVER_OFFSET_ISO, + TimeZoneOffsetPolicy: config.Build?.TimeZoneOffsetPolicy, }, Tests: { ResultsPath: config.Tests.ResultsPath, diff --git a/src/conf/config-loader.ts b/src/conf/config-loader.ts index f53ca3b..ff7bcbe 100644 --- a/src/conf/config-loader.ts +++ b/src/conf/config-loader.ts @@ -17,6 +17,8 @@ export class ConfigLoader implements Config { cqlTranslatorVersion: string; cqlEngine: string; cqlEngineVersion: string; + SERVER_OFFSET_ISO: string; + TimeZoneOffsetPolicy: string; }; Tests: { ResultsPath: string; @@ -55,14 +57,18 @@ export class ConfigLoader implements Config { process.env.CQL_VERSION || configData.Build?.CqlVersion, testsRunDescription: process.env.TESTS_RUN_DESCRIPTION || configData.Build?.testsRunDescription, - cqlTranslator: + cqlTranslator: process.env.CQL_TRANSLATOR || configData.Build?.cqlTranslator || 'Unknown', - cqlTranslatorVersion: + cqlTranslatorVersion: process.env.CQL_TRANSLATOR_VERSION || configData.Build?.cqlTranslatorVersion || 'Unknown', cqlEngine: process.env.CQL_ENGINE || configData.Build?.cqlEngine || 'Unknown', - cqlEngineVersion: - process.env.CQL_ENGINE_VERSION || configData.Build?.cqlEngineVersion || 'Unknown' + cqlEngineVersion: + process.env.CQL_ENGINE_VERSION || configData.Build?.cqlEngineVersion || 'Unknown', + SERVER_OFFSET_ISO: + process.env.SERVER_OFFSET_ISO || configData.Build?.SERVER_OFFSET_ISO || '+00:00', + TimeZoneOffsetPolicy: + process.env.TIME_ZONE_OFFSET_POLICY || configData.Build?.TimeZoneOffsetPolicy || '', }; this.Tests = { diff --git a/src/cql-engine/cql-engine.ts b/src/cql-engine/cql-engine.ts index 7754784..e2730d5 100644 --- a/src/cql-engine/cql-engine.ts +++ b/src/cql-engine/cql-engine.ts @@ -1,5 +1,6 @@ import axios, { AxiosResponse } from 'axios'; import { CQLEngineInfo } from '../models/results-types.js'; +import { response } from 'express'; /** * Represents a CQL Engine. @@ -39,19 +40,36 @@ export class CQLEngine { private baseURL?: string; private metadata?: any; private description?: string; + private _SERVER_OFFSET_ISO?: string; - /** - * Creates an instance of CQLEngine. - * @param baseURL - The base URL for the CQL engine. - * @param cqlPath - The path for the CQL engine (optional). - */ - constructor(baseURL: string, cqlPath: string | null = null, - cqlTranslator: string, cqlTranslatorVersion: string, - cqlEngine: string, cqlEngineVersion: string) { - this._prepareBaseURL(baseURL, cqlPath); - this._setInformationFields(cqlTranslator, cqlTranslatorVersion, - cqlEngine, cqlEngineVersion); - } + /** + * Creates a new CQLEngine instance. + * + * @param baseURL Base URL of the FHIR server (e.g. http://localhost:8080/fhir/$cql) + * @param cqlPath Optional path to local CQL files + * @param cqlTranslator Name of the CQL translator + * @param cqlTranslatorVersion Version of the CQL translator + * @param cqlEngine Name of the CQL engine + * @param cqlEngineVersion Version of the CQL engine + * @param SERVER_OFFSET_ISO Timezone offset used by the server (e.g. "+00:00", "-06:00") + */ constructor( + baseURL: string, + cqlPath: string | null = null, + cqlTranslator: string = '', + cqlTranslatorVersion: string = '', + cqlEngine: string = '', + cqlEngineVersion: string = '', + SERVER_OFFSET_ISO: string = '' + ) { + this._prepareBaseURL(baseURL, cqlPath); + this._setInformationFields( + cqlTranslator, + cqlTranslatorVersion, + cqlEngine, + cqlEngineVersion + ); + this.SERVER_OFFSET_ISO = SERVER_OFFSET_ISO; + } /** * Prepares the base URL. @@ -86,31 +104,31 @@ export class CQLEngine { private _setInformationFields(cqlTranslator: string, cqlTranslatorVersion: string, cqlEngine: string, cqlEngineVersion: string) { - this.info.cqlTranslator = cqlTranslator; - this.info.cqlTranslatorVersion = cqlTranslatorVersion; - this.info.cqlEngine = cqlEngine; - this.info.cqlEngineVersion = cqlEngineVersion; - } - - /** - * Fetches metadata from the CQL engine. - * @param force - Whether to force fetching metadata. - * @returns A Promise that resolves when metadata is fetched. - */ - async fetch(force: boolean = false): Promise { - if (this.baseURL) { - if (!this.metadata || force) { - try { - const response: AxiosResponse = await axios.get(`${this.baseURL}/metadata`); - if (response?.data) { - this.metadata = response.data; - } - } catch (e) { - console.error(e); - } - } - } - } + this.info.cqlTranslator = cqlTranslator; + this.info.cqlTranslatorVersion = cqlTranslatorVersion; + this.info.cqlEngine = cqlEngine; + this.info.cqlEngineVersion = cqlEngineVersion; + } + + /** + * Fetches metadata from the CQL engine. + * @param force - Whether to force fetching metadata. + * @returns A Promise that resolves when metadata is fetched. + */ + async fetch(force: boolean = false): Promise { + if (this.baseURL) { + if (!this.metadata || force) { + try { + const response: AxiosResponse = await axios.get(`${this.baseURL}/metadata`); + if (response?.data) { + this.metadata = response.data; + } + } catch (e) { + console.error(e); + } + } + } + } /** * Sets the API URL. @@ -208,6 +226,14 @@ export class CQLEngine { return this.info?.cqlEngineVersion ?? null; } + get SERVER_OFFSET_ISO(): string { + return this._SERVER_OFFSET_ISO; + } + + set SERVER_OFFSET_ISO(value: string) { + this._SERVER_OFFSET_ISO = value; + } + /** * Converts the CQLEngine object to JSON. * @returns The JSON representation of the CQLEngine object. @@ -222,6 +248,7 @@ export class CQLEngine { cqlTranslatorVersion: this.info.cqlTranslatorVersion || '', cqlEngine: this.info.cqlEngine || '', cqlEngineVersion: this.info.cqlEngineVersion || '', + SERVER_OFFSET_ISO: this.SERVER_OFFSET_ISO || '', }; } @@ -231,9 +258,15 @@ export class CQLEngine { * @returns The CQLEngine instance. */ static fromJSON(cqlInfo: CQLEngineInfo): CQLEngine { - const engine = new CQLEngine(cqlInfo.apiUrl || '', null, - cqlInfo.cqlTranslator, cqlInfo.cqlTranslatorVersion, - cqlInfo.cqlEngine, cqlInfo.cqlEngineVersion); + const engine = new CQLEngine( + cqlInfo.apiUrl || '', + null, + cqlInfo.cqlTranslator, + cqlInfo.cqlTranslatorVersion, + cqlInfo.cqlEngine, + cqlInfo.cqlEngineVersion, + cqlInfo.SERVER_OFFSET_ISO + ); if (cqlInfo?.cqlVersion) { engine.cqlVersion = cqlInfo.cqlVersion; } @@ -249,6 +282,9 @@ export class CQLEngine { if (cqlInfo?.cqlEngineVersion) { engine.cqlEngineVersion = cqlInfo.cqlEngineVersion; } + if (cqlInfo?.SERVER_OFFSET_ISO) { + engine._SERVER_OFFSET_ISO = cqlInfo.SERVER_OFFSET_ISO; + } return engine; } } diff --git a/src/jobs/job-processor.ts b/src/jobs/job-processor.ts index e56198a..9ce636f 100644 --- a/src/jobs/job-processor.ts +++ b/src/jobs/job-processor.ts @@ -30,7 +30,7 @@ export class JobProcessor { // Process the tests using the shared TestRunner const results = await this.testRunner.runTests(jobRequest.config, { - onProgress: async (current, total, message) => { + onProgress: async (current: number, total: number, message?: string) => { await this.jobManager.updateJobProgress(jobId, current, total, message); }, }); diff --git a/src/models/config-types.ts b/src/models/config-types.ts index 6cf9b3c..396c294 100644 --- a/src/models/config-types.ts +++ b/src/models/config-types.ts @@ -17,6 +17,7 @@ export interface Config { CqlOutputPath: string; CqlVersion?: string; testsRunDescription?: string; // Note: schema has this misplaced but it's used in code + SERVER_OFFSET_ISO: string; }; Tests: { ResultsPath: string; diff --git a/src/models/results-types.ts b/src/models/results-types.ts index c1a5740..c263929 100644 --- a/src/models/results-types.ts +++ b/src/models/results-types.ts @@ -28,4 +28,5 @@ export interface CQLEngineInfo { cqlTranslatorVersion: string; cqlEngine: string; cqlEngineVersion: string; + SERVER_OFFSET_ISO: string; } diff --git a/src/models/test-types.ts b/src/models/test-types.ts index 2ec86c3..d0e1964 100644 --- a/src/models/test-types.ts +++ b/src/models/test-types.ts @@ -40,6 +40,7 @@ export interface TestGroup { description?: string; reference?: string; notes?: string; + capability?: CapabilityKV[]; test: Test[]; } @@ -79,6 +80,7 @@ export interface InternalTestResult { invalid?: 'false' | 'true' | 'semantic' | 'undefined'; expression: string; capability?: CapabilityKV[]; + groupCapability?: CapabilityKV[]; SkipMessage?: string; } diff --git a/src/server/config-utils.ts b/src/server/config-utils.ts index 6f686dd..b5626b3 100644 --- a/src/server/config-utils.ts +++ b/src/server/config-utils.ts @@ -50,14 +50,16 @@ export function createConfigFromData(configData: any): ConfigLoader { }; config.Build = { - CqlFileVersion: process.env.CQL_FILE_VERSION || configData.Build?.CqlFileVersion || '1.0.000', - CqlOutputPath: process.env.CQL_OUTPUT_PATH || configData.Build?.CqlOutputPath || './cql', - CqlVersion: process.env.CQL_VERSION || configData.Build?.CqlVersion, - testsRunDescription: process.env.TESTS_RUN_DESCRIPTION || configData.Build?.testsRunDescription || 'No configuration provided', - cqlTranslator: process.env.CQL_TRANSLATOR || configData.Build?.cqlTranslator || 'No configuration provided', - cqlTranslatorVersion: process.env.CQL_TRANSLATOR_VERSION || configData.Build?.cqlTranslatorVersion || 'No configuration provided', - cqlEngine: process.env.CQL_ENGINE || configData.Build?.cqlEngine || 'No configuration provided', - cqlEngineVersion: process.env.CQL_ENGINE_VERSION || configData.Build?.cqlEngineVersion || 'No configuration provided' + CqlFileVersion: process.env.CQL_FILE_VERSION || configData.Build?.CqlFileVersion || '1.0.000', + CqlOutputPath: process.env.CQL_OUTPUT_PATH || configData.Build?.CqlOutputPath || './cql', + CqlVersion: process.env.CQL_VERSION || configData.Build?.CqlVersion, + testsRunDescription: process.env.TESTS_RUN_DESCRIPTION || configData.Build?.testsRunDescription || 'No configuration provided', + cqlTranslator: process.env.CQL_TRANSLATOR || configData.Build?.cqlTranslator || 'No configuration provided', + cqlTranslatorVersion: process.env.CQL_TRANSLATOR_VERSION || configData.Build?.cqlTranslatorVersion || 'No configuration provided', + cqlEngine: process.env.CQL_ENGINE || configData.Build?.cqlEngine || 'No configuration provided', + cqlEngineVersion: process.env.CQL_ENGINE_VERSION || configData.Build?.cqlEngineVersion || 'No configuration provided', + SERVER_OFFSET_ISO: process.env.SERVER_OFFSET_ISO || configData.Build?.SERVER_OFFSET_ISO || '+00:00', + TimeZoneOffsetPolicy: process.env.TIME_ZONE_OFFSET_POLICY || configData.Build?.TimeZoneOffsetPolicy || '', }; config.Tests = { diff --git a/src/services/test-runner.ts b/src/services/test-runner.ts index 2d6727a..732b89d 100644 --- a/src/services/test-runner.ts +++ b/src/services/test-runner.ts @@ -34,7 +34,8 @@ export class TestRunner { configData.Build.cqlTranslator, configData.Build.cqlTranslatorVersion, configData.Build.cqlEngine, - configData.Build.cqlEngineVersion + configData.Build.cqlEngineVersion, + configData.Build.SERVER_OFFSET_ISO ); cqlEngine.cqlVersion = '1.5'; //default value const cqlVersion = config.Build?.CqlVersion; @@ -42,6 +43,15 @@ export class TestRunner { cqlEngine.cqlVersion = cqlVersion; } + await cqlEngine.fetch(); + + const activeTimeZonePolicy = await this.resolveTimeZoneOffsetPolicy( + config, + cqlEngine.apiUrl!, + cqlEngine['metadata'], + options.useAxios + ); + console.log('Resolved timezone policy:', activeTimeZonePolicy); // Load CVL using dynamic import // @ts-ignore const cvlModule = await import('../../cvl/cvl.mjs'); @@ -82,6 +92,8 @@ export class TestRunner { resultExtractor, skipMap, config, + cqlEngine, + activeTimeZonePolicy, options.useAxios ); results.add(result); @@ -107,6 +119,8 @@ export class TestRunner { resultExtractor: ResultExtractor, skipMap: Map, config: ConfigLoader, + cqlEngine: CQLEngine, + activeTimeZonePolicy: string, useAxios: boolean = false ): Promise { const key = `${result.testsName}-${result.groupName}-${result.testName}`; @@ -121,6 +135,16 @@ export class TestRunner { return result; } + const timezonePolicySkipReason = this.shouldSkipTimezonePolicyTest( + result, + activeTimeZonePolicy + ); + if (timezonePolicySkipReason) { + result.SkipMessage = timezonePolicySkipReason; + result.testStatus = 'skip'; + return result; + } + const data = generateParametersResource(result, config.FhirServer.CqlOperation); try { @@ -131,6 +155,8 @@ export class TestRunner { result.testName ); + this.applyServerOffsetToParameters(data, cqlEngine); + let response: any; if (useAxios) { // Use axios for backward compatibility @@ -197,6 +223,227 @@ export class TestRunner { return result; } + private async resolveTimeZoneOffsetPolicy( + config: ConfigLoader, + apiUrl: string, + serverMetadata?: any, + useAxios: boolean = false + ): Promise { + // + const metadataPolicy = this.extractTimeZonePolicyFromMetadata(serverMetadata); + if (metadataPolicy) { + console.log('Resolved timezone policy from metadata:', metadataPolicy); + return metadataPolicy; + } + + const configuredPolicy = + process.env.TIME_ZONE_OFFSET_POLICY?.trim() || + config.Build?.TimeZoneOffsetPolicy?.trim(); + + if (configuredPolicy) { + console.log('Resolved timezone policy from env/config:', configuredPolicy); + return configuredPolicy; + } + + const probedPolicy = await this.detectTimeZoneOffsetPolicy(apiUrl, useAxios); + if (probedPolicy) { + console.log('Resolved timezone policy from probe:', probedPolicy); + return probedPolicy; + } + + const fallbackPolicy = 'timezone-offset-policy.default-server-offset'; + console.log('Resolved timezone policy from fallback:', fallbackPolicy); + return fallbackPolicy; + } + + private async detectTimeZoneOffsetPolicy( + apiUrl: string, + useAxios: boolean = false + ): Promise { + // order of setting timezone offset policy: metadata -> env/config -> probe -> fallback + const data = { + resourceType: 'Parameters', + parameter: [ + { + name: 'expression', + valueString: 'timezoneoffset from @2012-04-01T00:00', + }, + ], + }; + + let response: any; + + if (useAxios) { + const axios = await import('axios'); + const axiosResponse = await axios.default.post(apiUrl, data, { + headers: { 'Content-Type': 'application/json' }, + }); + response = { + status: axiosResponse.status, + data: axiosResponse.data, + }; + } else { + const fetchResponse = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + response = { + status: fetchResponse.status, + data: await fetchResponse.json(), + }; + } + + if (response.status !== 200) { + return null; + } + + const extracted = this.extractProbeResult(response.data); + + if (extracted === 'null') { + return 'timezone-offset-policy.no-default-offset'; + } + + if (typeof extracted === 'number') { + return 'timezone-offset-policy.default-server-offset'; + } + + if (typeof extracted === 'string') { + const trimmed = extracted.trim().toLowerCase(); + if (trimmed === 'null') { + return 'timezone-offset-policy.no-default-offset'; + } + if (/^-?\d+$/.test(trimmed)) { + return 'timezone-offset-policy.default-server-offset'; + } + } + + return null; + } + + private extractProbeResult(responseBody: any): any { + const parameter = responseBody?.parameter; + if (!Array.isArray(parameter) || parameter.length === 0) { + return null; + } + + const returnParam = parameter.find((p: any) => p.name === 'return') || parameter[0]; + + if (returnParam.valueInteger !== undefined) { + return returnParam.valueInteger; + } + if (returnParam.valueDecimal !== undefined) { + return returnParam.valueDecimal; + } + if (returnParam.valueString !== undefined) { + return returnParam.valueString; + } + if (returnParam.valueBoolean !== undefined) { + return returnParam.valueBoolean; + } + + return null; + } + + private extractTimeZonePolicyFromMetadata(metadata: any): string | null { + if (!metadata || typeof metadata !== 'object') { + return null; + } + + const policyCodes = [ + 'timezone-offset-policy.no-default-offset', + 'timezone-offset-policy.default-server-offset', + ]; + + function findInObject(obj: any): string | null { + if (!obj || typeof obj !== 'object') { + return null; + } + + if (Array.isArray(obj)) { + for (const item of obj) { + const found = findInObject(item); + if (found) return found; + } + return null; + } + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string' && policyCodes.includes(value)) { + return value; + } + + if (key === 'code' && typeof value === 'string' && policyCodes.includes(value)) { + return value; + } + + if ( + key === 'valueCode' && + typeof value === 'string' && + policyCodes.includes(value) + ) { + return value; + } + + if ( + key === 'valueString' && + typeof value === 'string' && + policyCodes.includes(value) + ) { + return value; + } + + if (typeof value === 'object') { + const found = findInObject(value); + if (found) return found; + } + } + + return null; + } + + return findInObject(metadata); + } + + private applyServerOffsetToParameters(data: any, engine: CQLEngine): void { + const expressionParam = data?.parameter?.find((p: any) => p.name === 'expression'); + if (!expressionParam || typeof expressionParam.valueString !== 'string') { + return; + } + + const offset = engine.SERVER_OFFSET_ISO; + if (typeof offset !== 'string' || offset.trim() === '') { + return; + } + + expressionParam.valueString = this.replaceServerOffsetPlaceholder( + expressionParam.valueString, + offset + ); + } + + private replaceServerOffsetPlaceholder(expression: string, serverOffsetISO: string): string { + return expression.replace(/\{\{SERVER_OFFSET_ISO\}\}/g, serverOffsetISO); + } + + private shouldSkipTimezonePolicyTest(test: any, activeTimeZonePolicy: string): string | null { + const requiredCapabilities = test.capability || []; + + const requiredPolicy = requiredCapabilities.find((c: any) => + c.code?.startsWith('timezone-offset-policy.') + )?.code; + + if (!requiredPolicy) { + return null; + } + + if (requiredPolicy !== activeTimeZonePolicy) { + return `requires ${requiredPolicy} but server is ${activeTimeZonePolicy}`; + } + + return null; + } + private compareVersions(versionA: string | undefined, versionB: string | undefined): number { // Split into numeric parts (e.g., "1.5.2" β†’ [1,5,2]) const partsA = String(versionA ?? '') diff --git a/src/shared/results-shared.ts b/src/shared/results-shared.ts index 50b77a4..afb75c2 100644 --- a/src/shared/results-shared.ts +++ b/src/shared/results-shared.ts @@ -19,8 +19,14 @@ export class Result implements InternalTestResult { invalid: 'false' | 'true' | 'semantic' | 'undefined'; expression: string; capability: CapabilityKV[] = []; - - constructor(testsName: string, groupName: string, test: Test) { + groupCapability: CapabilityKV[] = []; + + constructor( + testsName: string, + groupName: string, + test: Test, + groupCapability: CapabilityKV[] = [] + ) { this.testsName = testsName; this.groupName = groupName; this.testName = test.name; @@ -54,7 +60,9 @@ export class Result implements InternalTestResult { this.capability = Array.isArray(test.capability) ? test.capability.map(({ code, value }) => ({ code, value })) : []; - } + this.groupCapability = Array.isArray(groupCapability) + ? groupCapability.map(({ code, value }) => ({ code, value })) + : []; } } export async function generateEmptyResults( @@ -77,7 +85,7 @@ export async function generateEmptyResults( if (test != undefined) { for (const t of test) { console.log(' Test: ' + t.name); - const r = new Result(ts.name, group.name, t); + const r = new Result(ts.name, group.name, t, group.capability || []); results.push(r); groupTests.push(r); } diff --git a/test/server-command.test.ts b/test/server-command.test.ts index 392e436..7a12fda 100644 --- a/test/server-command.test.ts +++ b/test/server-command.test.ts @@ -6,22 +6,23 @@ import { ServerCommand } from '../src/commands/server-command.js'; // Test data and mock helpers const createMockConfig = (overrides = {}) => ({ - FhirServer: { - BaseUrl: 'http://localhost:8080/fhir/', - CqlOperation: '$cql', - }, - Build: { - CqlFileVersion: '1.0.000', - CqlOutputPath: './cql', - }, - Debug: { - QuickTest: false, - }, - Tests: { - ResultsPath: './results', - SkipList: [], - }, - ...overrides, + FhirServer: { + BaseUrl: 'http://localhost:8080/fhir/', + CqlOperation: '$cql', + }, + Build: { + CqlFileVersion: '1.0.000', + CqlOutputPath: './cql', + SERVER_OFFSET_ISO: '+00:00' + }, + Debug: { + QuickTest: false, + }, + Tests: { + ResultsPath: './results', + SkipList: [], + }, + ...overrides, }); const createMockResults = () => ({