diff --git a/.github/workflows/eslint.js.yml b/.github/workflows/eslint.js.yml new file mode 100644 index 000000000..85889bac7 --- /dev/null +++ b/.github/workflows/eslint.js.yml @@ -0,0 +1,37 @@ +name: ESLint + +on: + push: + paths: + - 'frontend/**/*.ts' + pull_request: + branches: + - main + +defaults: + run: + working-directory: frontend + +jobs: + eslint: + + runs-on: ubuntu-24.04 + + strategy: + matrix: + node-version: [24.x] + + steps: + - uses: actions/checkout@v4 + - name: Enable Corepack + run: corepack enable + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + cache-dependency-path: 'frontend/pnpm-lock.yaml' + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Run ESLint + run: pnpm run eslint diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index d75275572..5215b8b3f 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,9 +1,17 @@ import tsPlugin from '@typescript-eslint/eslint-plugin'; import tsParser from '@typescript-eslint/parser'; import jsdocPlugin from 'eslint-plugin-jsdoc'; +import chaiFriendlyPlugin from 'eslint-plugin-chai-friendly'; export default [ - jsdocPlugin.configs['flat/recommended'], + { + // tests/html/ is regenerated Sphinx build output (gitignored), not project code. + ignores: ['tests/html/**'], + }, + { + ...jsdocPlugin.configs['flat/recommended'], + files: ['src/**/*.ts', 'tests/**/*.ts'], + }, { files: ['src/**/*.ts', 'tests/**/*.ts'], plugins: { @@ -21,6 +29,18 @@ export default [ ...tsPlugin.configs.recommended.rules, 'jsdoc/no-undefined-types': 'off', 'max-len': ['error', {ignoreRegExpLiterals: true}], + '@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_'}], + }, + }, + { + // chai-friendly's rule recognizes chai assertions as non-dead expressions. + files: ['tests/**/*.ts'], + plugins: { + 'chai-friendly': chaiFriendlyPlugin, + }, + rules: { + '@typescript-eslint/no-unused-expressions': 'off', + 'chai-friendly/no-unused-expressions': 'error', }, }, ]; diff --git a/frontend/package.json b/frontend/package.json index 39adaf8a9..e60897417 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "css-loader": "^7.1.4", "css-minimizer-webpack-plugin": "^8.0.0", "eslint": "^10.7.0", + "eslint-plugin-chai-friendly": "^1.2.1", "eslint-plugin-jsdoc": "^63.2.0", "eslint-webpack-plugin": "^6.0.0", "file-loader": "^6.2.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e49f9dd7e..5eba5f16a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: eslint: specifier: ^10.7.0 version: 10.7.0(jiti@2.7.0) + eslint-plugin-chai-friendly: + specifier: ^1.2.1 + version: 1.2.1(eslint@10.7.0(jiti@2.7.0)) eslint-plugin-jsdoc: specifier: ^63.2.0 version: 63.2.0(eslint@10.7.0(jiti@2.7.0)) @@ -2082,6 +2085,12 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-plugin-chai-friendly@1.2.1: + resolution: {integrity: sha512-mV3EOJLDr8+L+LS8uCkP711fnNHz+4PsmPyz18xwkvjJwfLRlnx0Eu6CFnb5B+dW5ahoav2jVer2KFYsmIuv3A==} + engines: {node: '>=0.10.0'} + peerDependencies: + eslint: '>=3.0.0' + eslint-plugin-jsdoc@63.2.0: resolution: {integrity: sha512-tccz9igEV5mTKVAwo/KwXqKP7ENqa3a+LpRFuEPAP3k/+SXG4T0sOvA+c2wwTD/TLNrH9MCW4LIu4xEExkJdzg==} engines: {node: ^22.13.0 || >=24} @@ -6066,6 +6075,10 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-plugin-chai-friendly@1.2.1(eslint@10.7.0(jiti@2.7.0)): + dependencies: + eslint: 10.7.0(jiti@2.7.0) + eslint-plugin-jsdoc@63.2.0(eslint@10.7.0(jiti@2.7.0)): dependencies: '@es-joy/jsdoccomment': 0.88.0 diff --git a/frontend/src/ts/resource.ts b/frontend/src/ts/resource.ts index 0e2929327..fc783fd2d 100644 --- a/frontend/src/ts/resource.ts +++ b/frontend/src/ts/resource.ts @@ -1,7 +1,5 @@ /** * Corresponds to a text file - * - * @export */ export type Resource = { basename: string; @@ -10,7 +8,5 @@ export type Resource = { /** * Corresponds to a list of Resources - * - * @export */ export type ResourceList = Array; diff --git a/frontend/tests/ts/areas.spec.ts b/frontend/tests/ts/areas.spec.ts index 982a01a25..da8693e1c 100644 --- a/frontend/tests/ts/areas.spec.ts +++ b/frontend/tests/ts/areas.spec.ts @@ -2,7 +2,7 @@ import { expect, use } from 'chai'; import chaiDom from 'chai-dom'; -const chai = use(chaiDom); +use(chaiDom); // Import package under test import {Area, OutputArea, LabArea, makeLabArea, LabContainer} diff --git a/frontend/tests/ts/dom-utils.spec.ts b/frontend/tests/ts/dom-utils.spec.ts index f0a22c2c8..8071955be 100644 --- a/frontend/tests/ts/dom-utils.spec.ts +++ b/frontend/tests/ts/dom-utils.spec.ts @@ -1,7 +1,7 @@ // Import testing libs import { expect, use } from 'chai'; import chaiDom from 'chai-dom'; -const chai = use(chaiDom); +use(chaiDom); import {getElemById, getElemsByClass, getElemsByTag} from '../../src/ts/dom-utils'; diff --git a/frontend/tests/ts/download.spec.ts b/frontend/tests/ts/download.spec.ts index 4084638a0..1e4a5e9fd 100644 --- a/frontend/tests/ts/download.spec.ts +++ b/frontend/tests/ts/download.spec.ts @@ -1,8 +1,7 @@ import { expect, use } from 'chai'; import chaiDom from 'chai-dom'; -import chaiAsPromised from 'chai-as-promised'; -const chai = use(chaiDom); +use(chaiDom); import JSZip from 'jszip'; import FileSaver from 'file-saver'; @@ -112,7 +111,8 @@ describe('Download', () => { }); it('should find builder switches', () => { - const parsedSwitches = parseSwitches({Builder: ["test1", "test2"], Compiler: []}); + const parsedSwitches = parseSwitches( + {Builder: ["test1", "test2"], Compiler: []}); const expectedBuilder = 'for Switches ("Ada") use ("test1", "test2");'; const expectedCompiler = 'for Switches ("Ada") use ();'; expect(parsedSwitches['--BUILDER_SWITCHES_PLACEHOLDER--']).to.equal( @@ -124,7 +124,8 @@ describe('Download', () => { }); it('should find compiler switches', () => { - const parsedSwitches = parseSwitches({Builder: [], "Compiler": ["test3", "test4"]}); + const parsedSwitches = parseSwitches( + {Builder: [], "Compiler": ["test3", "test4"]}); const expectedBuilder = 'for Switches ("Ada") use ();'; const expectedCompiler = 'for Switches ("Ada") use ("test3", "test4");'; expect(parsedSwitches['--BUILDER_SWITCHES_PLACEHOLDER--']).to.equal( @@ -213,7 +214,11 @@ describe('Download', () => { }); it('should find a main (c file)', () => { - const cFile = `#include \nint main() {\nprintf("Hello, World!");\nreturn 0;\n}`; + const cFile = `#include +int main() { +printf("Hello, World!"); +return 0; +}`; const files: ResourceList = [ {basename: 'other.c', contents: cFile}, {basename: 'test.ads', contents: ''}, @@ -259,13 +264,15 @@ describe('Download', () => { describe('#getGprContents()', () => { it('should replace all placeholders', () => { const files: ResourceList = [{basename: 'main.adb', contents: ''}]; - const gpr = getGprContents(files, {Builder: [], Compiler: []}, 'main.adb', false); + const gpr = + getGprContents(files, {Builder: [], Compiler: []}, 'main.adb', false); expect(gpr).to.not.contain('--MAIN_PLACEHOLDER--'); expect(gpr).to.not.contain('--LANGUAGE_PLACEHOLDER--'); expect(gpr).to.not.contain('--COMPILER_SWITCHES_PLACEHOLDER--'); expect(gpr).to.not.contain('--BUILDER_SWITCHES_PLACEHOLDER--'); expect(gpr).to.not.contain('--'); - const gpr_spark = getGprContents(files, {Builder: [], Compiler: []}, 'main.adb', true); + const gpr_spark = + getGprContents(files, {Builder: [], Compiler: []}, 'main.adb', true); expect(gpr_spark).to.not.contain('--MAIN_PLACEHOLDER--'); expect(gpr_spark).to.not.contain('--LANGUAGE_PLACEHOLDER--'); expect(gpr_spark).to.not.contain('--COMPILER_SWITCHES_PLACEHOLDER--'); @@ -281,7 +288,8 @@ describe('Download', () => { let origGenerateAsync: any; const files: ResourceList = [ - {basename: 'main.adb', contents: 'procedure Main is begin null; end Main;'}, + {basename: 'main.adb', + contents: 'procedure Main is begin null; end Main;'}, ]; const switches: UnparsedSwitches = {Builder: [], Compiler: []}; @@ -299,6 +307,7 @@ describe('Download', () => { }); after(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (JSZip.prototype as any).generateAsync = origGenerateAsync; }); @@ -313,7 +322,8 @@ describe('Download', () => { expect(savedFilename).to.equal('MyProject.zip'); }); - it('should include source, main.gpr, and main.adc in non-SPARK mode', async () => { + it('should include source, main.gpr, and main.adc in non-SPARK mode', + async () => { downloadProject(files, switches, 'main.adb', 'Test', false); await new Promise((r) => setTimeout(r, 200)); expect(capturedFileNames).to.include('main.adb'); @@ -323,7 +333,8 @@ describe('Download', () => { expect(capturedFileNames).not.to.include('main_spark.adc'); }); - it('should also include main_spark.gpr and main_spark.adc in SPARK mode', async () => { + it('should also include main_spark.gpr and main_spark.adc in SPARK mode', + async () => { downloadProject(files, switches, 'main.adb', 'Test', true); await new Promise((r) => setTimeout(r, 200)); expect(capturedFileNames).to.include('main.gpr'); diff --git a/frontend/tests/ts/editor.spec.ts b/frontend/tests/ts/editor.spec.ts index 9c430fa10..2852fba38 100644 --- a/frontend/tests/ts/editor.spec.ts +++ b/frontend/tests/ts/editor.spec.ts @@ -2,7 +2,7 @@ import { expect, use } from 'chai'; import chaiDom from 'chai-dom'; -const chai = use(chaiDom); +use(chaiDom); import * as Ace from 'ace-builds'; @@ -40,7 +40,8 @@ describe('Editor', () => { }); it('should set C_CPP mode for a .c file', () => { - const cResource: Resource = {basename: 'main.c', contents: 'int main() { return 0; }'}; + const cResource: Resource = + {basename: 'main.c', contents: 'int main() { return 0; }'}; inTest.addSession(cResource.basename, cResource.contents); inTest.setSession(cResource.basename); const session = editor.getSession(); diff --git a/frontend/tests/ts/scrolltop.spec.ts b/frontend/tests/ts/scrolltop.spec.ts index 8ea0a5beb..a5e093e60 100644 --- a/frontend/tests/ts/scrolltop.spec.ts +++ b/frontend/tests/ts/scrolltop.spec.ts @@ -2,13 +2,12 @@ import { expect, use } from 'chai'; import chaiDom from 'chai-dom'; -const chai = use(chaiDom); +use(chaiDom); import {scrollTop} from '../../src/ts/scrolltop'; /** * Helper function to trigger window event - * * @param {Window} element - The window element * @param {string} eventName - The event to do */ @@ -20,9 +19,8 @@ function triggerEvent(element: Window, eventName: string): void { /** * Helper function used to override default non implemented version in JSDOM - * - * @param {number} x - * @param {number} y + * @param {number} x - The x-coordinate to scroll to + * @param {number} y - The y-coordinate to scroll to */ function scrollTo(x: number, y: number): void { document.body.scrollTop = y; diff --git a/frontend/tests/ts/server.spec.ts b/frontend/tests/ts/server.spec.ts index b490fdab1..cdf7d597f 100644 --- a/frontend/tests/ts/server.spec.ts +++ b/frontend/tests/ts/server.spec.ts @@ -4,7 +4,7 @@ import chaiAsPromised from 'chai-as-promised'; import chaiDom from 'chai-dom'; use(chaiAsPromised); -const chai = use(chaiDom); +use(chaiDom); import {Server, WebSocket} from 'mock-socket'; @@ -14,10 +14,9 @@ import {CheckOutput, RunProgram} from '../../src/ts/server-types'; global.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket; /** -* Remove all event listeners from the server -* -* @param {Server} server - The server to remove the listeners from -*/ + * Remove all event listeners from the server + * @param {Server} server - The server to remove the listeners from + */ function removeListeners(server: Server): void { for (let type in server.listeners) { server.listeners[type] = []; @@ -36,7 +35,8 @@ describe('ServerWorker', () => { lab: false, }; let server: Server = new Server(baseURL); - let client: ServerWorker = new ServerWorker(baseURL, (data: CheckOutput.FS): boolean => { + let client: ServerWorker = new ServerWorker(baseURL, + (data: CheckOutput.FS): boolean => { cbCount++; return data.completed; }); @@ -66,7 +66,7 @@ describe('ServerWorker', () => { before(() => { server.on('connection', (socket) => { - socket.on('message', (event) => { + socket.on('message', (_event) => { serverResponses.forEach((msg) => { socket.send(JSON.stringify(msg)); }); @@ -95,7 +95,7 @@ describe('ServerWorker', () => { before(() => { server.on('connection', (socket) => { - socket.on('message', (event) => { + socket.on('message', (_event) => { socket.send(JSON.stringify(serverResponse)); }); }); @@ -106,7 +106,8 @@ describe('ServerWorker', () => { }); it('should throw an exception when AWS rejects the request', async () => { - await expect(client.execute(tsData, 2000)).to.be.rejectedWith(expectedErrorMsg); + await expect(client.execute(tsData, 2000)) + .to.be.rejectedWith(expectedErrorMsg); }); }); @@ -117,7 +118,7 @@ describe('ServerWorker', () => { before(() => { server.on('connection', (socket) => { - socket.on('message', (event) => {}); + socket.on('message', (_event) => {}); }); }); @@ -126,7 +127,8 @@ describe('ServerWorker', () => { }); it('should timeout if no response is recieved', async () => { - await expect(client.execute(tsData, timeout)).to.be.rejectedWith(expectedErrorMsg); + await expect(client.execute(tsData, timeout)) + .to.be.rejectedWith(expectedErrorMsg); }); }); }); diff --git a/frontend/tests/ts/widget.spec.ts b/frontend/tests/ts/widget.spec.ts index 6caefc386..dc22098fb 100644 --- a/frontend/tests/ts/widget.spec.ts +++ b/frontend/tests/ts/widget.spec.ts @@ -2,11 +2,11 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import chaiDom from 'chai-dom'; -import {Client, Server, WebSocket} from 'mock-socket'; +import {Server, WebSocket} from 'mock-socket'; import FileSaver from 'file-saver'; -// const chai = use(chaiDom); -const chai = use(chaiAsPromised); +use(chaiDom); +use(chaiAsPromised); import {readFileSync} from 'fs'; import {resolve} from 'path'; @@ -32,7 +32,6 @@ const __dirname = dirname(__filename); /** * Helper function to fill DOM from a file - * * @param {string} filename - The filename to use */ function fillDOM(filename: string): void { @@ -53,7 +52,6 @@ function clearDOM(): void { /** * Helper function to trigger an event - * * @param {HTMLElement} element - The element to trigger the event on * @param {string} eventName - The event name to trigger */ @@ -64,10 +62,9 @@ function triggerEvent(element: HTMLElement, eventName: string): void { } /** -* Remove all event listeners from the server -* -* @param {Server} server - The server to remove the listeners from -*/ + * Remove all event listeners from the server + * @param {Server} server - The server to remove the listeners from + */ function removeListeners(server: Server): void { for (let type in server.listeners) { server.listeners[type] = []; @@ -139,7 +136,6 @@ describe('Widget', () => { let buttonGroup: HTMLElement; let outputDiv: HTMLElement; let runButton: HTMLButtonElement; - const identifier = 123; before(() => { buttonGroup = getElemById(root.id + '.button-group'); @@ -147,7 +143,7 @@ describe('Widget', () => { // stub scrollIntoView function beacuse JSDOM doesn't have it // eslint-disable-next-line max-len - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars window.HTMLElement.prototype.scrollIntoView = (arg: any): void => {}; }); @@ -223,7 +219,8 @@ describe('Widget', () => { 'Compiler': ['-gnata', '-gnatX'], }; - const request: RunProgram.TS = JSON.parse(receivedMessages[0]) as RunProgram.TS; + const request: RunProgram.TS = + JSON.parse(receivedMessages[0]) as RunProgram.TS; expect(request.data.files).to.have.length(1); expect(request.data.mode).to.equal('run'); @@ -264,7 +261,6 @@ describe('Widget', () => { let fakeDiv: HTMLDivElement; let fakeOA: OutputArea; let realDiv: HTMLElement; - let receivedMessages: Array = []; beforeEach(() => { fakeDiv = document.createElement('div') as HTMLDivElement; @@ -281,7 +277,7 @@ describe('Widget', () => { "requestId": "abc_-=2" }; server.on('connection', (socket) => { - socket.on('message', (event) => { + socket.on('message', (_event) => { socket.send(JSON.stringify(serverResponse)); }); }); @@ -313,7 +309,7 @@ describe('Widget', () => { } ]; server.on('connection', (socket) => { - socket.on('message', (event) => { + socket.on('message', (_event) => { serverResponses.forEach((msg) => { socket.send(JSON.stringify(msg)); }); @@ -347,7 +343,7 @@ describe('Widget', () => { } ]; server.on('connection', (socket) => { - socket.on('message', (event) => { + socket.on('message', (_event) => { serverResponses.forEach((msg) => { socket.send(JSON.stringify(msg)); }); @@ -384,7 +380,7 @@ describe('Widget', () => { } ]; server.on('connection', (socket) => { - socket.on('message', (event) => { + socket.on('message', (_event) => { serverResponses.forEach((msg) => { socket.send(JSON.stringify(msg)); }); @@ -406,11 +402,13 @@ describe('Widget', () => { root.dataset.switches = 'invalid json'; runButton.click(); await ServerWorker.delay(250); - expect(realDiv.textContent).to.include(Strings.INTERNAL_ERROR_MESSAGE); + expect(realDiv.textContent) + .to.include(Strings.INTERNAL_ERROR_MESSAGE); root.dataset.switches = originalSwitches as string; }); - it('should add output line for stdout from a file not in viewMap', async () => { + it('should add output line for stdout from a file not in viewMap', + async () => { const serverResponse: CheckOutput.FS = { output: [ {type: 'stdout', data: 'unknown.adb:1:2: error: test error'}, @@ -484,8 +482,8 @@ describe('Widget', () => { }); it('should revert theme setting when user cancels', () => { - const themeSetting = getElemById(root.id + '.settings-bar.theme-setting') as - HTMLInputElement; + const themeSetting = getElemById( + root.id + '.settings-bar.theme-setting') as HTMLInputElement; window.confirm = (): boolean => false; const originalChecked = themeSetting.checked; themeSetting.checked = !originalChecked; @@ -494,8 +492,8 @@ describe('Widget', () => { }); it('should apply dark theme and reload when user confirms', () => { - const themeSetting = getElemById(root.id + '.settings-bar.theme-setting') as - HTMLInputElement; + const themeSetting = getElemById( + root.id + '.settings-bar.theme-setting') as HTMLInputElement; window.confirm = (): boolean => true; themeSetting.checked = true; try { @@ -513,11 +511,12 @@ describe('Widget', () => { getElemById(root.id + '.settings-bar.compiler-switches'); }); - it('should deactivate mutually exclusive switches when one is checked', () => { - const gnato = document.getElementById( - root.id + '.settings-bar.compiler-switches.-gnato') as HTMLInputElement; - const gnato0 = document.getElementById( - root.id + '.settings-bar.compiler-switches.-gnato0') as HTMLInputElement; + it('should deactivate mutually exclusive switches when one is checked', + () => { + const gnatoId = root.id + '.settings-bar.compiler-switches.-gnato'; + const gnato = document.getElementById(gnatoId) as HTMLInputElement; + const gnato0Id = root.id + '.settings-bar.compiler-switches.-gnato0'; + const gnato0 = document.getElementById(gnato0Id) as HTMLInputElement; gnato.checked = true; gnato0.checked = true; triggerEvent(gnato0, 'change'); @@ -549,7 +548,8 @@ describe('Widget', () => { 'compiler-switch-help-info')[0] as HTMLElement; const firstEntry = compilerSwitchesSetting.getElementsByClassName( 'compiler-switch-entry')[0] as HTMLElement; - const b = firstEntry.getElementsByTagName('button')[0] as HTMLButtonElement; + const b = + firstEntry.getElementsByTagName('button')[0] as HTMLButtonElement; b.click(); expect(d.classList.contains('disabled')).to.be.false; expect(d.querySelector('b')).to.not.be.null; @@ -584,7 +584,8 @@ describe('Widget', () => { const outputDiv = getElemById(root.id + '.output-area'); dlButton.click(); await ServerWorker.delay(100); - expect(outputDiv.textContent).to.include(Strings.INTERNAL_ERROR_MESSAGE); + expect(outputDiv.textContent) + .to.include(Strings.INTERNAL_ERROR_MESSAGE); root.dataset.switches = originalSwitches as string; }); }); @@ -640,7 +641,6 @@ describe('Widget', () => { }); describe('Normal Behavior', () => { - const identifier = 123; const consoleMsg = 'General message'; let receivedMessages: Array = []; @@ -784,7 +784,8 @@ describe('Widget', () => { }); it('should populate the code-block-info output element', () => { - const cbiContents = getElemById(root.id + '.code_block_info.run info.contents'); + const cbiContents = + getElemById(root.id + '.code_block_info.run info.contents'); expect(cbiContents.innerText).to.include('Hello from run output!'); }); });