diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml
index c64044a0..ff9ea482 100644
--- a/.github/workflows/build-and-test.yaml
+++ b/.github/workflows/build-and-test.yaml
@@ -7,3 +7,20 @@ jobs:
with:
java_version: 21
secrets: inherit
+
+ javascript-tests:
+ name: JavaScript Tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+
+ - name: Run JavaScript tests
+ run: |
+ cd webroot/js/test
+ node --test *.test.mjs
diff --git a/webroot/js/component/output.js b/webroot/js/component/output.js
index c7aef5e2..b5c5b257 100644
--- a/webroot/js/component/output.js
+++ b/webroot/js/component/output.js
@@ -15,12 +15,45 @@ function highlightJSON(json) {
json = JSON.stringify(json, null, 2);
}
+ // Common regex patterns for JSON syntax highlighting
+
+ // Matches optional whitespace (spaces, tabs, newlines)
+ // Examples: "", " ", "\t", "\n", " \n "
+ const optionalWhitespace = '\\s*';
+
+ // Matches JSON characters that precede a value: colon followed by optional whitespace, or array/object start with optional whitespace
+ // Examples: ": ", ":[", ", ", "[ ", "[42"
+ const valuePrefixes = '(:\\s*|[\\[\\,]\\s*)';
+
+ // Matches JSON characters that follow a value: comma, closing brackets/braces, newline, or end of string
+ // Examples: ",", "]", "}", "\n", end of string
+ const valueEndings = '(?=\\s*[,\\]\\}\\n]|$)';
+
+ // Matches quoted strings with escaped characters
+ // Examples: "hello", "world \"quoted\"", "path\\to\\file", "unicode: \\u0041"
+ const quotedString = '"(?:[^"\\\\]|\\\\.)*"';
+
+ // Matches JSON object keys (quoted strings containing word chars, spaces, underscores, hyphens)
+ // Examples: "name", "user_id", "first-name", "my key", "data_2023"
+ const objectKey = '"[\\w\\s_-]+"';
+
+ // Matches numbers (integer, decimal, scientific notation)
+ // Examples: 42, -17, 3.14, -0.5, 1e10, 2.5e-3, 1E+5
+ const number = '-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?';
+
+ // Complete regex patterns for each replacement
+ const keyPattern = new RegExp(`(${objectKey})(${optionalWhitespace}:)`, 'g'); // "name": or "user_id" :
+ const stringValuePattern = new RegExp(`(:\\s*)(${quotedString})`, 'g'); // : "John Doe" or :"hello"
+ const numberPattern = new RegExp(`${valuePrefixes}(${number})${valueEndings}`, 'g'); // : 42, [123, or , -3.14
+ const booleanPattern = new RegExp(`${valuePrefixes}(true|false)${valueEndings}`, 'g'); // : true, [false, or , true
+ const nullPattern = new RegExp(`${valuePrefixes}(null)${valueEndings}`, 'g'); // : null, [null, or , null
+
return json
- .replace(/("[\w\s_-]+")(\s*:)/g, '$1$2')
- .replace(/:\s*(".*?")/g, ': $1')
- .replace(/:\s*(\d+\.?\d*)/g, ': $1')
- .replace(/:\s*(true|false)/g, ': $1')
- .replace(/:\s*(null)/g, ': $1');
+ .replace(keyPattern, '$1$2')
+ .replace(stringValuePattern, '$1$2')
+ .replace(numberPattern, '$1$2')
+ .replace(booleanPattern, '$1$2')
+ .replace(nullPattern, '$1$2');
}
function formatOutput(data) {
diff --git a/webroot/js/package.json b/webroot/js/package.json
new file mode 100644
index 00000000..3dbc1ca5
--- /dev/null
+++ b/webroot/js/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "module"
+}
diff --git a/webroot/js/test/README.md b/webroot/js/test/README.md
new file mode 100644
index 00000000..495deae9
--- /dev/null
+++ b/webroot/js/test/README.md
@@ -0,0 +1,13 @@
+# JavaScript Unit Tests
+
+This directory contains Node.js unit tests using ES modules and Node.js's built-in `node:test` module.
+
+To run the tests, you need Node.js v18+ installed. Then run:
+
+```bash
+# Run one test file
+node --test webroot/js/test/output.test.mjs
+
+# Run all tests in the directory
+node --test webroot/js/test/
+```
diff --git a/webroot/js/test/output.test.mjs b/webroot/js/test/output.test.mjs
new file mode 100644
index 00000000..a463482c
--- /dev/null
+++ b/webroot/js/test/output.test.mjs
@@ -0,0 +1,160 @@
+import { test, describe } from 'node:test';
+import assert from 'node:assert';
+import { highlightJSON } from '../component/output.js';
+
+describe('highlightJSON', () => {
+ test('should highlight numbers', () => {
+ const input = { count: 42 };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"count":42}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should not highlight numbers within strings', () => {
+ const input = {
+ link_id: "aws:us-west-2:94889860-0a55-4fbd-981e-3a9ce99ca1ec"
+ };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"link_id":"aws:us-west-2:94889860-0a55-4fbd-981e-3a9ce99ca1ec"}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should handle mixed numbers and strings', () => {
+ const input = {
+ service_id: 8,
+ site_id: 276,
+ link_id: "aws:us-west-2:94889860-0a55-4fbd-981e-3a9ce99ca1ec"
+ };
+ const result = highlightJSON(JSON.stringify(input, null, 2));
+
+ const expected = `{
+ "service_id": 8,
+ "site_id": 276,
+ "link_id": "aws:us-west-2:94889860-0a55-4fbd-981e-3a9ce99ca1ec"
+}`;
+ assert.strictEqual(result, expected);
+ });
+
+ test('should highlight strings', () => {
+ const input = { name: "test-value" };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"name":"test-value"}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should highlight keys', () => {
+ const input = { test_key: "value" };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"test_key":"value"}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should highlight booleans', () => {
+ const input = { flag: true, disabled: false };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"flag":true,"disabled":false}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should highlight null', () => {
+ const input = { value: null };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"value":null}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should handle negative numbers', () => {
+ const input = { temperature: -15 };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"temperature":-15}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should handle decimal numbers', () => {
+ const input = { price: 19.99 };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"price":19.99}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should handle scientific notation', () => {
+ const input = { value: 1.23e-4 };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"value":0.000123}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should handle nested objects', () => {
+ const input = {
+ user: {
+ id: 123,
+ name: "John Doe",
+ active: true,
+ metadata: null,
+ scores: [95.5, -2.3, 0]
+ }
+ };
+ const result = highlightJSON(JSON.stringify(input, null, 2));
+
+ const expected = `{
+ "user": {
+ "id": 123,
+ "name": "John Doe",
+ "active": true,
+ "metadata": null,
+ "scores": [
+ 95.5,
+ -2.3,
+ 0
+ ]
+ }
+}`;
+ assert.strictEqual(result, expected);
+ });
+
+ test('should handle escaped quotes', () => {
+ const input = {
+ message: 'He said "Hello, world!" to everyone'
+ };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"message":"He said \\"Hello, world!\\" to everyone"}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should handle empty objects and arrays', () => {
+ const input = { empty_obj: {}, empty_array: [] };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"empty_obj":{},"empty_array":[]}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should handle special characters in strings', () => {
+ const input = {
+ special: "Line 1\nLine 2\tTabbed\r\nWindows line ending",
+ unicode: "Unicode: 🚀 ñ é"
+ };
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '{"special":"Line 1\\nLine 2\\tTabbed\\r\\nWindows line ending","unicode":"Unicode: 🚀 ñ é"}';
+ assert.strictEqual(result, expected);
+ });
+
+ test('should handle non-object input', () => {
+ const input = [1, 2, 3];
+ const result = highlightJSON(JSON.stringify(input));
+
+ const expected = '[1,2,3]';
+ assert.strictEqual(result, expected);
+ });
+});