Repository: FHIR/sushi (ts)

Issue:
Need a way to set xhtml values.
If I want to set the narrative of a resource example or anywhere else that the Narrative datatype is used, there does not seem to be a way to set a fixed xhtml value.

* section.text.div = "No adverse event happened.  Not sure why I'm sending this report."

doesn't work as it generates an error:
error Cannot fix string value: No drugs taken. Value does not match element type: xhtml


Pull request description:
Sushi self-update and xhtml outer div check
Fixes #305 and #969

For #305:
Changed the `checkXhtml` function to assure that the outer div is checked and that it both starts and ends with the appropriate `div` tag

For #969:
Added three functions: `getLocalSushiVersion()`, `getLatestSushiVersion()`, `checkSushiVersion()`. 
`getLocalSushiVersion()` checks local sushi version, outputs version as string
`getLatestSushiVersion()` makes an axios call to check the current sushi version, outputs version as string
`checkSushiVersion()`calls both of the above functions to then compare the two strings and output a warning if different

`checkSushiVersion()` is called at the end of the app.ts main process

Will probably need more tests. 

Patch:
diff --git a/package.json b/package.json
index ae24971..90ac60f 100644
--- a/package.json
+++ b/package.json
@@ -98,4 +98,4 @@
     "winston": "^3.3.3",
     "yaml": "^1.9.2"
   }
-}
+}
\ No newline at end of file
diff --git a/src/app.ts b/src/app.ts
index 3f70cb8..d9c17b0 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -28,7 +28,9 @@ import {
   getRawFSHes,
   init,
   getRandomPun,
-  setIgnoredWarnings
+  setIgnoredWarnings,
+  getLocalSushiVersion,
+  checkSushiVersion
 } from './utils';
 
 const FSH_VERSION = '2.0.0';
@@ -195,21 +197,24 @@ async function app() {
   }
 
   console.log();
-  printResults(outPackage);
+  const sushiVersions = await checkSushiVersion();
+  printResults(outPackage, sushiVersions);
+
+  console.log();
 
   process.exit(stats.numError);
 }
 
 function getVersion(): string {
-  const packageJSONPath = path.join(__dirname, '..', 'package.json');
-  if (fs.existsSync(packageJSONPath)) {
-    const sushiVersion = fs.readJSONSync(packageJSONPath)?.version;
+  const sushiVersion = getLocalSushiVersion();
+  if (sushiVersion !== null) {
     return `SUSHI v${sushiVersion} (implements FHIR Shorthand specification v${FSH_VERSION})`;
   }
   return 'unknown';
 }
 
-function printResults(pkg: Package) {
+function printResults(pkg: Package, sushiVersions: any) {
+  const { latest, current } = sushiVersions;
   // NOTE: These variables are creatively names to align well in the strings below while keeping prettier happy
   const profileNum = pad(pkg.profiles.length.toString(), 13);
   const extentNum = pad(pkg.extensions.length.toString(), 12);
@@ -244,6 +249,18 @@ function printResults(pkg: Package) {
     clr('╚' + '═════════════════════════════════════════════════════════════════' + '' + '╝')
   ];
 
+  if (latest != null && current !== 'unknown' && latest !== current) {
+    const endline = results.pop();
+    // prettier-ignore
+    results.push(
+      clr('╠'  + '═════════════════════════════════════════════════════════════════' + '' + '╣'),
+      clr('║') + `    You are using SUSHI version ${current}, but the latest stable     ` + '' + clr('║'),
+      clr('║') + `  release is version ${latest}. To install the latest release, run:  ` + '' + clr('║'),
+      clr('║') + '                  npm install -g fsh-sushi                       ' + '' + clr('║'),
+      endline
+    );
+  }
+
   const convertChars = !supportsFancyCharacters();
   results.forEach(r => {
     if (convertChars) {
diff --git a/src/fhirtypes/ElementDefinition.ts b/src/fhirtypes/ElementDefinition.ts
index 1e9622e..5029d59 100644
--- a/src/fhirtypes/ElementDefinition.ts
+++ b/src/fhirtypes/ElementDefinition.ts
@@ -1913,6 +1913,13 @@ export class ElementDefinition {
 
   private checkXhtml(value: string): boolean {
     try {
+      if (this.path.endsWith('.div')) {
+        const startsWithDiv = /^\s*<\s*div[\s>]/.test(value);
+        const endsWithDiv = /<\s*\/\s*div\s*>\s*$/.test(value);
+        if (!startsWithDiv || !endsWithDiv) {
+          logger.warn(`xhtml div elements should start and end with <div> tags for ${this.id}`);
+        }
+      }
       return sax.parser(true).write(value).error == null;
     } catch (ex) {
       return false;
diff --git a/src/utils/Processing.ts b/src/utils/Processing.ts
index 8b42cfa..7948d04 100644
--- a/src/utils/Processing.ts
+++ b/src/utils/Processing.ts
@@ -549,3 +549,32 @@ export function getFilesRecursive(dir: string): string[] {
     return [];
   }
 }
+
+export function getLocalSushiVersion(): string {
+  const packageJSONPath = path.join(__dirname, '..', '..', 'package.json');
+  if (fs.existsSync(packageJSONPath)) {
+    return fs.readJSONSync(packageJSONPath)?.version;
+  }
+  return null;
+}
+
+export async function getLatestSushiVersion(): Promise<string> {
+  try {
+    const res = await axios.get('https://registry.npmjs.org/fsh-sushi');
+    const latestVer = res.data['dist-tags'].latest;
+    if (latestVer == null) {
+      logger.error('Unable to determine the latest version of sushi.');
+    } else {
+      return latestVer;
+    }
+  } catch (e) {
+    logger.error(`Unable to determine the latest version of sushi: ${e.message}`);
+  }
+}
+
+export async function checkSushiVersion(): Promise<any> {
+  const latest = await getLatestSushiVersion();
+  const current = getLocalSushiVersion();
+
+  return { latest, current };
+}


Test patch:
diff --git a/test/fhirtypes/ElementDefinition.assignString.test.ts b/test/fhirtypes/ElementDefinition.assignString.test.ts
index bc6de9c..ae02303 100644
--- a/test/fhirtypes/ElementDefinition.assignString.test.ts
+++ b/test/fhirtypes/ElementDefinition.assignString.test.ts
@@ -5,6 +5,7 @@ import { loadFromPath } from '../../src/fhirdefs/load';
 import { FHIRDefinitions } from '../../src/fhirdefs/FHIRDefinitions';
 import { StructureDefinition, ElementDefinition } from '../../src/fhirtypes';
 import { TestFisher } from '../testhelpers';
+import { loggerSpy } from '../testhelpers/loggerSpy';
 
 describe('ElementDefinition', () => {
   let defs: FHIRDefinitions;
@@ -1175,4 +1176,27 @@ describe('ElementDefinition', () => {
       });
     });
   });
+  describe('#checkXhtml', () => {
+    beforeEach(() => {
+      loggerSpy.reset();
+    });
+    it("should warn and throw when path doesn't start and end with div", () => {
+      let didThrow = false;
+      const narrativeDiv = patient.findElementByPath('text.div', fisher);
+      try {
+        narrativeDiv.assignValue('<piv xmlns="http://www.w3.org/1999/xhtml">Twas brillig</div>');
+      } catch (error) {
+        didThrow = true;
+        expect(loggerSpy.getLastMessage('warn')).toMatch(
+          'xhtml div elements should start and end with <div> tags for Patient.text.div'
+        );
+      }
+      expect(didThrow).toBeTrue();
+    });
+    it("shouldn't warn when path starts and ends with div", () => {
+      const narrativeDiv = patient.findElementByPath('text.div', fisher);
+      narrativeDiv.assignValue('<div xmlns="http://www.w3.org/1999/xhtml">Twas brillig</div>');
+      expect(loggerSpy.getAllLogs('warn')).toHaveLength(0);
+    });
+  });
 });
diff --git a/test/utils/Processing.test.ts b/test/utils/Processing.test.ts
index 2e03936..9b88c80 100644
--- a/test/utils/Processing.test.ts
+++ b/test/utils/Processing.test.ts
@@ -17,7 +17,10 @@ import {
   writeFHIRResources,
   init,
   checkNullValuesOnArray,
-  writePreprocessedFSH
+  writePreprocessedFSH,
+  getLocalSushiVersion,
+  getLatestSushiVersion,
+  checkSushiVersion
 } from '../../src/utils/Processing';
 import * as loadModule from '../../src/fhirdefs/load';
 import { FHIRDefinitions } from '../../src/fhirdefs';
@@ -1275,4 +1278,109 @@ describe('Processing', () => {
       expect(consoleSpy.mock.calls.slice(-1)[0]).toEqual(['\nAborting Initialization.\n']);
     });
   });
+
+  describe('#getLatestSushiVersion()', () => {
+    let mockedAxios: jest.Mocked<typeof axios>;
+
+    beforeAll(() => {
+      jest.mock('axios');
+      mockedAxios = axios as jest.Mocked<typeof axios>;
+    });
+
+    beforeEach(() => {
+      loggerSpy.reset();
+    });
+    it('successfully fetches data', async () => {
+      const data = {
+        data: {
+          name: 'fsh-sushi',
+          'dist-tags': {
+            latest: '2.2.6',
+            beta: '2.0.0-beta.3',
+            'pre-1.0': '0.16.1',
+            internal: '2.0.0-beta.1-fshonline-hotfix'
+          }
+        }
+      };
+      mockedAxios.get.mockImplementationOnce(() => Promise.resolve(data));
+      await expect(getLatestSushiVersion()).resolves.toEqual('2.2.6');
+    });
+
+    it('unsuccessfully fetches data due to latest tag being missing', async () => {
+      const data = {
+        data: {
+          name: 'fsh-sushi',
+          'dist-tags': {
+            beta: '2.0.0-beta.3',
+            'pre-1.0': '0.16.1',
+            internal: '2.0.0-beta.1-fshonline-hotfix'
+          }
+        }
+      };
+      mockedAxios.get.mockImplementationOnce(() => Promise.resolve(data));
+      await getLatestSushiVersion();
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        'Unable to determine the latest version of sushi.'
+      );
+    });
+
+    it("unsuccessfully fetches data due to 'dist-tags' being missing", async () => {
+      const data = {};
+      mockedAxios.get.mockImplementationOnce(() => Promise.resolve(data));
+      await getLatestSushiVersion();
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        "Unable to determine the latest version of sushi: Cannot read property 'dist-tags' of undefined"
+      );
+    });
+  });
+
+  describe('#checkSushiVersion()', () => {
+    let mockedAxios: jest.Mocked<typeof axios>;
+
+    beforeAll(() => {
+      jest.mock('axios');
+      mockedAxios = axios as jest.Mocked<typeof axios>;
+    });
+
+    beforeEach(() => {
+      loggerSpy.reset();
+    });
+    it('returns an object with the latest and current sushi verisons', async () => {
+      const localVersion = getLocalSushiVersion();
+      const data = {
+        data: {
+          name: 'fsh-sushi',
+          'dist-tags': {
+            latest: localVersion,
+            beta: '2.0.0-beta.3',
+            'pre-1.0': '0.16.1',
+            internal: '2.0.0-beta.1-fshonline-hotfix'
+          }
+        }
+      };
+      mockedAxios.get.mockImplementationOnce(() => Promise.resolve(data));
+      const versionObj = await checkSushiVersion();
+      expect(versionObj).toHaveProperty('latest');
+      expect(versionObj).toHaveProperty('current');
+      expect(versionObj).toStrictEqual({ latest: localVersion, current: localVersion });
+    });
+    it('should return an object with an undefined latest value when latest is not present', async () => {
+      const localVersion = getLocalSushiVersion();
+      const data = {
+        data: {
+          name: 'fsh-sushi',
+          'dist-tags': {
+            beta: '2.0.0-beta.3',
+            'pre-1.0': '0.16.1',
+            internal: '2.0.0-beta.1-fshonline-hotfix'
+          }
+        }
+      };
+      mockedAxios.get.mockImplementationOnce(() => Promise.resolve(data));
+      const versionObj = await checkSushiVersion();
+      expect(versionObj).toHaveProperty('latest');
+      expect(versionObj).toHaveProperty('current');
+      expect(versionObj).toStrictEqual({ latest: undefined, current: localVersion });
+    });
+  });
 });