diff --git a/.github/workflows/ishikawa-tools.yml b/.github/workflows/ishikawa-tools.yml new file mode 100644 index 000000000..e928ee6f3 --- /dev/null +++ b/.github/workflows/ishikawa-tools.yml @@ -0,0 +1,48 @@ +name: Generate Reports + +on: + schedule: + - cron: '0 0 * * *' + issues: + types: [opened, deleted, closed, reopened, labeled] + workflow_dispatch: + +jobs: + generate-reports: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.10.x' + + - name: Install dependencies + run: | + pip install matplotlib pandas requests PyGithub + + - name: Check environment variables + env: + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + USER: ${{ secrets.USER }} + PROJECT: ${{ secrets.PROJECT }} + run: | + echo "User secret length: ${#USER}" + echo "Project secret length: ${#PROJECT}" # Changed the checks for user and project to length checks (these secrets should probably be changed to variables) + echo "Token length: ${#ACCESS_TOKEN}" # This will print the length of the token to ensure it's set, without exposing it. + + - name: Run report generation script + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} #This should work instead of using a personal access token + USER: ${{ secrets.USER }} + PROJECT: ${{ secrets.PROJECT }} + run: python ishikawa_tools/ishikawa_tools_script.py + + - uses: actions/upload-artifact@v4 + with: + name: ishikawa-screenshots + path: ./ishikawa_tools/output + retention-days: 1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 139a9a57d..b129af05f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,3 +26,26 @@ jobs: - name: Test run: npm test + + - name: Comment to PR + if: failure() && github.event_name == 'pull_request' + uses: thollander/actions-comment-pull-request@v2 + with: + message: "⚠️ The tests have failed, @${{ github.actor }} Please review the proposed changes." + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Send message to Discord + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + if: failure() && github.event_name == 'pull_request' + uses: Ilshidur/action-discord@master + with: + args: | + ⚠️ The GitHub event `${{ github.event_name }}` has failed. + **Repository**: `${{ github.repository }}` + **Workflow**: `${{ github.workflow }}` + **Actor**: `${{ github.actor }}` + **Pull Request**: [Link](${{ github.event.pull_request.html_url }}) + **Action URL**: [View Details](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + Please review the proposed changes. + diff --git a/cypress.config.js b/cypress.config.js index 97f47c412..9313118a5 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -1,9 +1,16 @@ -const { defineConfig } = require("cypress"); +const { defineConfig } = require('cypress') +require('dotenv').config() module.exports = defineConfig({ + projectId: 'xmf2jf', e2e: { setupNodeEvents(on, config) { // implement node event listeners here }, }, -}); + env: { + // add environment variables here + ...process.env, + url: 'http://localhost:8080', + }, +}) diff --git a/cypress/e2e/auth.spec.cy.js b/cypress/e2e/auth.spec.cy.js new file mode 100644 index 000000000..8bcb31f60 --- /dev/null +++ b/cypress/e2e/auth.spec.cy.js @@ -0,0 +1,67 @@ +const authUser = require('../fixtures/authUser.json') +const url = Cypress.env('url') + +describe('Authentication Suite', () => { + describe('Registration', () => { + it('should allow a new user to register', () => { + cy.visit(url + '/signup') + const { email, password } = authUser + cy.get('form').findByLabelText(/e-mail/i).type(email) + cy.get('form').findByLabelText("Password").type(password) + cy.get('form').findByLabelText("Confirm your password").type(password) + cy.findByRole('button', {name: 'Sign-up'}).click() + cy.wait(500) + cy.contains(email) + cy.deleteUser(email, password) + }) + it('should reject registration with invalid password', () => { + cy.visit(url + '/signup') + const { email, invalidPassword } = authUser + cy.get('form').findByLabelText(/e-mail/i).type(email) + cy.get('form').findByLabelText("Password").type(invalidPassword) + cy.get('form').findByLabelText("Confirm your password").type(invalidPassword) + cy.findByRole('button', {name: 'Sign-up'}).click() + cy.contains('Password must be at least 6 characters') + }) + it('should reject registration when passwords do not match', () => { + cy.visit(url + '/signup') + const { email, password, invalidPassword } = authUser + cy.get('form').findByLabelText(/e-mail/i).type(email) + cy.get('form').findByLabelText("Password").type(password) + cy.get('form').findByLabelText("Confirm your password").type(invalidPassword) + cy.findByRole('button', {name: 'Sign-up'}).click() + cy.contains('Different passwords') + }) + }) + describe('Login', () => { + const { email, password } = authUser + beforeEach(() => { + cy.signup(email, password) + cy.logout() + cy.visit(url + '/signin') + }) + afterEach(() => { + cy.deleteUser(email, password) + }) + it('should allow a registered user to login', () => { + cy.get('form').findByLabelText(/e-mail/i).type(email) + cy.get('form').findByLabelText('Password').type(password) + cy.findByTestId('sign-in-button').click() + cy.wait(500) + cy.contains(email) + }) + it('should reject login with incorrect password', () => { + const { email, password, invalidPassword } = authUser + cy.get('form').findByLabelText(/e-mail/i).type(email) + cy.get('form').findByLabelText('Password').type(invalidPassword) + cy.findByTestId('sign-in-button').click() + cy.contains('Incorrect password') + }) + it('should reject login with unregistered email', () => { + cy.get('form').findByLabelText(/e-mail/i).type('noexist@noexist.com') + cy.get('form').findByLabelText('Password').type('noexist') + cy.findByTestId('sign-in-button').click() + cy.contains('Incorrect username or password') + }) + }) +}) diff --git a/cypress/e2e/heuristic.spec.cy.js b/cypress/e2e/heuristic.spec.cy.js new file mode 100644 index 000000000..a922e18d7 --- /dev/null +++ b/cypress/e2e/heuristic.spec.cy.js @@ -0,0 +1,31 @@ +const heuristic = require('../fixtures/heuristic.json') +const authUser = require('../fixtures/authUser.json') + +const url = Cypress.env('url') +const { email, password } = authUser + +describe('Heuristic test Suite', () => { + before('Signup into the app', () => { + cy.deleteUser(email, password) + cy.signup(email, password) + cy.login(email, password) + }) + after('Remove user', () => { + cy.deleteUser(email, password) + }) + describe('Create Heuristic Test', () => { + it('should allow a new test to create', () => { + cy.visit(url + '/testslist') + cy.findByTestId('create-test-btn').click() + cy.findByText('Create a blank test').click() + cy.findByText('Usability Heuristic').click() + + const { name, description } = heuristic + cy.findByLabelText(/Test name/i).type(name) + cy.findByLabelText(/Test Description/i).type(description) + cy.findByTestId('add-name-test-creation-btn').click() + + cy.contains(/Manager/i) + }) + }) +}) diff --git a/cypress/fixtures/authUser.json b/cypress/fixtures/authUser.json new file mode 100644 index 000000000..23f9dcd43 --- /dev/null +++ b/cypress/fixtures/authUser.json @@ -0,0 +1,13 @@ +{ + "email": "ruxailab@ruxailab.com", + "password": "ruxailab1234", + "invalidPassword": "123", + "data": { + "accessLevel": 1, + "email" : "ruxailab@ruxailab.com", + "myAnswers": {}, + "myTests": {}, + "notifications": [] + }, + "collection": "users" +} diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json deleted file mode 100644 index 02e425437..000000000 --- a/cypress/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" -} diff --git a/cypress/fixtures/heuristic.json b/cypress/fixtures/heuristic.json new file mode 100644 index 000000000..93fa06e05 --- /dev/null +++ b/cypress/fixtures/heuristic.json @@ -0,0 +1,4 @@ +{ + "name": "Heuristic Test", + "description": "This is a test of the heuristic test" +} diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 66ea16ef0..16f1fa204 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -22,4 +22,17 @@ // // // -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) \ No newline at end of file +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) + +import '@testing-library/cypress/add-commands' +import { deleteUser, + logInWithEmailAndPassword, + logOut, + signUpWithEmailAndPassword } + from './commands/auth' + + +Cypress.Commands.add('deleteUser', deleteUser) +Cypress.Commands.add('login', logInWithEmailAndPassword) +Cypress.Commands.add('logout', logOut) +Cypress.Commands.add('signup', signUpWithEmailAndPassword) diff --git a/cypress/support/commands/auth.js b/cypress/support/commands/auth.js new file mode 100644 index 000000000..f34db04b3 --- /dev/null +++ b/cypress/support/commands/auth.js @@ -0,0 +1,130 @@ +import { initializeApp } from 'firebase/app' +import { + getAuth, + connectAuthEmulator, + deleteUser as deleteUserAuth, + signInWithEmailAndPassword, + signOut, + createUserWithEmailAndPassword, +} from 'firebase/auth' +import { + getFirestore, + connectFirestoreEmulator, + doc, + setDoc, + deleteDoc, +} from 'firebase/firestore' + +const authUser = require('../../fixtures/authUser.json') + +const firebaseConfig = { + apiKey: Cypress.env('VUE_APP_FIREBASE_API_KEY'), + authDomain: Cypress.env('VUE_APP_FIREBASE_AUTH_DOMAIN'), + storageBucket: Cypress.env('VUE_APP_FIREBASE_STORAGE_BUCKET'), + projectId: Cypress.env('VUE_APP_FIREBASE_PROJECT_ID'), + appId: Cypress.env('VUE_APP_FIREBASE_APP_ID'), +} + +const firebaseApp = initializeApp(firebaseConfig) +const auth = getAuth(firebaseApp) +const db = getFirestore(firebaseApp) +if (window.Cypress) { + connectAuthEmulator(auth, 'http://localhost:9099') + connectFirestoreEmulator(db, 'localhost', 8081) +} + +/** + * Delete the currently logged-in user + * @returns {Promise} + */ +export const deleteUser = async (email, password) => { + try { + const { user } = await signInWithEmailAndPassword(auth, email, password) + const { collection } = authUser + await deleteDocById(collection, user.uid) + await deleteUserAuth(user) + console.info(`Deleted user with ID "${user.uid}"`) + } catch (err) { + console.error(err) + } +} + +/** + * Log in with email and password + * @param email + * @param password + * @returns {Promise} + */ +export const logInWithEmailAndPassword = async (email, password) => { + try { + await signInWithEmailAndPassword(auth, email, password).then(() => { + console.info(`Logged in as "${email}"`) + }) + } catch (err) { + console.error(err) + } +} + +/** + * Log out the currently logged-in user + * @returns {Promise} + */ +export const logOut = async () => { + try { + await signOut(auth).then(() => { + console.info('Logged out') + }) + } catch (err) { + console.error(err) + } +} + +/** + * Sign up with email and password + * @param email + * @param password + * @returns {Promise} + */ +export const signUpWithEmailAndPassword = async (email, password) => { + try { + const { user } = await createUserWithEmailAndPassword(auth, email, password) + console.info(`Signed up as "${email}" with user ID "${user.uid}"`) + const { data, collection } = authUser + await createDoc(collection, user.uid, data) + } catch (err) { + console.error(err) + } +} + +/** + * Create a document in a collection + * @param col + * @param docId + * @param data + * @returns {Promise} + */ +export const createDoc = async (col, docId, data) => { + try { + const ref = doc(db, `${col}/${docId}`) + await setDoc(ref, data) + } catch (err) { + console.error(err) + } +} + +/** + * Delete a document by ID + * @param col + * @param docId + * @returns {Promise} + */ +export const deleteDocById = async (col, docId) => { + try { + const ref = doc(db, `${col}/${docId}`) + await deleteDoc(ref) + } catch (err) { + console.error(err) + } +} + + diff --git a/ishikawa_tools/ishikawa_tools_script.py b/ishikawa_tools/ishikawa_tools_script.py new file mode 100644 index 000000000..b1b97dae2 --- /dev/null +++ b/ishikawa_tools/ishikawa_tools_script.py @@ -0,0 +1,183 @@ +import os +import pandas as pd +from datetime import datetime, timedelta +from github import Github +import matplotlib.pyplot as plt +import requests +from textwrap import wrap +from pathlib import Path + +def fetch_issues(repo_owner, repo_name, github_token): + url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/issues" + headers = {'Authorization': f'token {github_token}'} + response = requests.get(url, headers=headers) + if response.status_code != 200: + print(f"Failed to fetch issues. Status code: {response.status_code}") + print(response.json()) # Print the full error message + return None + else: + return response.json() + +def generate_pareto_diagram(issues): + labels_count = {} + for issue in issues: + for label in issue['labels']: + label_name = label['name'] + labels_count[label_name] = labels_count.get(label_name, 0) + 1 + + sorted_labels_count = dict(sorted(labels_count.items(), key=lambda item: item[1], reverse=True)) + + labels = list(sorted_labels_count.keys()) + counts = list(sorted_labels_count.values()) + + total_issues = sum(counts) + cumulative_percentage = [sum(counts[:i + 1]) / total_issues * 100 for i in range(len(counts))] + + _, ax1 = plt.subplots() + ax1.bar(labels, counts, color='b') + ax1.set_xlabel('Labels', fontsize=12) + ax1.set_ylabel('Frequency', color='b', fontsize=12) + ax1.tick_params(axis='y', colors='b') + + ax2 = ax1.twinx() + ax2.plot(labels, cumulative_percentage, color='r', marker='o') + ax2.set_ylabel('Cumulative Percentage (%)', color='r', fontsize=12) + ax2.tick_params(axis='y', colors='r') + + plt.title('Pareto Diagram of Issues by Labels - General Report', fontsize=14) + plt.xticks(rotation=45, ha='right', fontsize=8) + ax1.set_xticklabels(['\n'.join(wrap(label, 13)) for label in labels]) + plt.tight_layout() + plt.savefig('./ishikawa_tools/output/pereto.pdf') + +def generate_weekly_report(github_token, username, repository_name): + g = Github(github_token) + repo = g.get_repo(f"{username}/{repository_name}") + issues = repo.get_issues(state='all') + + weekly_counts = { + 'Monday': {'opened': 0, 'closed': 0}, + 'Tuesday': {'opened': 0, 'closed': 0}, + 'Wednesday': {'opened': 0, 'closed': 0}, + 'Thursday': {'opened': 0, 'closed': 0}, + 'Friday': {'opened': 0, 'closed': 0}, + 'Saturday': {'opened': 0, 'closed': 0}, + 'Sunday': {'opened': 0, 'closed': 0} + } + + def get_day_of_week(date_str): + date = datetime.strptime(date_str[:10], '%Y-%m-%d') + return date.strftime('%A') + + current_date = datetime.now() + one_week_ago = current_date - timedelta(days=7) + + for issue in issues: + created_at = issue.created_at.replace(tzinfo=None) + if created_at >= one_week_ago: + day_of_week = get_day_of_week(str(created_at)) + weekly_counts[day_of_week]['opened'] += 1 + + if issue.closed_at: + closed_at = issue.closed_at.replace(tzinfo=None) + if closed_at >= one_week_ago: + day_of_week = get_day_of_week(str(closed_at)) + weekly_counts[day_of_week]['closed'] += 1 + + dias = list(weekly_counts.keys()) + abiertas = [weekly_counts[d]["opened"] for d in weekly_counts] + cerradas = [weekly_counts[d]["closed"] for d in weekly_counts] + + data = { + "Día de la Semana": dias, + "Abiertas": abiertas, + "Cerradas": cerradas + } + + df = pd.DataFrame(data) + + fig, ax = plt.subplots() + ax.axis('off') + tabla = ax.table(cellText=df.values, colLabels=df.columns, cellLoc='center', loc='center') + + tabla.auto_set_font_size(False) + tabla.set_fontsize(12) + tabla.scale(1.5, 1.5) + plt.savefig('./ishikawa_tools/output/checklist.pdf') + +def generate_histogram_report(repo_owner, repo_name, github_token): + url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/issues" + headers = {'Authorization': f'token {github_token}'} + response = requests.get(url, headers=headers) + if response.status_code != 200: + print(f"Failed to fetch issues. Status code: {response.status_code}") + print(response.text) + return None + else: + issues = response.json() + + labels_count = {} + for issue in issues: + for label in issue['labels']: + label_name = label['name'] + labels_count[label_name] = labels_count.get(label_name, 0) + 1 + + plt.figure(figsize=(10, 6)) + plt.bar(labels_count.keys(), labels_count.values()) + plt.ylabel('Number of Issues') + plt.title('Histogram of Issues by Label - General Report') + plt.xticks(rotation=20) + plt.yticks(range(0, max(labels_count.values()) + 1)) + plt.savefig('./ishikawa_tools/output/histogram.pdf') + +def generate_scatter_diagram_report(repo_owner, repo_name, github_token): + url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/issues" + headers = {'Authorization': f'token {github_token}'} + response = requests.get(url, headers=headers) + if response.status_code != 200: + print(f"Failed to fetch issues. Status code: {response.status_code}") + print(response.text) + return None + else: + issues = response.json() + + days_to_close = [] + issue_created_dates = [] + + for issue in issues: + created_at = datetime.strptime(issue['created_at'], '%Y-%m-%dT%H:%M:%SZ') + closed_at = datetime.strptime(issue['closed_at'], '%Y-%m-%dT%H:%M:%SZ') if issue['closed_at'] else datetime.now() + time_to_close = (closed_at - created_at).days + days_to_close.append(time_to_close) + issue_created_dates.append(created_at) + + plt.figure(figsize=(10, 6)) + plt.scatter(days_to_close, issue_created_dates, color='blue', alpha=0.7) + plt.xlabel('Days for closing the issue') + plt.ylabel('Date of creation of the issue') + plt.title('Scatter Diagram of closed issues') + plt.grid(True) + plt.savefig('./ishikawa_tools/output/scatter.pdf') + +def main(): + github_token = os.getenv('TOKEN') + username = os.getenv('USER') + + if not all([github_token, username, repository_name]): + print("One or more environment variables are missing.") + return + + issues = fetch_issues(username, repository_name, github_token) + if issues is None: + print("Exiting script due to fetch issues failure.") + return + + Path("./ishikawa_tools/output").mkdir(parents=True, exist_ok=True) + + generate_pareto_diagram(issues) + generate_weekly_report(github_token, username, repository_name) + generate_histogram_report(username, repository_name, github_token) + generate_scatter_diagram_report(username, repository_name, github_token) + +if __name__ == "__main__": + main() diff --git a/ishikawa_tools/requirements.txt b/ishikawa_tools/requirements.txt new file mode 100644 index 000000000..ff9c84e03 --- /dev/null +++ b/ishikawa_tools/requirements.txt @@ -0,0 +1,4 @@ +matplotlib +pandas +requests +PyGithub \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json index 486c7bedb..1ea48a976 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -3,6 +3,7 @@ "baseUrl": "./", "paths": { "@/*": ["src/*"] - } + }, + "types": ["cypress", "@testing-library/cypress"] } } diff --git a/package.json b/package.json index 30c3dcd2c..f16154bbc 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ }, "devDependencies": { "@intlify/vue-i18n-loader": "^1.1.0", + "@testing-library/cypress": "^10.0.1", "@testing-library/dom": "^9.0.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/user-event": "12", @@ -60,7 +61,7 @@ "@vue/test-utils": "^1.3.0", "babel-eslint": "^8.2.6", "babel-plugin-transform-require-context": "^0.1.1", - "cypress": "^13.7.3", + "cypress": "^13.8.1", "eslint": "^6.7.2", "eslint-plugin-vue": "^6.2.2", "jsdoc": "^3.6.11", diff --git a/src/components/organisms/ModeratedCoopsView.vue b/src/components/organisms/ModeratedCoopsView.vue index 4c77db7ed..b6bce6981 100644 --- a/src/components/organisms/ModeratedCoopsView.vue +++ b/src/components/organisms/ModeratedCoopsView.vue @@ -48,7 +48,7 @@ mdi-arrow-right diff --git a/src/views/admin/DashboardView.vue b/src/views/admin/DashboardView.vue index 747509f80..83103d8df 100644 --- a/src/views/admin/DashboardView.vue +++ b/src/views/admin/DashboardView.vue @@ -16,6 +16,7 @@ v-bind="attrs" @click="goToCreateTestRoute()" v-on="on" + data-testid="create-test-btn" > mdi-plus diff --git a/src/views/public/SignInView.vue b/src/views/public/SignInView.vue index 755ee78b0..be588e37f 100644 --- a/src/views/public/SignInView.vue +++ b/src/views/public/SignInView.vue @@ -34,6 +34,7 @@