diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..ca0246c
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,45 @@
+name: CI
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+env:
+ JAVA_VERSION: 21
+
+jobs:
+ test:
+ strategy:
+ fail-fast: false
+ matrix:
+ node-version: ['22.x']
+ os: [ubuntu-latest, windows-2022, macos-latest]
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: ${{ env.JAVA_VERSION }}
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ registry-url: 'https://registry.npmjs.org/'
+
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Build the project
+ run: npm run build
+
+ - name: Run tests
+ run: npm run test
diff --git a/jest.config.js b/jest.config.js
index e142053..8ccda6d 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,14 +1,6 @@
-import { weaverConfig } from "@specs-feup/clava/code/WeaverConfiguration.js";
-
const config = {
preset: "ts-jest/presets/default-esm",
- testEnvironment: "@specs-feup/lara/jest/jestEnvironment.js",
- testEnvironmentOptions: {
- weaverConfig,
- },
- globalSetup: "@specs-feup/lara/jest/jestGlobalSetup.js",
- globalTeardown: "@specs-feup/lara/jest/jestGlobalTeardown.js",
- setupFiles: ["@specs-feup/lara/jest/setupFiles/sharedJavaModule.js"],
+ testEnvironment: "node",
//notify: true,
//notifyMode: "always",
//verbose: true,
diff --git a/package.json b/package.json
index 696d8c8..d91b6d8 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"test:watch": "npm run test -- --watch"
},
"dependencies": {
- "@specs-feup/lara": "^3.0.4",
+ "@specs-feup/lara": "^3.5.2",
"express": "^4.21.2",
"ws": "^8.18.0"
},
diff --git a/src/AstConverterUtils.test.ts b/src/AstConverterUtils.test.ts
new file mode 100644
index 0000000..0e5e8dc
--- /dev/null
+++ b/src/AstConverterUtils.test.ts
@@ -0,0 +1,60 @@
+import {
+ addIdentation,
+ escapeHtml,
+ getNodeCodeTags,
+ getSpanTags,
+ getSyntaxHighlightTags,
+ normalizeFilepath,
+} from "./AstConverterUtils.js";
+
+describe("AstConverterUtils", () => {
+ describe("addIdentation", () => {
+ it("indents every line except the first one", () => {
+ expect(addIdentation("first\nsecond\nthird", 2)).toBe(
+ "first\n second\n third"
+ );
+ });
+
+ it("leaves single-line code unchanged", () => {
+ expect(addIdentation("const value = 1;", 3)).toBe("const value = 1;");
+ });
+ });
+
+ it("escapes HTML control characters without changing other text", () => {
+ expect(escapeHtml("if (left < right && right > 0)"))
+ .toBe("if (left < right && right > 0)");
+ });
+
+ it("creates generic span tags with all supplied attributes", () => {
+ expect(getSpanTags('class="node"', 'data-id="42"')).toEqual([
+ '',
+ "",
+ ]);
+ });
+
+ it("creates node-code span tags", () => {
+ expect(getNodeCodeTags("node-7")).toEqual([
+ '',
+ "",
+ ]);
+ });
+
+ it.each(["comment", "keyword", "literal", "string", "type"] as const)(
+ "creates %s syntax-highlighting tags",
+ (type) => {
+ expect(getSyntaxHighlightTags(type)).toEqual([
+ ``,
+ "",
+ ]);
+ }
+ );
+
+ it("normalizes Windows separators and preserves POSIX separators", () => {
+ expect(normalizeFilepath("src\\public\\index.html")).toBe(
+ "src/public/index.html"
+ );
+ expect(normalizeFilepath("src/public/index.html")).toBe(
+ "src/public/index.html"
+ );
+ });
+});
diff --git a/src/public/js/ToolJoinPoint.test.ts b/src/public/js/ToolJoinPoint.test.ts
new file mode 100644
index 0000000..51c5851
--- /dev/null
+++ b/src/public/js/ToolJoinPoint.test.ts
@@ -0,0 +1,69 @@
+import ToolJoinPoint from "./ToolJoinPoint.js";
+
+describe("ToolJoinPoint", () => {
+ const jsonTree = {
+ id: "root",
+ type: "program",
+ code: "const answer = 42;",
+ filepath: "src/example.ts",
+ info: { language: "TypeScript" },
+ children: [
+ {
+ id: "child",
+ type: "declaration",
+ code: "const answer = 42;",
+ filepath: "src/example.ts",
+ info: { name: "answer" },
+ children: [],
+ },
+ ],
+ };
+
+ it("builds a nested join-point tree from JSON", () => {
+ const root = ToolJoinPoint.fromJson(jsonTree);
+
+ expect(root.id).toBe("root");
+ expect(root.type).toBe("program");
+ expect(root.code).toBe("const answer = 42;");
+ expect(root.filepath).toBe("src/example.ts");
+ expect(root.info).toEqual({ language: "TypeScript" });
+ expect(root.children).toHaveLength(1);
+ expect(root.children[0]).toBeInstanceOf(ToolJoinPoint);
+ expect(root.children[0].id).toBe("child");
+ });
+
+ it("serializes a nested join-point tree to JSON", () => {
+ expect(ToolJoinPoint.fromJson(jsonTree).toJson()).toEqual(jsonTree);
+ });
+
+ it("clones the complete tree into new join-point instances", () => {
+ const original = ToolJoinPoint.fromJson(jsonTree);
+ const clone = original.clone();
+
+ expect(clone).not.toBe(original);
+ expect(clone.children[0]).not.toBe(original.children[0]);
+ expect(clone.toJson()).toEqual(original.toJson());
+ });
+
+ it("supports join points without code or a filepath", () => {
+ const joinPoint = new ToolJoinPoint(
+ "synthetic",
+ "scope",
+ undefined,
+ undefined,
+ {},
+ []
+ );
+
+ expect(joinPoint.code).toBeUndefined();
+ expect(joinPoint.filepath).toBeUndefined();
+ expect(joinPoint.toJson()).toEqual({
+ id: "synthetic",
+ type: "scope",
+ code: undefined,
+ filepath: undefined,
+ info: {},
+ children: [],
+ });
+ });
+});
diff --git a/src/public/js/utils.test.ts b/src/public/js/utils.test.ts
new file mode 100644
index 0000000..fd24985
--- /dev/null
+++ b/src/public/js/utils.test.ts
@@ -0,0 +1,15 @@
+import { countChar } from "./utils.js";
+
+describe("countChar", () => {
+ it("counts every occurrence of the requested character", () => {
+ expect(countChar("abracadabra", "a")).toBe(5);
+ });
+
+ it("returns zero when the character is absent", () => {
+ expect(countChar("visualization", "x")).toBe(0);
+ });
+
+ it("handles Unicode characters", () => {
+ expect(countChar("a✨b✨c", "✨")).toBe(2);
+ });
+});