From 75c080aa4c39a453cf27f98fc6ff246e62cdbbca Mon Sep 17 00:00:00 2001 From: Asli Aykan <56061820+asliayk@users.noreply.github.com> Date: Tue, 3 Dec 2024 21:28:24 +0100 Subject: [PATCH 01/36] Communication: Fix an issue with list formatting in Markdown (#9925) --- .../posting-content-part.components.ts | 8 ++- .../posting-markdown-editor.component.ts | 36 ++++++++++ .../model/actions/bulleted-list.action.ts | 2 +- .../model/actions/list.action.ts | 21 +++--- .../model/actions/ordered-list.action.ts | 2 +- .../monaco-editor/monaco-editor.component.ts | 13 ++++ .../posting-content-part.component.spec.ts | 46 +++++++++++++ ...postings-markdown-editor.component.spec.ts | 68 +++++++++++++------ .../monaco-editor-action.integration.spec.ts | 2 +- .../monaco-editor.component.spec.ts | 38 +++++++++++ 10 files changed, 204 insertions(+), 32 deletions(-) diff --git a/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts b/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts index 6c8322045888..a485184bd3df 100644 --- a/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts +++ b/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts @@ -76,15 +76,21 @@ export class PostingContentPartComponent implements OnInit { processContent() { if (this.postingContentPart.contentBeforeReference) { this.processedContentBeforeReference = this.escapeNumberedList(this.postingContentPart.contentBeforeReference); + this.processedContentBeforeReference = this.escapeUnorderedList(this.processedContentBeforeReference); } if (this.postingContentPart.contentAfterReference) { this.processedContentAfterReference = this.escapeNumberedList(this.postingContentPart.contentAfterReference); + this.processedContentAfterReference = this.escapeUnorderedList(this.processedContentAfterReference); } } escapeNumberedList(content: string): string { - return content.replace(/^(\s*\d+)\. /gm, '$1\\. '); + return content.replace(/^(\s*\d+)\. /gm, '$1\\. '); + } + + escapeUnorderedList(content: string): string { + return content.replace(/^(- )/gm, '\\$1'); } /** diff --git a/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts b/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts index 03a87624d52f..c176eed51aa0 100644 --- a/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts +++ b/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts @@ -15,6 +15,7 @@ import { inject, input, } from '@angular/core'; +import monaco from 'monaco-editor'; import { ViewContainerRef } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MetisService } from 'app/shared/metis/metis.service'; @@ -122,6 +123,41 @@ export class PostingMarkdownEditorComponent implements OnInit, ControlValueAcces ngAfterViewInit(): void { this.markdownEditor.enableTextFieldMode(); + + const editor = this.markdownEditor.monacoEditor; + if (editor) { + editor.onDidChangeModelContent((event: monaco.editor.IModelContentChangedEvent) => { + const position = editor.getPosition(); + if (!position) { + return; + } + + const model = editor.getModel(); + if (!model) { + return; + } + + const lineContent = model.getLineContent(position.lineNumber).trimStart(); + const hasPrefix = lineContent.startsWith('- ') || /^\s*1\. /.test(lineContent); + if (hasPrefix && event.changes.length === 1 && (event.changes[0].text.startsWith('- ') || event.changes[0].text.startsWith('1. '))) { + return; + } + + if (hasPrefix) { + this.handleKeyDown(model, position.lineNumber); + } + }); + } + } + + private handleKeyDown(model: monaco.editor.ITextModel, lineNumber: number): void { + const lineContent = model.getLineContent(lineNumber).trimStart(); + + if (lineContent.startsWith('- ')) { + this.markdownEditor.handleActionClick(new MouseEvent('click'), this.defaultActions.find((action) => action instanceof BulletedListAction)!); + } else if (/^\d+\. /.test(lineContent)) { + this.markdownEditor.handleActionClick(new MouseEvent('click'), this.defaultActions.find((action) => action instanceof OrderedListAction)!); + } } /** diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/bulleted-list.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/bulleted-list.action.ts index d7bdd9aa1d1e..508f03649209 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/bulleted-list.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/bulleted-list.action.ts @@ -1,7 +1,7 @@ import { faListUl } from '@fortawesome/free-solid-svg-icons'; import { ListAction } from './list.action'; -const BULLET_PREFIX = '• '; +const BULLET_PREFIX = '- '; /** * Action used to add or modify a bullet-point list in the text editor. diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/list.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/list.action.ts index 659f4bed285c..5f404a25535e 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/list.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/list.action.ts @@ -37,7 +37,7 @@ export abstract class ListAction extends TextEditorAction { */ protected stripAnyListPrefix(line: string): string { const numberedListRegex = /^\s*\d+\.\s+/; - const bulletListRegex = /^\s*[-*+•]\s+/; + const bulletListRegex = /^\s*[-*+]\s+/; if (numberedListRegex.test(line)) { return line.replace(numberedListRegex, ''); @@ -91,10 +91,13 @@ export abstract class ListAction extends TextEditorAction { } if (position.getColumn() === currentLineText.length + 1) { - const lineWithoutPrefix = this.stripAnyListPrefix(currentLineText); const newPrefix = this.getPrefix(1); - const updatedLine = currentLineText.startsWith(newPrefix) ? lineWithoutPrefix : newPrefix + lineWithoutPrefix; + if (currentLineText.startsWith(newPrefix)) { + return; + } + + const updatedLine = newPrefix + currentLineText; editor.replaceTextAtRange( new TextEditorRange(new TextEditorPosition(position.getLineNumber(), 1), new TextEditorPosition(position.getLineNumber(), currentLineText.length + 1)), @@ -110,7 +113,7 @@ export abstract class ListAction extends TextEditorAction { // Determine if all lines have the current prefix let allLinesHaveCurrentPrefix; - if (this.getPrefix(1) != '• ') { + if (this.getPrefix(1) != '- ') { const numberedListRegex = /^\s*\d+\.\s+/; allLinesHaveCurrentPrefix = lines.every((line) => numberedListRegex.test(line)); } else { @@ -124,7 +127,7 @@ export abstract class ListAction extends TextEditorAction { const linesWithoutPrefix = lines.map((line) => this.stripAnyListPrefix(line)); updatedLines = linesWithoutPrefix.map((line, index) => { - const prefix = this.getPrefix(index) != '• ' ? this.getPrefix(index + 1) : this.getPrefix(startLineNumber + index); + const prefix = this.getPrefix(index) != '- ' ? this.getPrefix(index + 1) : this.getPrefix(startLineNumber + index); return prefix + line; }); } @@ -141,7 +144,7 @@ export abstract class ListAction extends TextEditorAction { */ protected hasPrefix(line: string): boolean { const numberedListRegex = /^\s*\d+\.\s+/; - const bulletListRegex = /^\s*[•\-*+]\s+/; + const bulletListRegex = /^\s*[-\-*+]\s+/; return numberedListRegex.test(line) || bulletListRegex.test(line); } @@ -162,9 +165,9 @@ export abstract class ListAction extends TextEditorAction { if (isNumbered) { const match = currentLineText.match(/^\s*(\d+)\.\s+/); const currentNumber = match ? parseInt(match[1], 10) : 0; - nextLinePrefix = `${currentNumber + 1}. `; + nextLinePrefix = `${currentNumber + 1}. `; } else { - nextLinePrefix = '• '; + nextLinePrefix = '- '; } } @@ -187,7 +190,7 @@ export abstract class ListAction extends TextEditorAction { if (position) { const lineNumber = position.getLineNumber(); const lineContent = editor.getLineText(lineNumber); - const linePrefixMatch = lineContent.match(/^\s*(\d+\.\s+|[-*+•]\s+)/); + const linePrefixMatch = lineContent.match(/^\s*(\d+\.\s+|[-*+]\s+)/); if (linePrefixMatch) { const prefixLength = linePrefixMatch[0].length; diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/ordered-list.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/ordered-list.action.ts index 06930260c14b..44bd5ea73027 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/ordered-list.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/ordered-list.action.ts @@ -12,7 +12,7 @@ export class OrderedListAction extends ListAction { } public getPrefix(lineNumber: number): string { - const space = lineNumber >= 10 ? ' ' : ' '; + const space = ' '; return `${lineNumber}.${space}`; } diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts index 73bb9f68f931..288f53cc9c07 100644 --- a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts @@ -118,6 +118,19 @@ export class MonacoEditorComponent implements OnInit, OnDestroy { return convertedWords.join(' '); } + public onDidChangeModelContent(listener: (event: monaco.editor.IModelContentChangedEvent) => void): monaco.IDisposable { + return this._editor.onDidChangeModelContent(listener); + } + + public getModel() { + return this._editor.getModel(); + } + + public getLineContent(lineNumber: number): string { + const model = this._editor.getModel(); + return model ? model.getLineContent(lineNumber) : ''; + } + ngOnInit(): void { const resizeObserver = new ResizeObserver(() => { this._editor.layout(); diff --git a/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts b/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts index 245f54d59a62..5a64278b8145 100644 --- a/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts @@ -270,4 +270,50 @@ describe('PostingContentPartComponent', () => { expect(outputEmitter).not.toHaveBeenCalled(); }); }); + + describe('Content processing', () => { + it('should process content before and after reference with escaped numbered and unordered lists', () => { + const contentBefore = '1. This is a numbered list\n2. Another item\n- This is an unordered list'; + const contentAfter = '1. Numbered again\n- Unordered again'; + component.postingContentPart = { + contentBeforeReference: contentBefore, + contentAfterReference: contentAfter, + linkToReference: undefined, + queryParams: undefined, + referenceStr: undefined, + } as PostingContentPart; + fixture.detectChanges(); + + component.processContent(); + + expect(component.processedContentBeforeReference).toBe('1\\. This is a numbered list\n2\\. Another item\n\\- This is an unordered list'); + expect(component.processedContentAfterReference).toBe('1\\. Numbered again\n\\- Unordered again'); + }); + + it('should escape numbered lists correctly', () => { + const content = '1. First item\n2. Second item\n3. Third item'; + const escapedContent = component.escapeNumberedList(content); + expect(escapedContent).toBe('1\\. First item\n2\\. Second item\n3\\. Third item'); + }); + + it('should escape unordered lists correctly', () => { + const content = '- First item\n- Second item\n- Third item'; + const escapedContent = component.escapeUnorderedList(content); + expect(escapedContent).toBe('\\- First item\n\\- Second item\n\\- Third item'); + }); + + it('should not escape text without numbered or unordered lists', () => { + const content = 'This is just a paragraph.\nAnother paragraph.'; + const escapedNumbered = component.escapeNumberedList(content); + const escapedUnordered = component.escapeUnorderedList(content); + expect(escapedNumbered).toBe(content); + expect(escapedUnordered).toBe(content); + }); + + it('should handle mixed numbered and unordered lists in content', () => { + const content = '1. Numbered item\n- Unordered item\n2. Another numbered item\n- Another unordered item'; + const escapedContent = component.escapeNumberedList(component.escapeUnorderedList(content)); + expect(escapedContent).toBe('1\\. Numbered item\n\\- Unordered item\n2\\. Another numbered item\n\\- Another unordered item'); + }); + }); }); diff --git a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts index 10a6a49276f9..80ef5cf80a23 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts @@ -37,6 +37,7 @@ import { TextEditorPosition } from 'app/shared/monaco-editor/model/actions/adapt import { BulletedListAction } from 'app/shared/monaco-editor/model/actions/bulleted-list.action'; import { OrderedListAction } from 'app/shared/monaco-editor/model/actions/ordered-list.action'; import { ListAction } from '../../../../../../../main/webapp/app/shared/monaco-editor/model/actions/list.action'; +import monaco from 'monaco-editor'; describe('PostingsMarkdownEditor', () => { let component: PostingMarkdownEditorComponent; @@ -124,6 +125,7 @@ describe('PostingsMarkdownEditor', () => { MockProvider(ChannelService), { provide: Overlay, useValue: mockOverlay }, { provide: OverlayPositionBuilder, useValue: overlayPositionBuilderMock }, + { provide: MarkdownEditorMonacoComponent, useValue: mockMarkdownEditorComponent }, ], declarations: [PostingMarkdownEditorComponent, MockComponent(MarkdownEditorMonacoComponent)], }) @@ -361,15 +363,15 @@ describe('PostingsMarkdownEditor', () => { getLineNumber: () => 1, getColumn: () => 6, } as TextEditorPosition); - mockEditor.getLineText.mockReturnValue('• First line'); + mockEditor.getLineText.mockReturnValue('- First line'); bulletedListAction.run(mockEditor); const { preventDefaultSpy } = simulateKeydownEvent(mockEditor, 'Enter', { shiftKey: true }); expect(preventDefaultSpy).toHaveBeenCalled(); - expect(mockEditor.replaceTextAtRange).toHaveBeenCalledWith(expect.any(TextEditorRange), '\n• '); - expect(mockEditor.setPosition).toHaveBeenCalledWith(new TextEditorPosition(2, 4)); + expect(mockEditor.replaceTextAtRange).toHaveBeenCalledWith(expect.any(TextEditorRange), '\n- '); + expect(mockEditor.setPosition).toHaveBeenCalledWith(new TextEditorPosition(2, 3)); }); it('should handle Cmd+Enter correctly without inserting double line breaks', () => { @@ -379,7 +381,7 @@ describe('PostingsMarkdownEditor', () => { getLineNumber: () => 1, getColumn: () => 6, } as TextEditorPosition); - mockEditor.getLineText.mockReturnValue('• First line'); + mockEditor.getLineText.mockReturnValue('- First line'); bulletedListAction.run(mockEditor); @@ -387,8 +389,8 @@ describe('PostingsMarkdownEditor', () => { expect(preventDefaultSpy).toHaveBeenCalled(); expect(stopPropagationSpy).toHaveBeenCalled(); - expect(mockEditor.replaceTextAtRange).toHaveBeenCalledWith(expect.any(TextEditorRange), '\n• '); - expect(mockEditor.setPosition).toHaveBeenCalledWith(new TextEditorPosition(2, 4)); + expect(mockEditor.replaceTextAtRange).toHaveBeenCalledWith(expect.any(TextEditorRange), '\n- '); + expect(mockEditor.setPosition).toHaveBeenCalledWith(new TextEditorPosition(2, 3)); }); const simulateListAction = (action: TextEditorAction, selectedText: string, expectedText: string, startLineNumber: number = 1) => { @@ -437,14 +439,14 @@ describe('PostingsMarkdownEditor', () => { it('should add bulleted list prefixes correctly', () => { const bulletedListAction = component.defaultActions.find((action: any) => action instanceof BulletedListAction) as BulletedListAction; const selectedText = `First line\nSecond line\nThird line`; - const expectedText = `• First line\n• Second line\n• Third line`; + const expectedText = `- First line\n- Second line\n- Third line`; simulateListAction(bulletedListAction, selectedText, expectedText); }); it('should remove bulleted list prefixes correctly when toggled', () => { const bulletedListAction = component.defaultActions.find((action: any) => action instanceof BulletedListAction) as BulletedListAction; - const selectedText = `• First line\n• Second line\n• Third line`; + const selectedText = `- First line\n- Second line\n- Third line`; const expectedText = `First line\nSecond line\nThird line`; simulateListAction(bulletedListAction, selectedText, expectedText); @@ -453,7 +455,7 @@ describe('PostingsMarkdownEditor', () => { it('should add ordered list prefixes correctly starting from 1', () => { const orderedListAction = component.defaultActions.find((action: any) => action instanceof OrderedListAction) as OrderedListAction; const selectedText = `First line\nSecond line\nThird line`; - const expectedText = `1. First line\n2. Second line\n3. Third line`; + const expectedText = `1. First line\n2. Second line\n3. Third line`; simulateListAction(orderedListAction, selectedText, expectedText); }); @@ -469,8 +471,8 @@ describe('PostingsMarkdownEditor', () => { it('should switch from bulleted list to ordered list correctly', () => { const bulletedListAction = component.defaultActions.find((action: any) => action instanceof BulletedListAction) as BulletedListAction; const orderedListAction = component.defaultActions.find((action: any) => action instanceof OrderedListAction) as OrderedListAction; - const bulletedText = `• First line\n• Second line\n• Third line`; - const expectedOrderedText = `1. First line\n2. Second line\n3. Third line`; + const bulletedText = `- First line\n- Second line\n- Third line`; + const expectedOrderedText = `1. First line\n2. Second line\n3. Third line`; simulateListAction(bulletedListAction, bulletedText, `First line\nSecond line\nThird line`); @@ -482,7 +484,7 @@ describe('PostingsMarkdownEditor', () => { const orderedListAction = component.defaultActions.find((action: any) => action instanceof OrderedListAction) as OrderedListAction; const bulletedListAction = component.defaultActions.find((action: any) => action instanceof BulletedListAction) as BulletedListAction; const orderedText = `1. First line\n2. Second line\n3. Third line`; - const expectedBulletedText = `• First line\n• Second line\n• Third line`; + const expectedBulletedText = `- First line\n- Second line\n- Third line`; simulateListAction(orderedListAction, orderedText, `First line\nSecond line\nThird line`); @@ -493,7 +495,7 @@ describe('PostingsMarkdownEditor', () => { it('should start ordered list numbering from 1 regardless of an inline list', () => { const orderedListAction = component.defaultActions.find((action: any) => action instanceof OrderedListAction) as OrderedListAction; const selectedText = `Some previous text\n1. First line\n2. Second line\n3. Third line`; - const expectedText = `1. Some previous text\n2. First line\n3. Second line\n4. Third line`; + const expectedText = `1. Some previous text\n2. First line\n3. Second line\n4. Third line`; simulateListAction(orderedListAction, selectedText, expectedText); }); @@ -502,8 +504,8 @@ describe('PostingsMarkdownEditor', () => { const bulletedListAction = component.defaultActions.find((action: any) => action instanceof BulletedListAction) as BulletedListAction; const orderedListAction = component.defaultActions.find((action: any) => action instanceof OrderedListAction) as OrderedListAction; - const bulletedText = `• First line\n• Second line\n• Third line`; - const expectedOrderedText = `1. First line\n2. Second line\n3. Third line`; + const bulletedText = `- First line\n- Second line\n- Third line`; + const expectedOrderedText = `1. First line\n2. Second line\n3. Third line`; simulateListAction(bulletedListAction, `First line\nSecond line\nThird line`, bulletedText); @@ -517,13 +519,13 @@ describe('PostingsMarkdownEditor', () => { const initialText = `First line\nSecond line\nThird line`; - const bulletedText = `• First line\n• Second line\n• Third line`; + const bulletedText = `- First line\n- Second line\n- Third line`; simulateListAction(bulletedListAction, initialText, bulletedText); mockEditor.replaceTextAtRange.mockClear(); simulateListAction(bulletedListAction, bulletedText, initialText); - const orderedText = `1. First line\n2. Second line\n3. Third line`; + const orderedText = `1. First line\n2. Second line\n3. Third line`; mockEditor.replaceTextAtRange.mockClear(); simulateListAction(orderedListAction, initialText, orderedText); @@ -537,14 +539,42 @@ describe('PostingsMarkdownEditor', () => { const initialText = `First line\nSecond line\nThird line`; - const bulletedText = `• First line\n• Second line\n• Third line`; + const bulletedText = `- First line\n- Second line\n- Third line`; simulateListAction(bulletedListAction, initialText, bulletedText); - const orderedText = `1. First line\n2. Second line\n3. Third line`; + const orderedText = `1. First line\n2. Second line\n3. Third line`; mockEditor.replaceTextAtRange.mockClear(); simulateListAction(orderedListAction, bulletedText, orderedText); mockEditor.replaceTextAtRange.mockClear(); simulateListAction(bulletedListAction, orderedText, bulletedText); }); + + it('should handle key down event and invoke the correct action', () => { + const bulletedListAction = new BulletedListAction(); + + component.defaultActions = [bulletedListAction]; + + const handleActionClickSpy = jest.spyOn(component.markdownEditor, 'handleActionClick'); + + const mockModel = { + getLineContent: jest.fn().mockReturnValue('- List item'), + } as unknown as monaco.editor.ITextModel; + const mockPosition = { lineNumber: 1 } as monaco.Position; + + (component as any).handleKeyDown(mockModel, mockPosition.lineNumber); + + expect(handleActionClickSpy).toHaveBeenCalledWith(expect.any(MouseEvent), bulletedListAction); + }); + + it('should handle invalid line content gracefully', () => { + const mockModel = { + getLineContent: jest.fn().mockReturnValue(''), + } as unknown as monaco.editor.ITextModel; + const mockPosition = { lineNumber: 1 } as monaco.Position; + const handleActionClickSpy = jest.spyOn(component.markdownEditor, 'handleActionClick'); + + (component as any).handleKeyDown(mockModel, mockPosition.lineNumber); + expect(handleActionClickSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts index 2de7e4afcf89..4551f36513f9 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts @@ -138,7 +138,7 @@ describe('MonacoEditorActionIntegration', () => { const action = new OrderedListAction(); comp.registerAction(action); action.executeInCurrentEditor(); - expect(comp.getText()).toBe('1. '); + expect(comp.getText()).toBe('1. '); }); it.each([1, 2, 3])('Should toggle heading %i on selected line', (headingLevel) => { diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts index 244e9004ac43..462ba750f969 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts @@ -289,4 +289,42 @@ describe('MonacoEditorComponent', () => { comp.setText(originalText); expect(comp.getText()).toBe(originalText); }); + + it('should register a listener for model content changes', () => { + const listenerStub = jest.fn(); + fixture.detectChanges(); + const disposable = comp.onDidChangeModelContent(listenerStub); + comp.setText(singleLineText); + expect(listenerStub).toHaveBeenCalled(); + disposable.dispose(); + }); + + it('should retrieve the editor model', () => { + fixture.detectChanges(); + comp.setText(singleLineText); + const model = comp.getModel(); + expect(model).not.toBeNull(); + expect(model?.getValue()).toBe(singleLineText); + }); + + it('should get the content of a specific line', () => { + fixture.detectChanges(); + comp.setText(multiLineText); + const lineContent = comp.getLineContent(2); + expect(lineContent).toBe('static void main() {'); + }); + + it('should handle invalid line numbers in getLineContent', () => { + fixture.detectChanges(); + comp.setText(multiLineText); + + // Invalid line numbers + expect(() => comp.getLineContent(0)).toThrow(); + expect(() => comp.getLineContent(-1)).toThrow(); + expect(() => comp.getLineContent(999)).toThrow(); + + // Empty line + comp.setText('line1\n\nline3'); + expect(comp.getLineContent(2)).toBe(''); + }); }); From 3b8d5f1062f678a0f503010c0827d6bbcc052027 Mon Sep 17 00:00:00 2001 From: Mohamed Bilel Besrour <58034472+BBesrour@users.noreply.github.com> Date: Tue, 3 Dec 2024 21:29:20 +0100 Subject: [PATCH 02/36] Integrated code lifecycle: Provide Instructors more options to control container configuration (#9487) --- .../exercises/programming-exercise-setup.inc | 26 ++++- .../programming/docker-flags-edit.png | Bin 0 -> 45795 bytes .../artemis/buildagent/dto/BuildConfig.java | 3 +- .../buildagent/dto/DockerFlagsDTO.java | 6 ++ .../buildagent/dto/DockerRunConfig.java | 7 ++ .../service/BuildJobContainerService.java | 35 ++++--- .../service/BuildJobExecutionService.java | 16 +++- .../aet/artemis/core/config/Constants.java | 2 + .../ProgrammingExerciseBuildConfig.java | 2 +- ...ProgrammingExerciseBuildConfigService.java | 90 ++++++++++++++++++ .../service/ProgrammingExerciseService.java | 46 ++++++++- .../localci/LocalCIQueueWebsocketService.java | 2 +- .../localci/LocalCITriggerService.java | 12 ++- ...ogrammingExerciseExportImportResource.java | 7 +- .../web/ProgrammingExerciseResource.java | 3 + .../changelog/20241022120000_changelog.xml | 8 ++ .../resources/config/liquibase/master.xml | 1 + ...xercise-build-configuration.component.html | 52 +++++++++- ...-exercise-build-configuration.component.ts | 88 ++++++++++++++++- ...-exercise-custom-build-plan.component.html | 1 + src/main/webapp/i18n/de/error.json | 5 +- .../webapp/i18n/de/programmingExercise.json | 13 +++ src/main/webapp/i18n/en/error.json | 5 +- .../webapp/i18n/en/programmingExercise.json | 13 +++ .../service/BuildAgentDockerServiceTest.java | 4 +- .../icl/LocalCIIntegrationTest.java | 14 +++ .../icl/LocalCIResourceIntegrationTest.java | 4 +- .../programming/icl/LocalCIServiceTest.java | 2 +- .../icl/LocalVCLocalCIIntegrationTest.java | 35 ++++++- .../icl/TestBuildAgentConfiguration.java | 7 ++ ...cise-build-configuration.component.spec.ts | 59 ++++++++++++ 31 files changed, 530 insertions(+), 38 deletions(-) create mode 100644 docs/user/exercises/programming/docker-flags-edit.png create mode 100644 src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerFlagsDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerRunConfig.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseBuildConfigService.java create mode 100644 src/main/resources/config/liquibase/changelog/20241022120000_changelog.xml diff --git a/docs/user/exercises/programming-exercise-setup.inc b/docs/user/exercises/programming-exercise-setup.inc index 8a2dc7cf9f78..563f65f48361 100644 --- a/docs/user/exercises/programming-exercise-setup.inc +++ b/docs/user/exercises/programming-exercise-setup.inc @@ -404,7 +404,8 @@ Edit Maximum Build Duration ^^^^^^^^^^^^^^^^^^^^^^^^^^^ **This option is only available when using** :ref:`integrated code lifecycle` -This section is optional. In most cases, the preconfigured build script does not need to be changed. + +This section is optional. In most cases, the default maximum build duration does not need to be changed. The maximum build duration is the time limit for the build plan to execute. If the build plan exceeds this time limit, it will be terminated. The default value is 120 seconds. You can change the maximum build duration by using the slider. @@ -412,6 +413,29 @@ You can change the maximum build duration by using the slider. .. figure:: programming/timeout-slider.png :align: center +Edit Container Configuration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**This option is only available when using** :ref:`integrated code lifecycle` + +This section is optional. In most cases, the default container configuration does not need to be changed. + +Currently, instructors can only change whether the container has internet access and add additional environment variables. +Disabling internet access can be useful if instructors want to prevent students from downloading additional dependencies during the build process. +If internet access is disabled, the container cannot access the internet during the build process. Thus, it will not be able to download additional dependencies. +The dependencies must then be included/cached in the docker image. + +Additional environment variables can be added to the container configuration. This can be useful if the build process requires additional environment variables to be set. + +.. figure:: programming/docker-flags-edit.png + :align: center + +We plan to add more options to the container configuration in the future. + +.. warning:: + - Disabling internet access is not currently supported for Swift and Haskell exercises. + + .. _configure_static_code_analysis_tools: Configure static code analysis diff --git a/docs/user/exercises/programming/docker-flags-edit.png b/docs/user/exercises/programming/docker-flags-edit.png new file mode 100644 index 0000000000000000000000000000000000000000..06a030f69f18ff2224cb458fe80d0eb2cacd413a GIT binary patch literal 45795 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 JzVYXi{{bP)$Swc? literal 0 HcmV?d00001 diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java index 9a41fc6fdc20..34d139aa6f19 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildConfig.java @@ -15,7 +15,8 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record BuildConfig(String buildScript, String dockerImage, String commitHashToBuild, String assignmentCommitHash, String testCommitHash, String branch, ProgrammingLanguage programmingLanguage, ProjectType projectType, boolean scaEnabled, boolean sequentialTestRunsEnabled, boolean testwiseCoverageEnabled, - List resultPaths, int timeoutSeconds, String assignmentCheckoutPath, String testCheckoutPath, String solutionCheckoutPath) implements Serializable { + List resultPaths, int timeoutSeconds, String assignmentCheckoutPath, String testCheckoutPath, String solutionCheckoutPath, DockerRunConfig dockerRunConfig) + implements Serializable { @Override public String dockerImage() { diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerFlagsDTO.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerFlagsDTO.java new file mode 100644 index 000000000000..bb10c5ddf313 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerFlagsDTO.java @@ -0,0 +1,6 @@ +package de.tum.cit.aet.artemis.buildagent.dto; + +import java.util.Map; + +public record DockerFlagsDTO(String network, Map env) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerRunConfig.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerRunConfig.java new file mode 100644 index 000000000000..2b45273e13fd --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/DockerRunConfig.java @@ -0,0 +1,7 @@ +package de.tum.cit.aet.artemis.buildagent.dto; + +import java.io.Serializable; +import java.util.List; + +public record DockerRunConfig(boolean isNetworkDisabled, List env) implements Serializable { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java index cfe5a1ab01e3..b68cc7a0c001 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java @@ -85,12 +85,13 @@ public BuildJobContainerService(DockerClient dockerClient, HostConfig hostConfig /** * Configure a container with the Docker image, the container name, optional proxy config variables, and set the command that runs when the container starts. * - * @param containerName the name of the container to be created - * @param image the Docker image to use for the container - * @param buildScript the build script to be executed in the container + * @param containerName the name of the container to be created + * @param image the Docker image to use for the container + * @param buildScript the build script to be executed in the container + * @param exerciseEnvVars the environment variables provided by the instructor * @return {@link CreateContainerResponse} that can be used to start the container */ - public CreateContainerResponse configureContainer(String containerName, String image, String buildScript) { + public CreateContainerResponse configureContainer(String containerName, String image, String buildScript, List exerciseEnvVars) { List envVars = new ArrayList<>(); if (useSystemProxy) { envVars.add("HTTP_PROXY=" + httpProxy); @@ -98,6 +99,9 @@ public CreateContainerResponse configureContainer(String containerName, String i envVars.add("NO_PROXY=" + noProxy); } envVars.add("SCRIPT=" + buildScript); + if (exerciseEnvVars != null && !exerciseEnvVars.isEmpty()) { + envVars.addAll(exerciseEnvVars); + } return dockerClient.createContainerCmd(image).withName(containerName).withHostConfig(hostConfig).withEnv(envVars) // Command to run when the container starts. This is the command that will be executed in the container's main process, which runs in the foreground and blocks the // container from exiting until it finishes. @@ -121,11 +125,23 @@ public void startContainer(String containerId) { /** * Run the script in the container and wait for it to finish before returning. * - * @param containerId the id of the container in which the script should be run - * @param buildJobId the id of the build job that is currently being executed + * @param containerId the id of the container in which the script should be run + * @param buildJobId the id of the build job that is currently being executed + * @param isNetworkDisabled whether the network should be disabled for the container */ + public void runScriptInContainer(String containerId, String buildJobId, boolean isNetworkDisabled) { + if (isNetworkDisabled) { + log.info("disconnecting container with id {} from network", containerId); + try { + dockerClient.disconnectFromNetworkCmd().withContainerId(containerId).withNetworkId("bridge").exec(); + } + catch (Exception e) { + log.error("Failed to disconnect container with id {} from network: {}", containerId, e.getMessage()); + buildLogsMap.appendBuildLogEntry(buildJobId, "Failed to disconnect container from default network 'bridge': " + e.getMessage()); + throw new LocalCIException("Failed to disconnect container from default network 'bridge': " + e.getMessage()); + } + } - public void runScriptInContainer(String containerId, String buildJobId) { log.info("Started running the build script for build job in container with id {}", containerId); // The "sh script.sh" execution command specified here is run inside the container as an additional process. This command runs in the background, independent of the // container's @@ -448,9 +464,4 @@ private Container getContainerForName(String containerName) { List containers = dockerClient.listContainersCmd().withShowAll(true).exec(); return containers.stream().filter(container -> container.getNames()[0].equals("/" + containerName)).findFirst().orElse(null); } - - private String getParentFolderPath(String path) { - Path parentPath = Paths.get(path).normalize().getParent(); - return parentPath != null ? parentPath.toString() : ""; - } } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java index 7c789cfafb28..c5e042b7f20e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobExecutionService.java @@ -232,10 +232,18 @@ public BuildResult runBuildJob(BuildJobQueueItem buildJob, String containerName) index++; } - CreateContainerResponse container = buildJobContainerService.configureContainer(containerName, buildJob.buildConfig().dockerImage(), buildJob.buildConfig().buildScript()); + List envVars = null; + boolean isNetworkDisabled = false; + if (buildJob.buildConfig().dockerRunConfig() != null) { + envVars = buildJob.buildConfig().dockerRunConfig().env(); + isNetworkDisabled = buildJob.buildConfig().dockerRunConfig().isNetworkDisabled(); + } + + CreateContainerResponse container = buildJobContainerService.configureContainer(containerName, buildJob.buildConfig().dockerImage(), buildJob.buildConfig().buildScript(), + envVars); return runScriptAndParseResults(buildJob, containerName, container.getId(), assignmentRepoUri, testsRepoUri, solutionRepoUri, auxiliaryRepositoriesUris, - assignmentRepositoryPath, testsRepositoryPath, solutionRepositoryPath, auxiliaryRepositoriesPaths, assignmentCommitHash, testCommitHash); + assignmentRepositoryPath, testsRepositoryPath, solutionRepositoryPath, auxiliaryRepositoriesPaths, assignmentCommitHash, testCommitHash, isNetworkDisabled); } /** @@ -270,7 +278,7 @@ public BuildResult runBuildJob(BuildJobQueueItem buildJob, String containerName) private BuildResult runScriptAndParseResults(BuildJobQueueItem buildJob, String containerName, String containerId, VcsRepositoryUri assignmentRepositoryUri, VcsRepositoryUri testRepositoryUri, VcsRepositoryUri solutionRepositoryUri, VcsRepositoryUri[] auxiliaryRepositoriesUris, Path assignmentRepositoryPath, Path testsRepositoryPath, Path solutionRepositoryPath, Path[] auxiliaryRepositoriesPaths, @Nullable String assignmentRepoCommitHash, - @Nullable String testRepoCommitHash) { + @Nullable String testRepoCommitHash, boolean isNetworkDisabled) { long timeNanoStart = System.nanoTime(); @@ -292,7 +300,7 @@ private BuildResult runScriptAndParseResults(BuildJobQueueItem buildJob, String buildLogsMap.appendBuildLogEntry(buildJob.id(), msg); log.debug(msg); - buildJobContainerService.runScriptInContainer(containerId, buildJob.id()); + buildJobContainerService.runScriptInContainer(containerId, buildJob.id(), isNetworkDisabled); msg = "~~~~~~~~~~~~~~~~~~~~ Finished Executing Build Script for Build job " + buildJob.id() + " ~~~~~~~~~~~~~~~~~~~~"; buildLogsMap.appendBuildLogEntry(buildJob.id(), msg); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java index 6ddd70dad841..1f3ae2a0ae8b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java @@ -39,6 +39,8 @@ public final class Constants { public static final int QUIZ_GRACE_PERIOD_IN_SECONDS = 5; + public static final int MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH = 1000; + /** * This constant determines how many seconds after the exercise due dates submissions will still be considered rated. * Submissions after the grace period exceeded will be flagged as illegal. diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java index a5ada6708999..d28e21bb3ad1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildConfig.java @@ -60,7 +60,7 @@ public class ProgrammingExerciseBuildConfig extends DomainObject { @Column(name = "timeout_seconds") private int timeoutSeconds; - @Column(name = "docker_flags") + @Column(name = "docker_flags", columnDefinition = "longtext") private String dockerFlags; @OneToOne(mappedBy = "buildConfig") diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseBuildConfigService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseBuildConfigService.java new file mode 100644 index 000000000000..5ccf7f2045a6 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseBuildConfigService.java @@ -0,0 +1,90 @@ +package de.tum.cit.aet.artemis.programming.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import jakarta.annotation.Nullable; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.tum.cit.aet.artemis.buildagent.dto.DockerFlagsDTO; +import de.tum.cit.aet.artemis.buildagent.dto.DockerRunConfig; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildConfig; + +@Profile(PROFILE_CORE) +@Service +public class ProgrammingExerciseBuildConfigService { + + private static final Logger log = org.slf4j.LoggerFactory.getLogger(ProgrammingExerciseBuildConfigService.class); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Converts a JSON string representing Docker flags (in JSON format) + * into a {@link DockerRunConfig} instance. + * + *

+ * The JSON string is expected to represent a {@link DockerFlagsDTO} object. + * Example JSON input: + * + *

+     * {"network":"none","env":{"key1":"value1","key2":"value2"}}
+     * 
+ * + * @param buildConfig the build config containing the Docker flags + * @return a {@link DockerRunConfig} object initialized with the parsed flags, or {@code null} if the JSON string is empty + */ + @Nullable + public DockerRunConfig getDockerRunConfig(ProgrammingExerciseBuildConfig buildConfig) { + DockerFlagsDTO dockerFlagsDTO = parseDockerFlags(buildConfig); + + return getDockerRunConfigFromParsedFlags(dockerFlagsDTO); + } + + DockerRunConfig getDockerRunConfigFromParsedFlags(DockerFlagsDTO dockerFlagsDTO) { + if (dockerFlagsDTO == null) { + return null; + } + List env = new ArrayList<>(); + boolean isNetworkDisabled = dockerFlagsDTO.network() != null && dockerFlagsDTO.network().equals("none"); + + if (dockerFlagsDTO.env() != null) { + for (Map.Entry entry : dockerFlagsDTO.env().entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + env.add(key + "=" + value); + } + } + + return new DockerRunConfig(isNetworkDisabled, env); + } + + /** + * Parses the JSON string representing Docker flags into DockerFlagsDTO. (see {@link DockerFlagsDTO}) + * + * @return a list of key-value pairs, or {@code null} if the JSON string is empty + * @throws IllegalArgumentException if the JSON string is invalid + */ + @Nullable + DockerFlagsDTO parseDockerFlags(ProgrammingExerciseBuildConfig buildConfig) { + if (StringUtils.isBlank(buildConfig.getDockerFlags())) { + return null; + } + + try { + return objectMapper.readValue(buildConfig.getDockerFlags(), DockerFlagsDTO.class); + } + catch (Exception e) { + log.error("Failed to parse DockerRunConfig from JSON string: {}. Using default settings.", buildConfig.getDockerFlags()); + throw new IllegalArgumentException("Failed to parse DockerRunConfig from JSON string: " + buildConfig.getDockerFlags(), e); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java index 20b546ad4a48..c60fbc5b9d34 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.programming.service; import static de.tum.cit.aet.artemis.core.config.Constants.ALLOWED_CHECKOUT_DIRECTORY; +import static de.tum.cit.aet.artemis.core.config.Constants.MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import static de.tum.cit.aet.artemis.programming.domain.build.BuildPlanType.SOLUTION; import static de.tum.cit.aet.artemis.programming.domain.build.BuildPlanType.TEMPLATE; @@ -46,6 +47,8 @@ import de.tum.cit.aet.artemis.assessment.repository.ResultRepository; import de.tum.cit.aet.artemis.atlas.api.CompetencyProgressApi; +import de.tum.cit.aet.artemis.buildagent.dto.DockerFlagsDTO; +import de.tum.cit.aet.artemis.buildagent.dto.DockerRunConfig; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationScheduleService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -185,6 +188,8 @@ public class ProgrammingExerciseService { private final CompetencyProgressApi competencyProgressApi; + private final ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService; + public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerciseRepository, GitService gitService, Optional versionControlService, Optional continuousIntegrationService, Optional continuousIntegrationTriggerService, TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository, @@ -199,7 +204,8 @@ public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerc ProgrammingSubmissionService programmingSubmissionService, Optional irisSettingsService, Optional aeolusTemplateService, Optional buildScriptGenerationService, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProfileService profileService, ExerciseService exerciseService, - ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, CompetencyProgressApi competencyProgressApi) { + ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, CompetencyProgressApi competencyProgressApi, + ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService) { this.programmingExerciseRepository = programmingExerciseRepository; this.gitService = gitService; this.versionControlService = versionControlService; @@ -233,6 +239,7 @@ public ProgrammingExerciseService(ProgrammingExerciseRepository programmingExerc this.exerciseService = exerciseService; this.programmingExerciseBuildConfigRepository = programmingExerciseBuildConfigRepository; this.competencyProgressApi = competencyProgressApi; + this.programmingExerciseBuildConfigService = programmingExerciseBuildConfigService; } /** @@ -372,6 +379,7 @@ public void validateNewProgrammingExerciseSettings(ProgrammingExercise programmi programmingExercise.validateProgrammingSettings(); programmingExercise.validateSettingsForFeedbackRequest(); validateCustomCheckoutPaths(programmingExercise); + validateDockerFlags(programmingExercise); auxiliaryRepositoryService.validateAndAddAuxiliaryRepositoriesOfProgrammingExercise(programmingExercise, programmingExercise.getAuxiliaryRepositories()); submissionPolicyService.validateSubmissionPolicyCreation(programmingExercise); @@ -1072,4 +1080,40 @@ public ProgrammingExercise loadProgrammingExerciseWithAuxiliaryRepositories(long final Set fetchOptions = Set.of(AuxiliaryRepositories); return programmingExerciseRepository.findByIdWithDynamicFetchElseThrow(exerciseId, fetchOptions); } + + /** + * Validates the network access feature for the given programming language. + * Currently, SWIFT and HASKELL do not support disabling the network access feature. + * + * @param programmingExercise the programming exercise to validate + */ + public void validateDockerFlags(ProgrammingExercise programmingExercise) { + ProgrammingExerciseBuildConfig buildConfig = programmingExercise.getBuildConfig(); + DockerFlagsDTO dockerFlagsDTO; + try { + dockerFlagsDTO = programmingExerciseBuildConfigService.parseDockerFlags(buildConfig); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("Error while parsing the docker flags", "Exercise", "dockerFlagsParsingError"); + } + + if (dockerFlagsDTO == null) { + return; + } + + if (dockerFlagsDTO.env() != null) { + for (var entry : dockerFlagsDTO.env().entrySet()) { + if (entry.getKey().length() > MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH || entry.getValue().length() > MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH) { + throw new BadRequestAlertException("The environment variables are too long. Max " + MAX_ENVIRONMENT_VARIABLES_DOCKER_FLAG_LENGTH + " chars", "Exercise", + "envVariablesTooLong"); + } + } + } + + DockerRunConfig dockerRunConfig = programmingExerciseBuildConfigService.getDockerRunConfigFromParsedFlags(dockerFlagsDTO); + + if (List.of(ProgrammingLanguage.SWIFT, ProgrammingLanguage.HASKELL).contains(programmingExercise.getProgrammingLanguage()) && dockerRunConfig.isNetworkDisabled()) { + throw new BadRequestAlertException("This programming language does not support disabling the network access feature", "Exercise", "networkAccessNotSupported"); + } + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java index d25304141c24..0e081a93728b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java @@ -174,7 +174,7 @@ private static List removeUnnecessaryInformation(List versionControlService, SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, LocalCIBuildConfigurationService localCIBuildConfigurationService, GitService gitService, ExerciseDateService exerciseDateService, - ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, BuildScriptProviderService buildScriptProviderService) { + ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, BuildScriptProviderService buildScriptProviderService, + ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService) { this.hazelcastInstance = hazelcastInstance; this.aeolusTemplateService = aeolusTemplateService; this.programmingLanguageConfiguration = programmingLanguageConfiguration; @@ -119,6 +124,7 @@ public LocalCITriggerService(@Qualifier("hazelcastInstance") HazelcastInstance h this.programmingExerciseBuildConfigRepository = programmingExerciseBuildConfigRepository; this.exerciseDateService = exerciseDateService; this.buildScriptProviderService = buildScriptProviderService; + this.programmingExerciseBuildConfigService = programmingExerciseBuildConfigService; } @PostConstruct @@ -310,6 +316,8 @@ private BuildConfig getBuildConfig(ProgrammingExerciseParticipation participatio dockerImage = programmingLanguageConfiguration.getImage(programmingExercise.getProgrammingLanguage(), Optional.ofNullable(programmingExercise.getProjectType())); } + DockerRunConfig dockerRunConfig = programmingExerciseBuildConfigService.getDockerRunConfig(buildConfig); + List resultPaths = getTestResultPaths(windfile); resultPaths = buildScriptProviderService.replaceResultPathsPlaceholders(resultPaths, buildConfig); @@ -319,7 +327,7 @@ private BuildConfig getBuildConfig(ProgrammingExerciseParticipation participatio return new BuildConfig(buildScript, dockerImage, commitHashToBuild, assignmentCommitHash, testCommitHash, branch, programmingLanguage, projectType, staticCodeAnalysisEnabled, sequentialTestRunsEnabled, testwiseCoverageEnabled, resultPaths, buildConfig.getTimeoutSeconds(), - buildConfig.getAssignmentCheckoutPath(), buildConfig.getTestCheckoutPath(), buildConfig.getSolutionCheckoutPath()); + buildConfig.getAssignmentCheckoutPath(), buildConfig.getTestCheckoutPath(), buildConfig.getSolutionCheckoutPath(), dockerRunConfig); } private ProgrammingExerciseBuildConfig loadBuildConfig(ProgrammingExercise programmingExercise) { diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java index 748645568dc6..da74833808c0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java @@ -78,6 +78,7 @@ import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseExportService; import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseImportFromFileService; import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseImportService; +import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseService; import de.tum.cit.aet.artemis.programming.service.ProgrammingLanguageFeature; import de.tum.cit.aet.artemis.programming.service.ProgrammingLanguageFeatureService; import de.tum.cit.aet.artemis.programming.service.SubmissionPolicyService; @@ -130,13 +131,15 @@ public class ProgrammingExerciseExportImportResource { private final Optional athenaModuleService; + private final ProgrammingExerciseService programmingExerciseService; + public ProgrammingExerciseExportImportResource(ProgrammingExerciseRepository programmingExerciseRepository, UserRepository userRepository, AuthorizationCheckService authCheckService, CourseService courseService, ProgrammingExerciseImportService programmingExerciseImportService, ProgrammingExerciseExportService programmingExerciseExportService, Optional programmingLanguageFeatureService, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, SubmissionPolicyService submissionPolicyService, ProgrammingExerciseTaskRepository programmingExerciseTaskRepository, ExamAccessService examAccessService, CourseRepository courseRepository, ProgrammingExerciseImportFromFileService programmingExerciseImportFromFileService, ConsistencyCheckService consistencyCheckService, - Optional athenaModuleService, CompetencyProgressApi competencyProgressApi) { + Optional athenaModuleService, CompetencyProgressApi competencyProgressApi, ProgrammingExerciseService programmingExerciseService) { this.programmingExerciseRepository = programmingExerciseRepository; this.userRepository = userRepository; this.courseService = courseService; @@ -153,6 +156,7 @@ public ProgrammingExerciseExportImportResource(ProgrammingExerciseRepository pro this.consistencyCheckService = consistencyCheckService; this.athenaModuleService = athenaModuleService; this.competencyProgressApi = competencyProgressApi; + this.programmingExerciseService = programmingExerciseService; } /** @@ -199,6 +203,7 @@ public ResponseEntity importProgrammingExercise(@PathVariab newExercise.validateGeneralSettings(); newExercise.validateProgrammingSettings(); newExercise.validateSettingsForFeedbackRequest(); + programmingExerciseService.validateDockerFlags(newExercise); validateStaticCodeAnalysisSettings(newExercise); final User user = userRepository.getUserWithGroupsAndAuthorities(); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java index 0a39cabc4e06..0eaab82ce448 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java @@ -329,6 +329,9 @@ public ResponseEntity updateProgrammingExercise(@RequestBod // Verify that the checkout directories have not been changed. This is required since the buildScript and result paths are determined during the creation of the exercise. programmingExerciseService.validateCheckoutDirectoriesUnchanged(programmingExerciseBeforeUpdate, updatedProgrammingExercise); + // Verify that the programming language supports the selected network access option + programmingExerciseService.validateDockerFlags(updatedProgrammingExercise); + // Verify that a theia image is provided when the online IDE is enabled if (updatedProgrammingExercise.isAllowOnlineIde() && updatedProgrammingExercise.getBuildConfig().getTheiaImage() == null) { throw new BadRequestAlertException("You need to provide a Theia image when the online IDE is enabled", ENTITY_NAME, "noTheiaImageProvided"); diff --git a/src/main/resources/config/liquibase/changelog/20241022120000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241022120000_changelog.xml new file mode 100644 index 000000000000..193a6370c0ed --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241022120000_changelog.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index e8f58b18d024..132674deed7e 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -32,6 +32,7 @@ + diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-build-configuration/programming-exercise-build-configuration.component.html b/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-build-configuration/programming-exercise-build-configuration.component.html index 8460ca15d6fd..bb59ed44df0d 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-build-configuration/programming-exercise-build-configuration.component.html +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-build-configuration/programming-exercise-build-configuration.component.html @@ -16,7 +16,57 @@ /> @if (!isAeolus()) { -
+ @if (isLanguageSupported) { +
+ +
+ @if (isNetworkDisabled) { + + } + } + +
+ + + + + + + + + + + + + + + + + + + + +
+