From 3afd1911c05174d4b8b845d975324e00b7fc9ce0 Mon Sep 17 00:00:00 2001 From: Marc Bessa Hoffmann Date: Sat, 27 Jan 2024 22:43:41 +0100 Subject: [PATCH 01/65] Extend JIRA integration to fetch tests and their steps; steps are displayed in story descriptions. --- backend/src/serverRouter/userRouter.js | 35 ++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/backend/src/serverRouter/userRouter.js b/backend/src/serverRouter/userRouter.js index cb23f25ba..dc4c4068e 100644 --- a/backend/src/serverRouter/userRouter.js +++ b/backend/src/serverRouter/userRouter.js @@ -353,30 +353,55 @@ router.get('/stories', async (req, res) => { // put into ticketManagement.ts // send request to Jira API try { await fetch( - `http://${Host}/rest/api/2/search?jql=project=${projectKey}+AND+labels=Seed-Test&startAt=0&maxResults=200`, + `http://${Host}/rest/api/2/search?jql=project=${projectKey}+AND+(labels=Seed-Test+OR+issuetype=Test)&startAt=0&maxResults=200`, options ) .then(async (response) => response.json()) .then(async (json) => { try { repo = await mongo.getOneJiraRepository(req.query.projectKey); - for (const issue of json.issues) if (issue.fields.labels.includes('Seed-Test')) { + for (const issue of json.issues) { + let testStepDescription = ''; + if (issue.fields.issuetype.name === 'Test') { + try { + const testStepsResponse = await fetch(`http://${Host}/rest/raven/2.0/api/test/${issue.key}/steps`, options); + const testSteps = await testStepsResponse.json(); + console.log(testSteps); + const steps = testSteps.steps; + testStepDescription = '\n\nTest-Steps:\n\n'; + for (const step of steps) { + const fields = step.fields; + const stepInfo = [`Step ${step.index}:`]; + + stepInfo.push(fields.Given ? `(GIVEN): ${fields.Given.value}` : '(GIVEN): Not used'); + stepInfo.push(fields.Action && fields.Action.value.raw ? `(WHEN): ${fields.Action.value.raw} ---` : '(WHEN): Not used --- '); + stepInfo.push(fields.Data && fields.Data.value.raw ? `(DATA): ${fields.Data.value.raw} --- ` : '(DATA): Not used --- '); + stepInfo.push(fields['Expected Result'] && fields['Expected Result'].value.raw ? `(THEN): ${fields['Expected Result'].value.raw} --- ` : '(THEN): Not used --- '); + testStepDescription += stepInfo.join('\n'); + } + + } catch (e) { + console.log('Error while getting test steps', e); + } + } + const story = { story_id: issue.id, title: issue.fields.summary, - body: issue.fields.description, + body: issue.fields.description + testStepDescription, state: issue.fields.status.name, issue_number: issue.key, storySource: 'jira' }; + if (issue.fields.assignee !== null) { - // skip in case of "unassigned" story.assignee = issue.fields.assignee.name; story.assignee_avatar_url = issue.fields.assignee.avatarUrls['32x32']; } else { story.assignee = 'unassigned'; story.assignee_avatar_url = null; } + const entry = await projectMng.fuseStoryWithDb(story, issue.id); tmpStories.set(entry._id.toString(), entry); storiesArray.push(entry._id); @@ -384,6 +409,7 @@ router.get('/stories', async (req, res) => { // put into ticketManagement.ts } catch (e) { console.error('Error while getting Jira issues:', e); } + Promise.all(storiesArray) .then((array) => { const orderedStories = matchOrder(array, tmpStories, repo); @@ -397,7 +423,6 @@ router.get('/stories', async (req, res) => { // put into ticketManagement.ts } catch (e) { console.error('Jira Error during API call:', e); } - // get DB Repo / Projects } else if (source === 'db' && typeof req.user !== 'undefined' && req.query.repoName !== 'null') { const result = await mongo.getAllStoriesOfRepo(req.query.id); From d19792a66403bf0a2e392920577bece2fe79a158 Mon Sep 17 00:00:00 2001 From: Marc Bessa Hoffmann Date: Wed, 31 Jan 2024 16:52:57 +0100 Subject: [PATCH 02/65] Add css to show line breaks in storydescription --- backend/src/serverRouter/userRouter.js | 12 ++++++------ .../src/app/story-editor/story-editor.component.html | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/src/serverRouter/userRouter.js b/backend/src/serverRouter/userRouter.js index dc4c4068e..1347c7341 100644 --- a/backend/src/serverRouter/userRouter.js +++ b/backend/src/serverRouter/userRouter.js @@ -368,15 +368,15 @@ router.get('/stories', async (req, res) => { // put into ticketManagement.ts const testSteps = await testStepsResponse.json(); console.log(testSteps); const steps = testSteps.steps; - testStepDescription = '\n\nTest-Steps:\n\n'; + testStepDescription = '\n\nTest-Steps:\n'; for (const step of steps) { const fields = step.fields; - const stepInfo = [`Step ${step.index}:`]; + const stepInfo = [`\n----- Step ${step.index} -----`]; - stepInfo.push(fields.Given ? `(GIVEN): ${fields.Given.value}` : '(GIVEN): Not used'); - stepInfo.push(fields.Action && fields.Action.value.raw ? `(WHEN): ${fields.Action.value.raw} ---` : '(WHEN): Not used --- '); - stepInfo.push(fields.Data && fields.Data.value.raw ? `(DATA): ${fields.Data.value.raw} --- ` : '(DATA): Not used --- '); - stepInfo.push(fields['Expected Result'] && fields['Expected Result'].value.raw ? `(THEN): ${fields['Expected Result'].value.raw} --- ` : '(THEN): Not used --- '); + stepInfo.push(fields.Given ? `(GIVEN): ${fields.Given.value}\n` : '(GIVEN): Not used\n'); + stepInfo.push(fields.Action && fields.Action.value.raw ? `(WHEN): ${fields.Action.value.raw}\n` : '(WHEN): Not steps used\n'); + stepInfo.push(fields.Data && fields.Data.value.raw ? `(DATA): ${fields.Data.value.raw}\n` : '(DATA): No data used\n'); + stepInfo.push(fields['Expected Result'] && fields['Expected Result'].value.raw ? `(THEN): ${fields['Expected Result'].value.raw}\n` : '(THEN): No steps used\n'); testStepDescription += stepInfo.join('\n'); } diff --git a/frontend/src/app/story-editor/story-editor.component.html b/frontend/src/app/story-editor/story-editor.component.html index b88e896ce..1c953c5b4 100644 --- a/frontend/src/app/story-editor/story-editor.component.html +++ b/frontend/src/app/story-editor/story-editor.component.html @@ -83,7 +83,9 @@

You are not authorized to use this project


- {{selectedStory.body}} +
+ {{selectedStory.body}} +
From b546fa61f19a1b18a643019f56565de7d92f5feb Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 2 Feb 2024 12:05:52 +0100 Subject: [PATCH 03/65] Removed data field from description --- backend/src/serverRouter/userRouter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/serverRouter/userRouter.js b/backend/src/serverRouter/userRouter.js index 1347c7341..ecebc5177 100644 --- a/backend/src/serverRouter/userRouter.js +++ b/backend/src/serverRouter/userRouter.js @@ -371,11 +371,11 @@ router.get('/stories', async (req, res) => { // put into ticketManagement.ts testStepDescription = '\n\nTest-Steps:\n'; for (const step of steps) { const fields = step.fields; - const stepInfo = [`\n----- Step ${step.index} -----`]; + const stepInfo = [`\n----- Scenario ${step.index} -----`]; stepInfo.push(fields.Given ? `(GIVEN): ${fields.Given.value}\n` : '(GIVEN): Not used\n'); stepInfo.push(fields.Action && fields.Action.value.raw ? `(WHEN): ${fields.Action.value.raw}\n` : '(WHEN): Not steps used\n'); - stepInfo.push(fields.Data && fields.Data.value.raw ? `(DATA): ${fields.Data.value.raw}\n` : '(DATA): No data used\n'); + //stepInfo.push(fields.Data && fields.Data.value.raw ? `(DATA): ${fields.Data.value.raw}\n` : '(DATA): No data used\n'); stepInfo.push(fields['Expected Result'] && fields['Expected Result'].value.raw ? `(THEN): ${fields['Expected Result'].value.raw}\n` : '(THEN): No steps used\n'); testStepDescription += stepInfo.join('\n'); } From c88ee7bc27c2190b7f6b82af53f87c2e5b9a88fd Mon Sep 17 00:00:00 2001 From: Marc Date: Tue, 27 Feb 2024 01:28:05 +0100 Subject: [PATCH 04/65] Implented logic to update steps in XRay. Still hardcoded --- backend/src/serverRouter/jiraRouter.js | 47 +++++++++++++++++++ frontend/src/app/Services/story.service.ts | 17 +++++++ .../story-editor/story-editor.component.ts | 10 +++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/backend/src/serverRouter/jiraRouter.js b/backend/src/serverRouter/jiraRouter.js index ca5b9da67..af8061dc9 100644 --- a/backend/src/serverRouter/jiraRouter.js +++ b/backend/src/serverRouter/jiraRouter.js @@ -3,6 +3,7 @@ const cors = require('cors'); const bodyParser = require('body-parser'); const fetch = require('node-fetch'); const userHelper = require('../../dist/helpers/userManagement'); +const issueTracker = require('../../dist/models/IssueTracker'); const router = express.Router(); @@ -121,4 +122,50 @@ router.post('/login', (req, res) => { } }); +router.put('/update-xray-status', async (req, res) => { + if (typeof req.user !== 'undefined' && typeof req.user.jira !== 'undefined') { + const jiraTracker = issueTracker.IssueTracker + .getIssueTracker(issueTracker.IssueTrackerOption.JIRA); + const clearPass = jiraTracker.decryptPassword(req.user.jira); + const { + AccountName, AuthMethod, Host + } = req.user.jira; + let authString = `Bearer ${clearPass}`; + if (AuthMethod === 'basic') { + const auth = Buffer.from(`${AccountName}:${clearPass}`).toString('base64'); + authString = `Basic ${auth}`; + } + const { testRunId, stepId, status } = req.body; + const testStatus = status ? 'PASS' : 'FAIL'; + const url = new URL(`https://${Host}/rest/raven/1.0/api/testrun/${testRunId}/step/${stepId}/status`); + url.searchParams.append('status', testStatus); + + const options = { + method: 'PUT', + headers: { + 'cache-control': 'no-cache', + 'Content-Type': 'application/json', + Authorization: authString + } + }; + try { + const response = await fetch(url, options); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + let data; + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const text = await response.text(); + if (text) data = JSON.parse(text); + } else data = { message: 'Success', status: response.status }; + res.json(data); + } catch (error) { + console.error('Error while updating Xray status:', error); + res.status(500).json({ message: 'Internal server error while updating Xray status' }); + } + } else { + console.log('No Jira User sent. (Got undefined)'); + res.status(500).json('No Jira User sent. Got (undefined)'); + } +}); + module.exports = router; diff --git a/frontend/src/app/Services/story.service.ts b/frontend/src/app/Services/story.service.ts index 334637421..cfba769cb 100644 --- a/frontend/src/app/Services/story.service.ts +++ b/frontend/src/app/Services/story.service.ts @@ -225,4 +225,21 @@ export class StoryService { return window.open(s); } } + +/** + * Updates XRay status in Jira + */ + /** + * Updates XRay status in Jira using fetch + */ + updateXrayStatus(testRunId, stepId, status) { + const data = { + testRunId: testRunId, + stepId: stepId, + status: status + }; + return this.http + .put(this.apiService.apiServer + '/jira/update-xray-status/', data, ApiService.getOptions()) + .pipe(tap()); +} } diff --git a/frontend/src/app/story-editor/story-editor.component.ts b/frontend/src/app/story-editor/story-editor.component.ts index 85fc6c9f4..cc4f3f1cb 100644 --- a/frontend/src/app/story-editor/story-editor.component.ts +++ b/frontend/src/app/story-editor/story-editor.component.ts @@ -1071,6 +1071,14 @@ export class StoryEditorComponent implements OnInit, OnDestroy { if (scenario_id) { // ScenarioReport const val = report.status; + const testStatus = val ? "PASS" : "FAIL"; + const testRunId = 224812; + const stepId = 268854; + this.storyService.updateXrayStatus(testRunId, stepId, testStatus).subscribe({ + next: () => { + console.log('XRay update successful');}, + error: (error) => { + console.error('Error while updating XRay status', error);}}); this.scenarioService.scenarioStatusChangeEmit( this.selectedStory._id, scenario_id, @@ -1101,7 +1109,7 @@ export class StoryEditorComponent implements OnInit, OnDestroy { ); } } - + /** * Download the test report */ From f55399d4fd8b46f8d70511e428bd25ce3538a1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20K=C3=B6hler?= Date: Tue, 23 Jan 2024 15:28:48 +0100 Subject: [PATCH 05/65] download directory os related --- backend/features/step_definitions/stepdefs.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/features/step_definitions/stepdefs.js b/backend/features/step_definitions/stepdefs.js index 3e81130d9..7b30fe141 100644 --- a/backend/features/step_definitions/stepdefs.js +++ b/backend/features/step_definitions/stepdefs.js @@ -13,8 +13,10 @@ const chrome = require('../../node_modules/selenium-webdriver/chrome'); const edge = require('../../node_modules/selenium-webdriver/edge'); const { applySpecialCommands } = require('../../src/serverHelper'); -const downloadDirectory = 'C:\\Users\\Public\\seed_Downloads'; let driver; + +const downloadDirectory = !(/^win/i.test(process.platform)) ? '/home/public/Downloads/' : 'C:\\Users\\Public\\seed_Downloads\\'; +if (!fs.existsSync(downloadDirectory)) fs.mkdirSync(downloadDirectory, { recursive: true }); const firefoxOptions = new firefox.Options().setPreference('browser.download.dir', downloadDirectory) .setPreference('browser.download.folderList', 2) // Set to 2 for the "custom" folder .setPreference('browser.helperApps.neverAsk.saveToDisk', 'application/octet-stream'); @@ -638,11 +640,11 @@ Then( async function checkDownloadedFile(fileName, directory) { const world = this; try { - const path = `${downloadDirectory}\\${fileName}`; + const path = `${downloadDirectory}${fileName}`; await fs.promises.access(path, fs.constants.F_OK); const timestamp = Date.now(); // Rename the downloaded file, so a new Run of the Test will not check the old file - await fs.promises.rename(path, `${downloadDirectory}\\Seed_Download-${timestamp.toString()}_${fileName}`, (err) => { + await fs.promises.rename(path, `${downloadDirectory}Seed_Download-${timestamp.toString()}_${fileName}`, (err) => { if (err) console.log(`ERROR: ${err}`); }); } catch (e) { From ac0d62aaa81eaa25ffb753924d5d704c0b442df0 Mon Sep 17 00:00:00 2001 From: Daniel Sorna Date: Fri, 2 Feb 2024 16:38:39 +0100 Subject: [PATCH 06/65] increase version number, adjust order of steps, remove unnecessary slide --- backend/package.json | 2 +- backend/src/database/stepTypes.js | 89 ++++++++++------------ frontend/package.json | 2 +- frontend/src/app/app.component.html | 2 +- frontend/src/app/login/login.component.ts | 19 +++-- frontend/src/assets/slide02.png | Bin 52488 -> 0 bytes 6 files changed, 51 insertions(+), 63 deletions(-) delete mode 100644 frontend/src/assets/slide02.png diff --git a/backend/package.json b/backend/package.json index e756eb2cd..475aaae88 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "seed-test-backend", - "version": "1.7.0", + "version": "1.7.1", "engines": { "node": "^18.13.0" }, diff --git a/backend/src/database/stepTypes.js b/backend/src/database/stepTypes.js index 5884c7456..314e70ba1 100644 --- a/backend/src/database/stepTypes.js +++ b/backend/src/database/stepTypes.js @@ -20,19 +20,6 @@ function stepDefs() { pre: 'I insert', mid: 'into the field ', values: ['', ''] - }, { - id: 0, - stepType: 'DISABLED_FOR_NOW', - type: 'Role', - pre: 'As a', - mid: '', - values: [ - '' - ], - selection: [ - 'Guest', - 'User' - ] }, { id: 500, stepType: 'given', @@ -218,7 +205,7 @@ function stepDefs() { ], type: 'Remove Cookie' }, { - id: 1, + id: 3, mid: 'and value ', pre: 'I add a session-storage with the name', stepType: 'given', @@ -228,7 +215,7 @@ function stepDefs() { ], type: 'Add Session-Storage' }, { - id: 2, + id: 4, mid: '', pre: 'I remove a session-storage with the name', stepType: 'given', @@ -323,7 +310,43 @@ function stepDefs() { 'unchecked' ], selectionValue: 1 - }, { + }, + { + id: 20, + stepType: 'then', + type: 'Check Tooltip', + pre: 'So the element', + mid: 'has the tooltip ', + values: [ + '', + '' + ] + }, + { + id: 31, + stepType: 'then', + type: 'CSS-Property', + pre: 'So on element', + mid: 'the css property ', + post: 'is', + values: [ + '', + '', + '' + ] + }, + { + id: 40, + stepType: 'then', + type: 'Check Image Name', + pre: 'So the image', + mid: 'has the name ', + values: [ + '', + '' + ] + }, + { id: 3, stepType: 'then', type: 'Empty Textbox', @@ -443,40 +466,6 @@ function stepDefs() { values: [ '' ] - }, - { - id: 501, - stepType: 'then', - type: 'CSS-Value', - pre: 'So on element', - mid: 'the css property ', - post: 'is', - values: [ - '', - '', - '' - ] - }, - { - id: 502, - stepType: 'then', - type: 'Tool-Tip', - pre: 'So the element', - mid: 'has the tool-tip ', - values: [ - '', - '' - ] - }, { - id: 3, - stepType: 'then', - type: 'Check Image', - pre: 'So the picture', - mid: 'has the name ', - values: [ - '', - '' - ] } ]; } diff --git a/frontend/package.json b/frontend/package.json index 98ec34b9b..3f204d1f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "seed-test-frontend", - "version": "1.7.0", + "version": "1.7.1", "engines": { "node": "18.13.0" }, diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index cda109466..fae437e93 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -35,7 +35,7 @@
- Version 1.7.0 {{version === '' ? " (demo)" : ""}} + Version 1.7.1 {{version === '' ? " (demo)" : ""}}
diff --git a/frontend/src/app/login/login.component.ts b/frontend/src/app/login/login.component.ts index 814165777..335aac211 100644 --- a/frontend/src/app/login/login.component.ts +++ b/frontend/src/app/login/login.component.ts @@ -52,16 +52,15 @@ export class LoginComponent implements OnInit, AfterViewInit { */ slides = [{'id':'0','image': '/assets//slide0.png'}, {'id':'1','image': '/assets//slide01.PNG','caption':'Login to Seed-Test via GitHub or create a new Seed-Test Account by registering.\nAlternatively you can try Seed-Test without an account, by trying our Demo.'}, - {'id':'2','image': '/assets//slide02.png','caption':'After the login via GitHub you can see your projects.'}, - {'id':'3','image': '/assets//slide03.PNG','caption':'Else you can just register yourself using your E-Mail.'}, - {'id':'4','image': '/assets//slide04.PNG','caption':'After the first Login of your Seed-Test account, you can create your own custom Projects \nor connect an existing GitHub Account or Jira Server.'}, - {'id':'5','image': '/assets//slide05.PNG','caption':'Name your custom Project and save it.'}, - {'id':'6','image': '/assets//slide06.PNG','caption':'Select your newly created Project to continue.'}, - {'id':'7','image': '/assets//slide07.png','caption':'With a new Custom Project you can create your own stories.\nIf you use a Github or Jira repository, you have to create an issue with the tag or label „story“, to make it appear in Seed-Test.'}, - {'id':'8','image': '/assets//slide08.PNG','caption':'Enter a name and description for your new story.'}, - {'id':'9','image': '/assets//slide09.PNG','caption':'Now you can add steps to create your first Test!\nUsually you want to start by using the Given-Step: "Website".'}, - {'id':'10','image': '/assets//slide10.PNG','caption':'Run your Test by clickling on "Run Scenario".'}, - {'id':'11','image': '/assets//slide11.png','caption':'For help and further information click on Help and check out our Tutorial.'}] + {'id':'2','image': '/assets//slide03.PNG','caption':'Else you can just register yourself using your E-Mail.'}, + {'id':'3','image': '/assets//slide04.PNG','caption':'After the first Login of your Seed-Test account, you can create your own custom Projects \nor connect an existing GitHub Account or Jira Server.'}, + {'id':'4','image': '/assets//slide05.PNG','caption':'Name your custom Project and save it.'}, + {'id':'5','image': '/assets//slide06.PNG','caption':'Select your newly created Project to continue.'}, + {'id':'6','image': '/assets//slide07.png','caption':'With a new Custom Project you can create your own stories.\nIf you use a Github or Jira repository, you have to create an issue with the tag or label „story“, to make it appear in Seed-Test.'}, + {'id':'7','image': '/assets//slide08.PNG','caption':'Enter a name and description for your new story.'}, + {'id':'8','image': '/assets//slide09.PNG','caption':'Now you can add steps to create your first Test!\nUsually you want to start by using the Given-Step: "Website".'}, + {'id':'9','image': '/assets//slide10.PNG','caption':'Run your Test by clickling on "Run Scenario".'}, + {'id':'10','image': '/assets//slide11.png','caption':'For help and further information click on Help and check out our Tutorial.'}] customOptions: OwlOptions = { loop: true, diff --git a/frontend/src/assets/slide02.png b/frontend/src/assets/slide02.png deleted file mode 100644 index 95ac5df9955c1d48776018677d79b7f4557df1c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52488 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 a94*GSx`*O_g%|2T? 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 From e00481da4b5d8af3e0bf60abd8a7e33fdc073350 Mon Sep 17 00:00:00 2001 From: Nataliia Karmazina Date: Wed, 31 Jan 2024 15:54:20 +0100 Subject: [PATCH 07/65] Bug is fixed --- .../app/modals/workgroup-edit/workgroup-edit.component.html | 2 +- .../src/app/modals/workgroup-edit/workgroup-edit.component.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/modals/workgroup-edit/workgroup-edit.component.html b/frontend/src/app/modals/workgroup-edit/workgroup-edit.component.html index f2b1eb00e..146f18ec2 100644 --- a/frontend/src/app/modals/workgroup-edit/workgroup-edit.component.html +++ b/frontend/src/app/modals/workgroup-edit/workgroup-edit.component.html @@ -157,7 +157,7 @@ + (click)="renameProject(projectName)">Rename project
diff --git a/frontend/src/app/modals/workgroup-edit/workgroup-edit.component.ts b/frontend/src/app/modals/workgroup-edit/workgroup-edit.component.ts index 9ebdb1fb2..2f731bbe1 100644 --- a/frontend/src/app/modals/workgroup-edit/workgroup-edit.component.ts +++ b/frontend/src/app/modals/workgroup-edit/workgroup-edit.component.ts @@ -344,8 +344,8 @@ export class WorkgroupEditComponent { /** * Submits the new name for the scenario */ - renameProject(form: NgForm) { - const name = form.value.newTitle; + renameProject(renameProject) { + const name = renameProject; const project = this.workgroupProject; if (name.replace(/\s/g, '').length > 0) { project.value = name; From c984d367d787c670d1a12729916c1cc1a3039cff Mon Sep 17 00:00:00 2001 From: i3rotlher <64362712+i3rotlher@users.noreply.github.com> Date: Mon, 12 Feb 2024 13:26:58 +0100 Subject: [PATCH 08/65] Special-Commands (#526) Regex and Special Command Highlighting --- backend/features/step_definitions/stepdefs.js | 26 +- backend/src/serverHelper.js | 18 +- backend/src/serverRouter/storyRouter.js | 2 +- frontend/src/app/Services/api.service.ts | 482 ++--- .../app/Services/highlight-input.service.ts | 221 ++- .../base-editor/base-editor.component.html | 169 +- .../app/base-editor/base-editor.component.ts | 1551 ++++++++++------- .../example-table/example-table.component.ts | 384 ++-- 8 files changed, 1693 insertions(+), 1160 deletions(-) diff --git a/backend/features/step_definitions/stepdefs.js b/backend/features/step_definitions/stepdefs.js index 7b30fe141..e0803ac33 100644 --- a/backend/features/step_definitions/stepdefs.js +++ b/backend/features/step_definitions/stepdefs.js @@ -290,7 +290,7 @@ When('I insert {string} into the field {string}', async function fillTextField(v `//textarea[@*='${label}']`, `//textarea[contains(@*='${label}')]`, `//*[@id='${label}']`, `//input[@type='text' and @*='${label}']`, `//label[contains(text(),'${label}')]/following::input[@type='text']`, `${label}`]; - if (value.includes('@@')) value = applyDateCommand(value); + if (value.includes('@@')) value = applySpecialCommands(value); const promises = []; for (const idString of identifiers) promises.push( @@ -557,13 +557,16 @@ Then('So I will be navigated to the website: {string}', async function checkUrl( const resolveRegex = (rawString) => { // undefined to empty string rawString = !rawString ? '' : rawString; - const regex = /\{Regex:([^}]*(?:\{[^}]*\}[^}]*)*)(\})(?=\s|$)/g; - return rawString.replace(regex, '($1)'); + const regex = /(\{Regex:)(.*)(\})(.*)/g; + const regexFound = regex.test(rawString); + const resultString = regexFound ? rawString.replace(regex, '$2$4') : rawString; + return { resultString, regexFound }; }; // Search a textfield in the html code and assert it with a Text Then('So I can see the text {string} in the textbox: {string}', async function checkForTextInField(expectedText, label) { - const resultString = resolveRegex(expectedText); + expectedText = applySpecialCommands(expectedText.toString()); + const { resultString, regexFound } = resolveRegex(expectedText); const world = this; @@ -577,7 +580,8 @@ Then('So I can see the text {string} in the textbox: {string}', async function c let resp = await elem.getText(); resp = resp == '' ? await elem.getAttribute('value') : resp; resp = resp == '' ? await elem.getAttribute('outerHTML') : resp; - match(resp, RegExp(resultString), `Textfield does not contain the string/regex: ${resultString} , actual: ${resp}`); + if (regexFound) match(resp, RegExp(resultString), `Textfield does not contain the string/regex: ${resultString} , actual: ${resp}`); + else expect(resp).to.equal(resultString, `Textfield does not contain the string: ${resultString}`); }) .catch(async (e) => { await driver.takeScreenshot().then(async (buffer) => { @@ -590,7 +594,8 @@ Then('So I can see the text {string} in the textbox: {string}', async function c // Search if a is text in html code Then('So I can see the text: {string}', async function textPresent(expectedText) { // text is present - const resultString = resolveRegex(expectedText); + expectedText = applySpecialCommands(expectedText.toString()); + const { resultString, regexFound } = resolveRegex(expectedText); const world = this; try { await driver.wait(async () => driver.executeScript('return document.readyState').then(async (readyState) => readyState === 'complete')); @@ -600,7 +605,8 @@ Then('So I can see the text: {string}', async function textPresent(expectedText) const innerHtmlBody = await driver.executeScript('return document.documentElement.innerHTML'); const outerHtmlBody = await driver.executeScript('return document.documentElement.outerHTML'); const bodyAll = cssBody + innerHtmlBody + outerHtmlBody; - match(bodyAll, RegExp(resultString), `Page HTML does not contain the string/regex: ${resultString}`); + if (regexFound) match(bodyAll, RegExp(resultString), `Page HTML does not contain the string/regex: ${resultString}`); + else expect(bodyAll).to.contain(resultString, `Page HTML does not contain the string/regex: ${resultString}`); }); } catch (e) { await driver.takeScreenshot().then(async (buffer) => { @@ -702,7 +708,8 @@ Then('So the picture {string} has the name {string}', async function checkPictur // Search if a text isn't in html code Then('So I can\'t see the text: {string}', async function checkIfTextIsMissing(expectedText) { - const resultString = resolveRegex(expectedText.toString()); + expectedText = applySpecialCommands(expectedText.toString()); + const { resultString, regexFound } = resolveRegex(expectedText); const world = this; try { await driver.wait(async () => driver.executeScript('return document.readyState').then(async (readyState) => readyState === 'complete')); @@ -711,7 +718,8 @@ Then('So I can\'t see the text: {string}', async function checkIfTextIsMissing(e const innerHtmlBody = await driver.executeScript('return document.documentElement.innerHTML'); const outerHtmlBody = await driver.executeScript('return document.documentElement.outerHTML'); const bodyAll = cssBody + innerHtmlBody + outerHtmlBody; - doesNotMatch(bodyAll, RegExp(resultString), `Page HTML does contain the string/regex: ${resultString}`); + if (regexFound) doesNotMatch(bodyAll, RegExp(resultString), `Page HTML does contain the string/regex: ${resultString}`); + else expect(bodyAll).to.not.contain(resultString, `Page HTML does contain the string/regex: ${resultString}`); }); } catch (e) { await driver.takeScreenshot().then(async (buffer) => { diff --git a/backend/src/serverHelper.js b/backend/src/serverHelper.js index c9ae575a9..769e0760b 100644 --- a/backend/src/serverHelper.js +++ b/backend/src/serverHelper.js @@ -470,7 +470,7 @@ function applyDateCommand(str) { // Wenn das Array leer ist, wurden keine Substrings gefunden if (indices.length === 0) { - return -1; + return str; } // Finde den niedrigsten Index (die erste Vorkommen) @@ -530,7 +530,7 @@ function calcDate(value) { // check the correct usage of @@Day, @@Month, @@Year else { - startcopy = start.slice(); + let startcopy = start.slice(); for (let i = 0; i < substrings.length; i++) { if (start.split(substrings[i]).length - 1 > 1) throw Error(`${substringsErr[i]} may only be used 0 or 1 time. Input: ${start}`); startcopy = startcopy.replace(substrings[i], ''); @@ -631,11 +631,17 @@ function calcDate(value) { return result; } +/** +* Applies the special commands to a string. +* Special commands are marked via: {Regex: TEXT}. +* return the string with the special commands applied. +*/ function applySpecialCommands(str) { - let appliedCommandsString = ''; - if (str.includes('@@Day') || str.includes('@@Month') || str.includes('@@Year') || str.includes('@@Date')) { - appliedCommandsString = applyDateCommand(str); - } + const pattern = /(((((@@(Day|Month|Year),(\d\d?\d?\d?))+)|(@@((\d|\d\d),)?[a-zA-Z]+))((\+|-)(@@((\d|\d\d),)?[a-zA-Z]+))+)|(((@@(Day|Month|Year),(\d\d?\d?\d?))+)|(@@((\d|\d\d),)?[a-zA-Z]+)))(@@format:.*€€)?/g; + let appliedCommandsString = str; + + // appliedCommandsString = applyDateCommand(str); + appliedCommandsString = str.replace(pattern, (match) => applyDateCommand(match)); return appliedCommandsString; } diff --git a/backend/src/serverRouter/storyRouter.js b/backend/src/serverRouter/storyRouter.js index 45c763f8c..d95d14d3c 100644 --- a/backend/src/serverRouter/storyRouter.js +++ b/backend/src/serverRouter/storyRouter.js @@ -186,7 +186,7 @@ router.post('/specialCommands/resolve', async (req, res) => { const result = helper.applySpecialCommands(req.body.command); res.status(200).json(result); } catch (error) { - handleError(res, error, error, 500); + handleError(res, error, error.message, 500); } }); diff --git a/frontend/src/app/Services/api.service.ts b/frontend/src/app/Services/api.service.ts index be04312f0..57cd56efb 100644 --- a/frontend/src/app/Services/api.service.ts +++ b/frontend/src/app/Services/api.service.ts @@ -1,244 +1,276 @@ -import {EventEmitter, Injectable} from '@angular/core'; -import {catchError, tap} from 'rxjs/operators'; -import {HttpClient, HttpErrorResponse} from '@angular/common/http'; -import {Observable, of, throwError} from 'rxjs'; -import {User} from '../model/User'; -import {RepositoryContainer} from '../model/RepositoryContainer'; - +import { EventEmitter, Injectable } from "@angular/core"; +import { catchError, tap } from "rxjs/operators"; +import { HttpClient, HttpErrorResponse } from "@angular/common/http"; +import { Observable, of, throwError } from "rxjs"; +import { User } from "../model/User"; +import { RepositoryContainer } from "../model/RepositoryContainer"; /** * Service for communication between components and the backend */ @Injectable({ - providedIn: 'root' + providedIn: "root", }) - export class ApiService { + /** + * @ignore + */ + constructor(private http: HttpClient) {} + /** + * url of the backend + */ + public apiServer: string = localStorage.getItem("url_backend"); - /** - * @ignore - */ - constructor(private http: HttpClient) { - } + // ------------------------------- TOASTR TEMPLATE -------------------------------- + /** + * name of component + */ + public nameComponent: string; + /** + * name of the first option in the toastr + */ + public firstOption: string; + /** + * name of the second option in the toastr + */ + public secondOption: string; + /** + * set name of component + * @param nameComponent + */ + nameOfComponent(nameComponent: string) { + this.nameComponent = nameComponent; + } + /** + * get name of component that user wants to delete + * @returns + */ + getNameOfComponent() { + return this.nameComponent; + } + /** + * set options for info-warning-toster + * @param nameComponent + */ + setToastrOptions(first: string, second: string) { + this.firstOption = first; + this.secondOption = second; + } + /** + * get name of options that user wants to execute + * @returns + */ + getNameOfToastrOptions() { + return [this.firstOption, this.secondOption]; + } + // ------------------------------- TOASTR TEMPLATE ----------------------------- - /** - * url of the backend - */ - public apiServer: string = localStorage.getItem('url_backend'); - - // ------------------------------- TOASTR TEMPLATE -------------------------------- - /** - * name of component - */ - public nameComponent: string; - /** - * name of the first option in the toastr - */ - public firstOption: string; - /** - * name of the second option in the toastr - */ - public secondOption: string; - /** - * set name of component - * @param nameComponent - */ - nameOfComponent(nameComponent:string){ - this.nameComponent = nameComponent; - } - /** - * get name of component that user wants to delete - * @returns - */ - getNameOfComponent(){ - return this.nameComponent; - } - /** - * set options for info-warning-toster - * @param nameComponent - */ - setToastrOptions(first: string, second: string){ - this.firstOption = first; - this.secondOption = second; - } - /** - * get name of options that user wants to execute - * @returns - */ - getNameOfToastrOptions(){ - return [this.firstOption, this.secondOption]; - } - // ------------------------------- TOASTR TEMPLATE ----------------------------- - - /** - * If the backend url was received - */ - public urlReceived = localStorage.getItem('url_backend') && localStorage.getItem('url_backend') !== 'undefined'; - - /** - * Event Emitter if the stories could not be retrieved - * Api - */ - public storiesErrorEvent = new EventEmitter(); - - /** - * Event Emitter to signal that the backend url is available - * Api - */ - public getBackendUrlEvent = new EventEmitter(); - - /** - * Event emitter to save the story / scenario and then run the test - */ - public runSaveOptionEvent = new EventEmitter(); - - /** - * Event emitter for handling copy of steps with example - */ - public copyStepWithExampleEvent = new EventEmitter(); - - /** - * Gets api headers - * @returns - */ - public static getOptions() { - return { withCredentials: true }; - } + /** + * If the backend url was received + */ + public urlReceived = + localStorage.getItem("url_backend") && + localStorage.getItem("url_backend") !== "undefined"; - /** - * Handles http error - * @param error - * @returns - */ - public handleError(error: HttpErrorResponse) { - console.log(JSON.stringify(error)); - return throwError(() => error); - } + /** + * Event Emitter if the stories could not be retrieved + * Api + */ + public storiesErrorEvent = new EventEmitter(); - /** - * Emits the run save option - * @param option - */ - public runSaveOption(option: string) { - this.runSaveOptionEvent.emit(option); - } + /** + * Event Emitter to signal that the backend url is available + * Api + */ + public getBackendUrlEvent = new EventEmitter(); - /** - * Emits the copy with example option - * @param option - */ - public copyStepWithExampleOption(option: string) { - this.copyStepWithExampleEvent.emit(option); - } + /** + * Event emitter to save the story / scenario and then run the test + */ + public runSaveOptionEvent = new EventEmitter(); - /** - * Handles the error from retrieve stories - * @param error - * @param caught - * @returns - */ - handleStoryError = (_error: HttpErrorResponse, _caught: Observable) => { - this.storiesErrorEvent.emit(); - return of([]); - } + /** + * Event emitter for handling copy of steps with example + */ + public copyStepWithExampleEvent = new EventEmitter(); - /** - * Retrieves the backend info for all api request necessary - * @returns - */ - getBackendInfo(): Promise { - const url = localStorage.getItem('url_backend'); - const clientId = localStorage.getItem('clientId'); - const version = localStorage.getItem('version'); - const gecko_enabled = localStorage.getItem('gecko_enabled') - const chromium_enabled = localStorage.getItem('chromium_enabled') - const edge_enabled = localStorage.getItem('edge_enabled') - - const gecko_emulators = localStorage.getItem('gecko_emulators') - const chromium_emulators = localStorage.getItem('chromium_emulators') - const edge_emulators = localStorage.getItem('edge_emulators') - - if (url && url !== 'undefined' && - clientId && clientId !== 'undefined' && - version && version !== 'undefined' && - gecko_enabled && gecko_enabled !== 'undefined' && - chromium_enabled && chromium_enabled !== 'undefined' && - edge_enabled && edge_enabled !== 'undefined' && - gecko_emulators && gecko_emulators !== 'undefined' && - chromium_emulators && chromium_emulators !== 'undefined' && - edge_emulators && edge_emulators !== 'undefined' - ) { - - this.urlReceived = true; - this.getBackendUrlEvent.emit(); - return Promise.resolve(url); - } else { - - return this.http.get(window.location.origin + '/backendInfo', ApiService.getOptions()).toPromise().then((backendInfo) => { - localStorage.setItem('url_backend', backendInfo.url); - localStorage.setItem('clientId', backendInfo.clientId); - localStorage.setItem('version', backendInfo.version); - localStorage.setItem('gecko_enabled', backendInfo.gecko_enabled); - localStorage.setItem('chromium_enabled', backendInfo.chromium_enabled); - localStorage.setItem('edge_enabled', backendInfo.edge_enabled) - localStorage.setItem('gecko_emulators', backendInfo.gecko_emulators) - localStorage.setItem('chromium_emulators', backendInfo.chromium_emulators) - localStorage.setItem('edge_emulators', backendInfo.edge_emulators) - this.urlReceived = true; - this.getBackendUrlEvent.emit(); - }); - } - } - /** - * Updates a user - * @param userID - * @param user - * @returns - */ - updateUser(userID: string, user: User): Observable {//not used - this.apiServer = localStorage.getItem('url_backend'); - return this.http - .post(this.apiServer + '/user/update/' + userID, user) - .pipe(tap(_ => { - // - }), catchError(this.handleStoryError)); - } - /** - * Submitts an issue to github to create a new step - * @param obj - * @returns - */ - submitGithub(obj) { - this.apiServer = localStorage.getItem('url_backend'); - return this.http - .post(this.apiServer + '/github/submitIssue/', obj, ApiService.getOptions()); - } - /** - * If the repo is github repo - * @param repo - * @returns - */ - isGithubRepo(repo: RepositoryContainer): boolean { - return ( repo.source === 'github'); - } + /** + * Gets api headers + * @returns + */ + public static getOptions() { + return { withCredentials: true }; + } - /** - * If the repo is a jira repo - * @param repo - * @returns - */ - isJiraRepo(repo: RepositoryContainer): boolean { - return ( repo.source === 'jira'); - } + /** + * Handles http error + * @param error + * @returns + */ + public handleError(error: HttpErrorResponse) { + console.log(JSON.stringify(error)); + return throwError(() => error); + } + + /** + * Emits the run save option + * @param option + */ + public runSaveOption(option: string) { + this.runSaveOptionEvent.emit(option); + } + + /** + * Emits the copy with example option + * @param option + */ + public copyStepWithExampleOption(option: string) { + this.copyStepWithExampleEvent.emit(option); + } - /** - * If the repo is a custom project - * @param repo - * @returns - */ - isCustomRepo(repo: RepositoryContainer): boolean { - return ( repo.source === 'db'); + /** + * Handles the error from retrieve stories + * @param error + * @param caught + * @returns + */ + handleStoryError = (_error: HttpErrorResponse, _caught: Observable) => { + this.storiesErrorEvent.emit(); + return of([]); + }; + + /** + * Retrieves the backend info for all api request necessary + * @returns + */ + getBackendInfo(): Promise { + const url = localStorage.getItem("url_backend"); + const clientId = localStorage.getItem("clientId"); + const version = localStorage.getItem("version"); + const gecko_enabled = localStorage.getItem("gecko_enabled"); + const chromium_enabled = localStorage.getItem("chromium_enabled"); + const edge_enabled = localStorage.getItem("edge_enabled"); + + const gecko_emulators = localStorage.getItem("gecko_emulators"); + const chromium_emulators = localStorage.getItem("chromium_emulators"); + const edge_emulators = localStorage.getItem("edge_emulators"); + + if ( + url && + url !== "undefined" && + clientId && + clientId !== "undefined" && + version && + version !== "undefined" && + gecko_enabled && + gecko_enabled !== "undefined" && + chromium_enabled && + chromium_enabled !== "undefined" && + edge_enabled && + edge_enabled !== "undefined" && + gecko_emulators && + gecko_emulators !== "undefined" && + chromium_emulators && + chromium_emulators !== "undefined" && + edge_emulators && + edge_emulators !== "undefined" + ) { + this.urlReceived = true; + this.getBackendUrlEvent.emit(); + return Promise.resolve(url); + } else { + return this.http + .get( + window.location.origin + "/backendInfo", + ApiService.getOptions() + ) + .toPromise() + .then((backendInfo) => { + localStorage.setItem("url_backend", backendInfo.url); + localStorage.setItem("clientId", backendInfo.clientId); + localStorage.setItem("version", backendInfo.version); + localStorage.setItem("gecko_enabled", backendInfo.gecko_enabled); + localStorage.setItem( + "chromium_enabled", + backendInfo.chromium_enabled + ); + localStorage.setItem("edge_enabled", backendInfo.edge_enabled); + localStorage.setItem("gecko_emulators", backendInfo.gecko_emulators); + localStorage.setItem( + "chromium_emulators", + backendInfo.chromium_emulators + ); + localStorage.setItem("edge_emulators", backendInfo.edge_emulators); + this.urlReceived = true; + this.getBackendUrlEvent.emit(); + }); } + } + /** + * Updates a user + * @param userID + * @param user + * @returns + */ + updateUser(userID: string, user: User): Observable { + //not used + this.apiServer = localStorage.getItem("url_backend"); + return this.http + .post(this.apiServer + "/user/update/" + userID, user) + .pipe( + tap((_) => { + // + }), + catchError(this.handleStoryError) + ); + } + /** + * Submitts an issue to github to create a new step + * @param obj + * @returns + */ + submitGithub(obj) { + this.apiServer = localStorage.getItem("url_backend"); + return this.http.post( + this.apiServer + "/github/submitIssue/", + obj, + ApiService.getOptions() + ); + } + /** + * If the repo is github repo + * @param repo + * @returns + */ + isGithubRepo(repo: RepositoryContainer): boolean { + return repo.source === "github"; + } + + /** + * If the repo is a jira repo + * @param repo + * @returns + */ + isJiraRepo(repo: RepositoryContainer): boolean { + return repo.source === "jira"; + } + + /** + * If the repo is a custom project + * @param repo + * @returns + */ + isCustomRepo(repo: RepositoryContainer): boolean { + return repo.source === "db"; + } + + resolveSpecialCommand(command) { + this.apiServer = localStorage.getItem("url_backend"); + return this.http.post( + this.apiServer + "/story/specialCommands/resolve", + { command: command }, + ApiService.getOptions() + ); + } } - diff --git a/frontend/src/app/Services/highlight-input.service.ts b/frontend/src/app/Services/highlight-input.service.ts index 9e36bce16..11f8bc00c 100644 --- a/frontend/src/app/Services/highlight-input.service.ts +++ b/frontend/src/app/Services/highlight-input.service.ts @@ -1,12 +1,12 @@ -import { Injectable } from '@angular/core'; -import { ToastrService } from 'ngx-toastr'; +import { Injectable } from "@angular/core"; +import { ToastrService } from "ngx-toastr"; +import { ApiService } from "./api.service"; @Injectable({ - providedIn: 'root' + providedIn: "root", }) export class HighlightInputService { - - constructor(public toastr: ToastrService) { } + constructor(public toastr: ToastrService, public apiService: ApiService) {} targetOffset: number = 0; /** @@ -19,58 +19,55 @@ export class HighlightInputService { * @param initialCall if call is from ngAfterView * @param isDark theming Service * @param regexInStory if first regex in Story - * @param valueIndex index of input field + * @param valueIndex index of input field * @param stepPre pre text of step + * @param highlightRegex if regex should be detexted and highlighted * @returns if a regex was detected */ - highlightRegex(element, initialCall?:boolean, isDark?:boolean, regexInStory?:boolean, valueIndex?: number, stepPre?: string) { - const regexPattern =/(\{Regex:)(.*?)(\})(?=\s|$)/g;// Regex pattern to recognize and highlight regex expressions -> start with {Regex: and end with } - - const textField = element + highlightInput( + element, + initialCall?: boolean, + isDark?: boolean, + regexInStory?: boolean, + valueIndex?: number, + stepPre?: string, + highlightRegex?: boolean + ) { + const textField = element; const textContent = textField.textContent; //Get current cursor position - const offset = this.getCaretCharacterOffsetWithin(textField) - const regexSteps = ['So I can see the text', 'So I can see the text:', 'So I can\'t see the text:', 'So I can\'t see text in the textbox:'] + const offset = this.getCaretCharacterOffsetWithin(textField); + const regexSteps = [ + "So I can see the text", + "So I can see the text:", + "So I can't see the text:", + "So I can't see text in the textbox:", + ]; var regexDetected = false; + var specialCommandDetected = false; + let highlightedText = textContent; - let highlightedText; - if(!valueIndex || (0==valueIndex && regexSteps.includes(stepPre))){ - if(isDark){ - highlightedText = textContent.replace(regexPattern, (match, match1, match2, match3) => { - regexDetected = true; - return ``+ - `${match1}`+ - `${match2}`+ - `${match3}`; - }); - } else{ - highlightedText = textContent.replace(regexPattern, (match, match1, match2, match3) => { - regexDetected = true; - return ``+ - `${match1}`+ - `${match2}`+ - `${match3}`; - }); + if (!valueIndex || (0 == valueIndex && regexSteps.includes(stepPre))) { + if (highlightRegex) { + ({ regexDetected, highlightedText } = this.highlightRegex( + highlightedText, + isDark + )); } + ({ specialCommandDetected, highlightedText } = + this.highlightSpecialCommands(highlightedText, isDark)); } textField.innerHTML = highlightedText ? highlightedText : textContent; - // Toastr logic - if(initialCall && regexDetected) { - regexInStory = true - } - if(regexDetected && !regexInStory){ - this.toastr.info('View our Documentation for more Info','Regular Expression detected!'); - } - // Set cursor to correct position - if(!initialCall){ - if (regexDetected) { //maybe not needed + if (!initialCall) { + if (true) { + //maybe not needed const selection = window.getSelection(); - selection.removeAllRanges() + selection.removeAllRanges(); // Call the function to find the correct node and offset - this.targetOffset = offset + this.targetOffset = offset; const result = this.findNodeAndOffset(textField); if (result !== null) { @@ -81,7 +78,12 @@ export class HighlightInputService { selection.setBaseAndExtent(node, offsetIndex, node, offsetIndex); } else if (node.nodeType === 1 && node.childNodes.length > 0) { // Element node with child nodes (e.g., ) - selection.setBaseAndExtent(node.childNodes[0], offsetIndex, node.childNodes[0], offsetIndex); + selection.setBaseAndExtent( + node.childNodes[0], + offsetIndex, + node.childNodes[0], + offsetIndex + ); } }); } @@ -89,18 +91,107 @@ export class HighlightInputService { requestAnimationFrame(() => { const selection = window.getSelection(); selection.removeAllRanges(); - selection.setBaseAndExtent(textField.firstChild, offset, textField.firstChild, offset) - }) + selection.setBaseAndExtent( + textField.firstChild, + offset, + textField.firstChild, + offset + ); + }); } } return regexDetected; } -/** - * Helper for Regex Highlighter, find right node and index for current cursor position - * @param element HTMLElement - * @returns node: node with cursor, number: offest of cursor in node - */ + /** + * This function takes a html element and highlights the regex inside it if found. + * @param element HTML element of contentedible div + * @param isDark theming Service + * @returns an object containing {regexDetected , highlightedText} + */ + highlightRegex(element, isDark) { + const regexPattern = /(\{Regex:)(.*)(\})(.*)/g; + + const textContent = element; + + var regexDetected = false; + let highlightedText; + // TODO: Hardcoded Styles + highlightedText = textContent.replace( + regexPattern, + (match, match1, match2, match3, match4) => { + regexDetected = true; + return ( + `` + + `${match1}` + + `${match2}` + + `${match3}${match4}` + ); + } + ); + return { regexDetected, highlightedText }; + } + + /** + * This function takes a html element and highlights the special commands inside it if found. + * @param element HTML element of contentedible div + * @param isDark theming Service + * @returns an object containing {regexDetected , highlightedText} + */ + highlightSpecialCommands(element, isDark) { + const specialCommandsPattern = + /(((((@@(Day|Month|Year),(\d\d?\d?\d?))+)|(@@((\d|\d\d),)?[a-zA-Z]+))((\+|-)(@@((\d|\d\d),)?[a-zA-Z]+))+)|(((@@(Day|Month|Year),(\d\d?\d?\d?))+)|(@@((\d|\d\d),)?[a-zA-Z]+)))(@@format:.*€€)?/g; + + const textContent = element; + + var specialCommandDetected = false; + let highlightedText; + // TODO: Hardcoded Styles + highlightedText = textContent.replace(specialCommandsPattern, (match) => { + specialCommandDetected = true; + var identifier = `specialInputId${ + Date.now().toString(36) + Math.random().toString(36).substr(2) + }`; + this.apiService.resolveSpecialCommand(match).subscribe({ + next: (resolvedCommand) => { + if (resolvedCommand === match) { + document + .querySelector(`#${identifier}`) + .setAttribute( + "uk-tooltip", + `title: Unknown command: ${resolvedCommand}` + ); + } else { + document + .querySelector(`#${identifier}`) + .setAttribute("uk-tooltip", `title:${resolvedCommand}`); + } + }, + error: (error) => { + document + .querySelector(`#${identifier}`) + .setAttribute("uk-tooltip", `title:Error: ${error.error.error}`); + }, + }); + return `${match}`; + }); + + return { specialCommandDetected, highlightedText }; + } + + /** + * Helper for Regex Highlighter, find right node and index for current cursor position + * @param element HTMLElement + * @returns node: node with cursor, number: offest of cursor in node + */ findNodeAndOffset(element: Node): [Node, number] | null { if (element.nodeType === 3) { // Text node @@ -110,7 +201,7 @@ export class HighlightInputService { } else { this.targetOffset -= textLength; } - } else if (element.nodeType === 1){ + } else if (element.nodeType === 1) { // Element node for (let i = 0; i < element.childNodes.length; i++) { const child = element.childNodes[i]; @@ -134,20 +225,20 @@ export class HighlightInputService { var win = doc.defaultView || doc.parentWindow; var sel; if (typeof win.getSelection != "undefined") { - sel = win.getSelection(); - if (sel.rangeCount > 0) { - var range = win.getSelection().getRangeAt(0); - var preCaretRange = range.cloneRange(); - preCaretRange.selectNodeContents(element); - preCaretRange.setEnd(range.endContainer, range.endOffset); - caretOffset = preCaretRange.toString().length; - } - } else if ( (sel = doc.selection) && sel.type != "Control") { - var textRange = sel.createRange(); - var preCaretTextRange = doc.body.createTextRange(); - preCaretTextRange.moveToElementText(element); - preCaretTextRange.setEndPoint("EndToEnd", textRange); - caretOffset = preCaretTextRange.text.length; + sel = win.getSelection(); + if (sel.rangeCount > 0) { + var range = win.getSelection().getRangeAt(0); + var preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(range.endContainer, range.endOffset); + caretOffset = preCaretRange.toString().length; + } + } else if ((sel = doc.selection) && sel.type != "Control") { + var textRange = sel.createRange(); + var preCaretTextRange = doc.body.createTextRange(); + preCaretTextRange.moveToElementText(element); + preCaretTextRange.setEndPoint("EndToEnd", textRange); + caretOffset = preCaretTextRange.text.length; } return caretOffset; } diff --git a/frontend/src/app/base-editor/base-editor.component.html b/frontend/src/app/base-editor/base-editor.component.html index 6364fd01d..41ed24ec1 100644 --- a/frontend/src/app/base-editor/base-editor.component.html +++ b/frontend/src/app/base-editor/base-editor.component.html @@ -7,14 +7,15 @@
- +
@@ -81,7 +83,7 @@ -
+
@@ -89,37 +91,35 @@
-
- - -
- +
+ - -
- - -
+ +
+ + +
{{i+1}}. Given (Precondition)
{{i+1}}. When (Action)
{{i+1}}. Then (Result)
-
-
+
+
-
+ (cdkDragStarted)="dragStarted($event, stepDefs, i)" (cdkDragEnded)="dragEnded()"> +
@@ -131,17 +131,15 @@ (click)="handleClick($event, currentStep, j)" [checked]="currentStep.checked">
- +
-
+
{{ selectedCount(stepDefs,i) || 1 }}
- {{i+1}}.{{j+1}} + {{i+1}}.{{j+1}}
+
@@ -160,7 +159,10 @@ type="text" value="{{currentStep.values[0]}}" on-input="addToValues(step_type_input1.value, j, 0, currentStep.stepType)" style="padding-left: 5px; padding-right: 5px;min-width: 100px; padding-bottom: 1px;" /--> -
{{currentStep.values[0]}}
+
{{currentStep.values[0]}}
- + {{exampleList}} @@ -193,7 +198,9 @@ - + {{dropdownValue}} @@ -211,7 +218,11 @@ value="{{currentStep.values[1]}}" on-input="addToValues(step_type_input2.value, j, 1, currentStep.stepType)" style="padding-left: 5px; padding-right: 5px;min-width: 100px" /--> -
{{currentStep.values[1]}}
+
{{currentStep.values[1]}}
- + {{exampleList}} @@ -245,7 +259,9 @@ - + {{dropdownValue}} @@ -264,7 +280,11 @@ value="{{currentStep.values[2]}}" on-input="addToValues(step_type_input3.value, j , 2, currentStep.stepType)" style="padding-left: 5px; padding-right: 5px;min-width: 100px" /--> -
{{currentStep.values[2]}}
+
{{currentStep.values[2]}}
- + {{exampleList}} @@ -298,7 +321,9 @@ - + {{dropdownValue}} @@ -318,13 +343,12 @@

- -
+
  • - +
    -
    Multiple Scenarios - +
    Multiple Scenarios + Define multiple Values in your Steps, to run a Scenario with the same Steps multiple times, but with different Data. Each line in the following Table, results in a separate Scenario. @@ -357,7 +381,7 @@ Each test case created by this will be carried out individually and independent.
    -
    +
    @@ -116,7 +119,13 @@

    You are not authorized to use this project

    CUC Key - {{ story.issue_number }} + + {{ story.issue_number }} + + + @@ -125,22 +134,16 @@

    You are not authorized to use this project

    - Seed-Story - - {{ story.issue_number }}. {{ story.title }} - - - - - - Jira + Seed-Story - Link + + {{ story.issue_number }}. {{ story.title }} + - + - - + +
    diff --git a/frontend/src/app/story-editor/story-editor.component.ts b/frontend/src/app/story-editor/story-editor.component.ts index 11e318732..0207f08d4 100644 --- a/frontend/src/app/story-editor/story-editor.component.ts +++ b/frontend/src/app/story-editor/story-editor.component.ts @@ -1022,18 +1022,6 @@ export class StoryEditorComponent implements OnInit, OnDestroy { this.backgroundService.backgroundReplaced = true; } - /** - * Selects a story and scenario - * @param story - */ - selectStoryScenario(story: Story) { - this.showResults = false; - this.selectedStory = story; - if (story.scenarios && story.scenarios.length > 0) { - this.selectScenario(story.scenarios[0]); - this.showEditor = true; - } else this.showEditor = false; - } /** * Make the API Request to run the tests and display the results as a chart * @param scenario_id @@ -1091,7 +1079,7 @@ export class StoryEditorComponent implements OnInit, OnDestroy { }, 10); this.toastr.info("", "Test is done"); this.runUnsaved = false; - + if (scenario_id) { // ScenarioReport const val = report.status; @@ -1119,6 +1107,24 @@ export class StoryEditorComponent implements OnInit, OnDestroy { val ); //filteredStories in stories-bar.component is undefined causing an error same file 270 } else { + + if (this.preConditionResults.length > 0) { + let preConditionStories = []; + for (let precondition of this.preConditionResults) { + for (let story of precondition.stories) { + preConditionStories.push(story); + } + } + + const temp_group = { + _id: 0, + name: 'Pre-Conditions', + member_stories: preConditionStories, + isSequential: true + }; + console.log('Pre-Condition Group:', temp_group); + //runGroup here + } // StoryReport report.scenarioStatuses.forEach((scenario) => { this.scenarioService.scenarioStatusChangeEmit( @@ -1553,6 +1559,7 @@ export class StoryEditorComponent implements OnInit, OnDestroy { // stories enthält nun alle Story-Objekte, die zu testKey gehören const results = { preConditionKey: precondition.preConditionKey, + preConditionName: precondition.preConditionName, stories: stories // Direkt als Array von Story-Objekten }; this.preConditionResults.push(results); @@ -1563,4 +1570,56 @@ export class StoryEditorComponent implements OnInit, OnDestroy { }); } } + + toTicket(issue_number: string) { + const host = this.selectedStory.host + const url = `https://${host}/browse/${issue_number}`; + window.open(url, "_blank"); + } + + /** + * Selects a new Story and with it a new scenario + * @param story + */ + selectStoryScenario(story: Story) { + this.selectedStory = story; + console.log("Selected Story:", this.selectedStory); + this.initialyAddIsExample(); + this.preConditionResults = []; + this.storyChosen.emit(story); + if (story.scenarios.length > 0) { + this.selectScenario(story.scenarios[0]); + } else this.selectScenario(null); + this.backgroundService.backgroundReplaced = undefined; + } + + initialyAddIsExample(){ + this.selectedStory.scenarios.forEach(scenario => { + scenario.stepDefinitions.given.forEach((value, index) =>{ + if(!scenario.stepDefinitions.given[index].isExample){ + scenario.stepDefinitions.given[index].isExample = new Array(value.values.length) + value.values.forEach((val,i) => { + scenario.stepDefinitions.given[index].isExample[i] = val.startsWith('<') && val.endsWith('>') + }) + } + }) + scenario.stepDefinitions.when.forEach((value, index) =>{ + if(!scenario.stepDefinitions.when[index].isExample){ + scenario.stepDefinitions.when[index].isExample = new Array(value.values.length) + value.values.forEach((val,i) => { + scenario.stepDefinitions.when[index].isExample[i] = val.startsWith('<') && val.endsWith('>') + }) + } + }) + scenario.stepDefinitions.then.forEach((value, index) =>{ + if(!scenario.stepDefinitions.then[index].isExample){ + scenario.stepDefinitions.then[index].isExample = new Array(value.values.length) + value.values.forEach((val,i) => { + scenario.stepDefinitions.then[index].isExample[i] = val.startsWith('<') && val.endsWith('>') + }) + } + }) + + }) + } } From 4c226177350b8f33680f8add38069c0ba7f52706 Mon Sep 17 00:00:00 2001 From: bessaYo Date: Tue, 2 Jul 2024 04:36:41 +0200 Subject: [PATCH 47/65] selected story also changes in story bar --- frontend/src/app/parent/parent.component.html | 4 +- .../stories-bar/stories-bar.component.html | 27 +- .../app/stories-bar/stories-bar.component.ts | 13 +- .../story-editor/story-editor.component.ts | 257 +++++++++++------- 4 files changed, 185 insertions(+), 116 deletions(-) diff --git a/frontend/src/app/parent/parent.component.html b/frontend/src/app/parent/parent.component.html index f9bbbe9fb..76dfcb7b2 100644 --- a/frontend/src/app/parent/parent.component.html +++ b/frontend/src/app/parent/parent.component.html @@ -1,10 +1,10 @@
    - +
    - +
    diff --git a/frontend/src/app/stories-bar/stories-bar.component.html b/frontend/src/app/stories-bar/stories-bar.component.html index c2b9b0bd5..136e49bed 100644 --- a/frontend/src/app/stories-bar/stories-bar.component.html +++ b/frontend/src/app/stories-bar/stories-bar.component.html @@ -63,24 +63,25 @@
    diff --git a/frontend/src/app/stories-bar/stories-bar.component.ts b/frontend/src/app/stories-bar/stories-bar.component.ts index c314587e5..64846a40f 100644 --- a/frontend/src/app/stories-bar/stories-bar.component.ts +++ b/frontend/src/app/stories-bar/stories-bar.component.ts @@ -121,6 +121,7 @@ export class StoriesBarComponent implements OnInit, OnDestroy { @Input() isDark: boolean; + @Input() newSelectedStory: Story; /** * SearchTerm for story title search @@ -275,6 +276,17 @@ export class StoriesBarComponent implements OnInit, OnDestroy { } + + ngOnChanges() { + + + if (this.newSelectedStory) { + console.log("Hey I got a new story", this.newSelectedStory.issue_number); + this.selectedStory = this.newSelectedStory; + this.selectStoryScenario(this.selectedStory); + } + } + /* TODO */ ngOnDestroy() { this.createStoryEmitter.unsubscribe(); @@ -709,5 +721,4 @@ export class StoriesBarComponent implements OnInit, OnDestroy { const repositoryContainer: RepositoryContainer = {value, source, _id}; this.storyService.goToTicket(story, repositoryContainer); } - } diff --git a/frontend/src/app/story-editor/story-editor.component.ts b/frontend/src/app/story-editor/story-editor.component.ts index 0207f08d4..f271f8c6d 100644 --- a/frontend/src/app/story-editor/story-editor.component.ts +++ b/frontend/src/app/story-editor/story-editor.component.ts @@ -10,6 +10,7 @@ import { } from "@angular/core"; import { ApiService } from "../Services/api.service"; import { Story } from "../model/Story"; +import { Group } from "../model/Group"; import { Scenario } from "../model/Scenario"; import { StepType } from "../model/StepType"; import { Background } from "../model/Background"; @@ -25,6 +26,7 @@ import { RenameBackgroundComponent } from "../modals/rename-background/rename-ba import { BackgroundService } from "../Services/background.service"; import { StoryService } from "../Services/story.service"; import { ScenarioService } from "../Services/scenario.service"; +import { GroupService } from '../Services/group.service'; import { ReportService } from "../Services/report.service"; import { ProjectService } from "../Services/project.service"; import { LoginService } from "../Services/login.service"; @@ -86,13 +88,14 @@ export class StoryEditorComponent implements OnInit, OnDestroy { @Input() set newSelectedStory(story: Story) { this.selectedStory = story; - if(this.selectedStory.preConditions) { + if (this.selectedStory !== undefined && this.selectedStory.preConditions) { this.preConditionResults = []; - this.getPreconditionStories(); - } - - // hide if no scenarios in story - this.showEditor = !!story.scenarios.length; + this.getPreconditionStories(); + if (!this.selectedStory.scenarios) { + // hide if no scenarios in story + this.showEditor = false; + } + } } /** @@ -372,7 +375,8 @@ export class StoryEditorComponent implements OnInit, OnDestroy { public loginService: LoginService, public blockService: BlockService, public managmentService: ManagementService, - public dialog: MatDialog + public dialog: MatDialog, + public groupService: GroupService, ) { if (this.apiService.urlReceived) { this.loadStepTypes(); @@ -601,9 +605,9 @@ export class StoryEditorComponent implements OnInit, OnDestroy { this.applyChangesToBackgrounds(this.selectedStory.background); } }); - this.convertToReferenceObservable = this.blockService.convertToReferenceEvent.subscribe(block => - this.blockService.convertSelectedStepsToRef(block, this.selectedScenario) - ); + this.convertToReferenceObservable = this.blockService.convertToReferenceEvent.subscribe(block => + this.blockService.convertSelectedStepsToRef(block, this.selectedScenario) + ); } ngOnDestroy() { @@ -704,23 +708,25 @@ export class StoryEditorComponent implements OnInit, OnDestroy { * @param scenario */ showDeleteScenarioToast($event: any) { - + this.apiService.nameOfComponent("scenario"); - if($event.testKey){ - this.toastr.warning( - "Are your sure you want to delete this scenario? It cannot be restored.", - "Delete Scenario?", - { - toastComponent: XrayToast, - } - );} else { + if ($event.testKey) { + this.toastr.warning( + "Are your sure you want to delete this scenario? It cannot be restored.", + "Delete Scenario?", + { + toastComponent: XrayToast, + } + ); + } else { this.toastr.warning( "Are your sure you want to delete this scenario? It cannot be restored.", "Delete Scenario?", { toastComponent: DeleteToast, } - );} + ); + } } /** @@ -1079,19 +1085,19 @@ export class StoryEditorComponent implements OnInit, OnDestroy { }, 10); this.toastr.info("", "Test is done"); this.runUnsaved = false; - + if (scenario_id) { // ScenarioReport const val = report.status; // Update xray status - if(selectedExecutions){ - for(const testRun of this.selectedScenario.testRunSteps){ + if (selectedExecutions) { + for (const testRun of this.selectedScenario.testRunSteps) { if (selectedExecutions.includes(testRun.testRunId)) { const testStatus = val ? "PASS" : "FAIL"; this.storyService.updateXrayStatus(testRun.testRunId, testRun.testRunStepId, testStatus) .subscribe({ next: () => { - console.log('XRay update successful for TestRunStepId:', testRun.testRunStepId, " and Test Execution:", testRun.testExecKey ); + console.log('XRay update successful for TestRunStepId:', testRun.testRunStepId, " and Test Execution:", testRun.testExecKey); }, error: (error) => { console.error('Error while updating XRay status for TestRunStepId:', testRun.testRunStepId, error); @@ -1108,50 +1114,57 @@ export class StoryEditorComponent implements OnInit, OnDestroy { ); //filteredStories in stories-bar.component is undefined causing an error same file 270 } else { + // If xray precondition exists, create temporary group for preconditions and run group if (this.preConditionResults.length > 0) { - let preConditionStories = []; + const member_stories = []; + + // get all stories from preconditions for (let precondition of this.preConditionResults) { for (let story of precondition.stories) { - preConditionStories.push(story); + member_stories.push(story); } } - + + member_stories.push(this.selectedStory); + + // create temporary group for preconditions const temp_group = { - _id: 0, - name: 'Pre-Conditions', - member_stories: preConditionStories, - isSequential: true + _id: 0, + name: 'Pre-Conditions', + member_stories: member_stories, + isSequential: true }; console.log('Pre-Condition Group:', temp_group); - //runGroup here - } - // StoryReport - report.scenarioStatuses.forEach((scenario) => { - this.scenarioService.scenarioStatusChangeEmit( - this.selectedStory._id, - scenario.scenarioId, - scenario.status - ); - - // run through testruns of currenct scenario and update xray status - const currentScenarioId = scenario.scenarioId - const currentScenario = this.selectedStory.scenarios.find(scenario => scenario.scenario_id === currentScenarioId) - if(selectedExecutions){ - for(const testRun of currentScenario.testRunSteps){ - if (selectedExecutions.includes(testRun.testRunId)) { - this.storyService.updateXrayStatus(testRun.testRunId, testRun.testRunStepId, scenario.status) - .subscribe({ - next: () => { - console.log('XRay update successful for TestRunStepId:', testRun.testRunStepId, " and Test Execution:", testRun.testExecKey ); - }, - error: (error) => { - console.error('Error while updating XRay status for TestRunStepId:', testRun.testRunStepId, error); - } - }); + this.runGroup(temp_group); + } else { + // StoryReport + report.scenarioStatuses.forEach((scenario) => { + this.scenarioService.scenarioStatusChangeEmit( + this.selectedStory._id, + scenario.scenarioId, + scenario.status + ); + + // run through testruns of currenct scenario and update xray status + const currentScenarioId = scenario.scenarioId + const currentScenario = this.selectedStory.scenarios.find(scenario => scenario.scenario_id === currentScenarioId) + if (selectedExecutions) { + for (const testRun of currentScenario.testRunSteps) { + if (selectedExecutions.includes(testRun.testRunId)) { + this.storyService.updateXrayStatus(testRun.testRunId, testRun.testRunStepId, scenario.status) + .subscribe({ + next: () => { + console.log('XRay update successful for TestRunStepId:', testRun.testRunStepId, " and Test Execution:", testRun.testExecKey); + }, + error: (error) => { + console.error('Error while updating XRay status for TestRunStepId:', testRun.testRunStepId, error); + } + }); + } } } - } - }); + }); + } } }); } else { @@ -1169,10 +1182,10 @@ export class StoryEditorComponent implements OnInit, OnDestroy { } } - /** - * Evaluates whether to open xray execution list modal in run scenario. - * @param scenario_id - */ + /** + * Evaluates whether to open xray execution list modal in run scenario. + * @param scenario_id + */ evaluateAndRunScenario(scenario_id) { if (this.selectedScenario && this.selectedScenario.testKey && this.selectedScenario.testRunSteps.length > 0) { // Open the modal if there are test execution steps @@ -1183,26 +1196,26 @@ export class StoryEditorComponent implements OnInit, OnDestroy { } } - /** - * Evaluates whether to open xray execution list modal in run story. - */ - evaluateAndRunStory() { + /** + * Evaluates whether to open xray execution list modal in run story. + */ + evaluateAndRunStory() { // Check if there is at least one scenario in the story with xray key and execution const executableTests = this.selectedStory.scenarios.some(scenario => - scenario.testKey && scenario.testRunSteps && scenario.testRunSteps.length > 0); + scenario.testKey && scenario.testRunSteps && scenario.testRunSteps.length > 0); if (executableTests) { this.executionListModal.openExecutionListModal(this.selectedStory); } else { this.runTests(null); } } - + /** * Run this function if we close execution list modal */ - executeTests(event: { scenarioId: number | null, selectedExecutions: number[] }){ - if(event.scenarioId != null){ - this.runTests(event.scenarioId,event.selectedExecutions); + executeTests(event: { scenarioId: number | null, selectedExecutions: number[] }) { + if (event.scenarioId != null) { + this.runTests(event.scenarioId, event.selectedExecutions); } else { this.runTests(null, event.selectedExecutions); } @@ -1563,13 +1576,12 @@ export class StoryEditorComponent implements OnInit, OnDestroy { stories: stories // Direkt als Array von Story-Objekten }; this.preConditionResults.push(results); - console.log("PreCondition and Stories fetched:", results); }) .catch(error => { console.error('Failed to fetch stories for a test set:', error); }); } - } + } toTicket(issue_number: string) { const host = this.selectedStory.host @@ -1583,43 +1595,88 @@ export class StoryEditorComponent implements OnInit, OnDestroy { */ selectStoryScenario(story: Story) { this.selectedStory = story; - console.log("Selected Story:", this.selectedStory); this.initialyAddIsExample(); this.preConditionResults = []; this.storyChosen.emit(story); if (story.scenarios.length > 0) { - this.selectScenario(story.scenarios[0]); + this.selectScenario(story.scenarios[0]); } else this.selectScenario(null); this.backgroundService.backgroundReplaced = undefined; } - initialyAddIsExample(){ + initialyAddIsExample() { this.selectedStory.scenarios.forEach(scenario => { - scenario.stepDefinitions.given.forEach((value, index) =>{ - if(!scenario.stepDefinitions.given[index].isExample){ - scenario.stepDefinitions.given[index].isExample = new Array(value.values.length) - value.values.forEach((val,i) => { - scenario.stepDefinitions.given[index].isExample[i] = val.startsWith('<') && val.endsWith('>') - }) - } - }) - scenario.stepDefinitions.when.forEach((value, index) =>{ - if(!scenario.stepDefinitions.when[index].isExample){ - scenario.stepDefinitions.when[index].isExample = new Array(value.values.length) - value.values.forEach((val,i) => { - scenario.stepDefinitions.when[index].isExample[i] = val.startsWith('<') && val.endsWith('>') - }) - } - }) - scenario.stepDefinitions.then.forEach((value, index) =>{ - if(!scenario.stepDefinitions.then[index].isExample){ - scenario.stepDefinitions.then[index].isExample = new Array(value.values.length) - value.values.forEach((val,i) => { - scenario.stepDefinitions.then[index].isExample[i] = val.startsWith('<') && val.endsWith('>') - }) - } - }) + scenario.stepDefinitions.given.forEach((value, index) => { + if (!scenario.stepDefinitions.given[index].isExample) { + scenario.stepDefinitions.given[index].isExample = new Array(value.values.length) + value.values.forEach((val, i) => { + scenario.stepDefinitions.given[index].isExample[i] = val.startsWith('<') && val.endsWith('>') + }) + } + }) + scenario.stepDefinitions.when.forEach((value, index) => { + if (!scenario.stepDefinitions.when[index].isExample) { + scenario.stepDefinitions.when[index].isExample = new Array(value.values.length) + value.values.forEach((val, i) => { + scenario.stepDefinitions.when[index].isExample[i] = val.startsWith('<') && val.endsWith('>') + }) + } + }) + scenario.stepDefinitions.then.forEach((value, index) => { + if (!scenario.stepDefinitions.then[index].isExample) { + scenario.stepDefinitions.then[index].isExample = new Array(value.values.length) + value.values.forEach((val, i) => { + scenario.stepDefinitions.then[index].isExample[i] = val.startsWith('<') && val.endsWith('>') + }) + } + }) }) } + + runGroup(group: Group, selectedExecutions?: number[]) { + console.log('Running Group:', group) + const id = localStorage.getItem('id'); + this.testRunningGroup = true; + const params = { repository: localStorage.getItem('repository'), source: localStorage.getItem('source') } + this.groupService.runGroup(id, group._id, params).subscribe({ + next: (ret: any) => { + this.report.emit(ret); + this.testRunningGroup = false; + const report = ret.report; + report.storyStatuses.forEach(story => { + story.scenarioStatuses.forEach(scenario => { + this.scenarioService.scenarioStatusChangeEmit( + story.storyId, scenario.scenarioId, scenario.status); + + this.scenarioService.getScenario(story.storyId, scenario.scenarioId).subscribe({ + next: (fullScenario) => { + if (fullScenario && fullScenario.testRunSteps) { + for (const testRun of fullScenario.testRunSteps) { + if (selectedExecutions && selectedExecutions.includes(testRun.testRunId)) { + this.storyService.updateXrayStatus(testRun.testRunId, testRun.testRunStepId, scenario.status) + .subscribe({ + next: () => { + console.log('XRay update successful for TestRunStepId:', testRun.testRunStepId, " and Test Execution:", testRun.testExecKey); + }, + error: (error) => { + console.error('Error while updating XRay status for TestRunStepId:', testRun.testRunStepId, error); + } + }); + } + } + } + }, + error: (error) => { + console.error('Error fetching scenario details', error); + } + }); + }); + }); + }, + error: (error) => { + console.error('Error running group', error); + } + }); + } } From 7a1829088ef52f19c60dfeb421b86cc0bb7cc780 Mon Sep 17 00:00:00 2001 From: bessaYo Date: Tue, 2 Jul 2024 16:14:52 +0200 Subject: [PATCH 48/65] Scrolls to selected story in story bar --- .../stories-bar/stories-bar.component.html | 2 +- .../app/stories-bar/stories-bar.component.ts | 205 ++++++++++-------- 2 files changed, 111 insertions(+), 96 deletions(-) diff --git a/frontend/src/app/stories-bar/stories-bar.component.html b/frontend/src/app/stories-bar/stories-bar.component.html index 136e49bed..51e55e738 100644 --- a/frontend/src/app/stories-bar/stories-bar.component.html +++ b/frontend/src/app/stories-bar/stories-bar.component.html @@ -63,7 +63,7 @@
      -
    • +