diff --git a/.github/scripts/add-release-label-to-pr-and-linked-issues.ts b/.github/scripts/add-release-label-to-pr-and-linked-issues.ts index a9b84532c8e..1c4d551828c 100644 --- a/.github/scripts/add-release-label-to-pr-and-linked-issues.ts +++ b/.github/scripts/add-release-label-to-pr-and-linked-issues.ts @@ -6,6 +6,7 @@ import { retrieveLinkedIssues } from './shared/issue'; import { Label } from './shared/label'; import { Labelable, addLabelToLabelable } from './shared/labelable'; import { retrievePullRequest } from './shared/pull-request'; +import { isValidVersionFormat } from './shared/utils'; main().catch((error: Error): void => { console.error(error); @@ -90,9 +91,3 @@ async function main(): Promise { await addLabelToLabelable(octokit, linkedIssue, releaseLabel); } } - -// This helper function checks if version has the correct format: "x.y.z" where "x", "y" and "z" are numbers. -function isValidVersionFormat(str: string): boolean { - const regex = /^\d+\.\d+\.\d+$/; - return regex.test(str); -} diff --git a/.github/scripts/check-template-and-add-labels.ts b/.github/scripts/check-template-and-add-labels.ts index fef8a5585d1..b931e27b055 100644 --- a/.github/scripts/check-template-and-add-labels.ts +++ b/.github/scripts/check-template-and-add-labels.ts @@ -12,6 +12,8 @@ import { } from './shared/labelable'; import { Label, + RegressionStage, + craftRegressionLabel, externalContributorLabel, invalidIssueTemplateLabel, invalidPullRequestTemplateLabel, @@ -19,14 +21,6 @@ import { import { TemplateType, templates } from './shared/template'; import { retrievePullRequest } from './shared/pull-request'; -enum RegressionStage { - DevelopmentFeature, - DevelopmentMain, - Testing, - Beta, - Production -} - const knownBots = ["metamaskbot", "dependabot", "github-actions", "sentry-io", "devin-ai-integration"]; main().catch((error: Error): void => { @@ -316,50 +310,3 @@ async function userBelongsToMetaMaskOrg( return Boolean(userBelongsToMetaMaskOrgResult?.user?.organization?.id); } - -// This function crafts appropriate label, corresponding to regression stage and release version. -function craftRegressionLabel(regressionStage: RegressionStage | undefined, releaseVersion: string | undefined): Label { - switch (regressionStage) { - case RegressionStage.DevelopmentFeature: - return { - name: `feature-branch-bug`, - color: '5319E7', // violet - description: `bug that was found on a feature branch, but not yet merged in main branch`, - }; - - case RegressionStage.DevelopmentMain: - return { - name: `regression-develop`, - color: '5319E7', // violet - description: `Regression bug that was found on main branch, but not yet present in production`, - }; - - case RegressionStage.Testing: - return { - name: `regression-RC-${releaseVersion || '*'}`, - color: '744C11', // orange - description: releaseVersion ? `Regression bug that was found in release candidate (RC) for release ${releaseVersion}` : `TODO: Unknown release version. Please replace with correct 'regression-RC-x.y.z' label, where 'x.y.z' is the number of the release where bug was found.`, - }; - - case RegressionStage.Beta: - return { - name: `regression-beta-${releaseVersion || '*'}`, - color: 'D94A83', // pink - description: releaseVersion ? `Regression bug that was found in beta in release ${releaseVersion}` : `TODO: Unknown release version. Please replace with correct 'regression-beta-x.y.z' label, where 'x.y.z' is the number of the release where bug was found.`, - }; - - case RegressionStage.Production: - return { - name: `regression-prod-${releaseVersion || '*'}`, - color: '5319E7', // violet - description: releaseVersion ? `Regression bug that was found in production in release ${releaseVersion}` : `TODO: Unknown release version. Please replace with correct 'regression-prod-x.y.z' label, where 'x.y.z' is the number of the release where bug was found.`, - }; - - default: - return { - name: `regression-*`, - color: 'EDEDED', // grey - description: `TODO: Unknown regression stage. Please replace with correct regression label: 'regression-develop', 'regression-RC-x.y.z', or 'regression-prod-x.y.z' label, where 'x.y.z' is the number of the release where bug was found.`, - }; - } -} diff --git a/.github/scripts/create-bug-report-issue.ts b/.github/scripts/create-bug-report-issue.ts new file mode 100644 index 00000000000..7af8456a6bf --- /dev/null +++ b/.github/scripts/create-bug-report-issue.ts @@ -0,0 +1,129 @@ +import * as core from '@actions/core'; +import { context, getOctokit } from '@actions/github'; +import { GitHub } from '@actions/github/lib/utils'; + +import { createIssue, retrieveIssueByTitle } from './shared/issue'; +import { + Label, + RegressionStage, + craftRegressionLabel, + craftTeamLabel, + createOrRetrieveLabel, + typeBugLabel, +} from './shared/label'; +import { codeRepoToPlanningRepo, codeRepoToPlatform, getCurrentDateFormatted, isValidVersionFormat } from './shared/utils'; +import { addIssueToGithubProject, GithubProject, GithubProjectField, retrieveGithubProject, updateGithubProjectDateFieldValue } from './shared/project'; + +main().catch((error: Error): void => { + console.error(error); + process.exit(1); +}); + +async function main(): Promise { + // "GITHUB_TOKEN" is an automatically generated, repository-specific access token provided by GitHub Actions. + // We can't use "GITHUB_TOKEN" here, as its permissions don't allow neither to create new labels + // nor to retrieve the content of organisations Github Projects. + // In our case, we may want to create "regression-RC-x.y.z" label when it doesn't already exist. + // We may also want to retrieve the content of organisation's Github Projects. + // As a consequence, we need to create our own "BUG_REPORT_TOKEN" with "repo" and "read:org" permissions. + // Such a token allows both to create new labels and fetch the content of organisation's Github Projects. + const personalAccessToken = process.env.BUG_REPORT_TOKEN; + if (!personalAccessToken) { + core.setFailed('BUG_REPORT_TOKEN not found'); + process.exit(1); + } + + const projectNumber = Number(process.env.RELEASES_GITHUB_PROJECT_BOARD_NUMBER); + if (!projectNumber) { + core.setFailed('RELEASES_GITHUB_PROJECT_BOARD_NUMBER not found'); + process.exit(1); + } + + const projectViewNumber = Number(process.env.RELEASES_GITHUB_PROJECT_BOARD_VIEW_NUMBER); + if (!projectViewNumber) { + core.setFailed('RELEASES_GITHUB_PROJECT_BOARD_VIEW_NUMBER not found'); + process.exit(1); + } + + const releaseVersion = process.env.RELEASE_VERSION; + if (!releaseVersion) { + core.setFailed('RELEASE_VERSION not found'); + process.exit(1); + } + if (!isValidVersionFormat(releaseVersion)) { + core.setFailed(`Invalid format for RELEASE_VERSION: ${releaseVersion}. Expected format: x.y.z`); + process.exit(1); + } + + const repoOwner = context.repo.owner; + if (!repoOwner) { + core.setFailed('repo owner not found'); + process.exit(1); + } + const codeRepoName = context.repo.repo; + if (!codeRepoName) { + core.setFailed('code repo name not found'); + process.exit(1); + } + const planningRepoName = codeRepoToPlanningRepo[codeRepoName]; + if (!planningRepoName) { + core.setFailed('planning repo name not found'); + process.exit(1); + } + + // Retrieve platform name + const platformName = codeRepoToPlatform[codeRepoName]; + if (!platformName) { + core.setFailed('platform name not found'); + process.exit(1); + } + + // Initialise octokit, required to call Github GraphQL API + const octokit: InstanceType = getOctokit(personalAccessToken, { + previews: ['bane'], // The "bane" preview is required for adding, updating, creating and deleting labels. + }); + + // Craft regression labels to add + const regressionLabelTesting: Label = craftRegressionLabel(RegressionStage.Testing, releaseVersion); + const regressionLabelProduction: Label = craftRegressionLabel(RegressionStage.Production, releaseVersion); + const teamLabel: Label = craftTeamLabel(`${platformName}-platform`); + + // Create of retrieve the different labels + await createOrRetrieveLabel(octokit, repoOwner, codeRepoName, regressionLabelProduction); + await createOrRetrieveLabel(octokit, repoOwner, codeRepoName, regressionLabelTesting); + await createOrRetrieveLabel(octokit, repoOwner, planningRepoName, regressionLabelProduction); + const regressionLabelTestingId = await createOrRetrieveLabel(octokit, repoOwner, planningRepoName, regressionLabelTesting); + const typeBugLabelId = await createOrRetrieveLabel(octokit, repoOwner, planningRepoName, typeBugLabel); + const teamLabelId = await createOrRetrieveLabel(octokit, repoOwner, planningRepoName, teamLabel); + + const issueTitle = `v${releaseVersion} Bug Report`; + const issueWithSameTitle = await retrieveIssueByTitle(octokit, repoOwner, planningRepoName, issueTitle); + if (issueWithSameTitle) { + core.setFailed(`Bug report already exists: https://github.com/${repoOwner}/${planningRepoName}/issues/${issueWithSameTitle.number}. This is not desired, but can happen in cases where a release gets re-cut.`); + process.exit(1); + } + + const issueBody = `**What is this bug report issue for?**\n\n1. This issue is used to track release dates on this [Github Project board](https://github.com/orgs/MetaMask/projects/${projectNumber}/views/${projectViewNumber}), which content then gets pulled into our metrics system.\n\n2. This issue is also used by our Zapier automations, to determine if automated notifications shall be sent on Slack for release \`${releaseVersion}\`. Notifications will only be sent as long as this issue is open.\n\n**Who created and/or closed this issue?**\n\n- This issue was automatically created by a GitHub action upon the creation of the release branch \`release/${releaseVersion}\`, indicating the release was cut.\n\n- This issue gets automatically closed by another GitHub action, once the \`release/${releaseVersion}\` branch merges into \`main\`, indicating the release is prepared for store submission.`; + const issueId = await createIssue(octokit, repoOwner, planningRepoName, issueTitle, issueBody, [regressionLabelTestingId, typeBugLabelId, teamLabelId]); + + // Retrieve project, in order to obtain its ID + const project: GithubProject = await retrieveGithubProject(octokit, projectNumber); + + const projectFieldName: string = "RC Cut"; + + const projectField: GithubProjectField | undefined = project.fields.find(field => field.name === projectFieldName); + + if (!projectField) { + throw new Error(`Project field with name ${projectFieldName} was not found on Github Project with ID ${project.id}.`); + } + + if (!projectField.id) { + throw new Error(`Project field with name ${projectFieldName} was found on Github Project with ID ${project.id}, but it has no 'id' property.`); + } + + // Add bug report issue to 'Releases' Github Project Board + await addIssueToGithubProject(octokit, project.id, issueId); + + // Update bug report issue's date property on 'Releases' Github Project Board + await updateGithubProjectDateFieldValue(octokit, project.id, projectField.id, issueId, getCurrentDateFormatted()); +} diff --git a/.github/scripts/package.json b/.github/scripts/package.json index e2bf8d85b07..69b5158174d 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -7,6 +7,7 @@ "check-pr-has-required-labels": "ts-node ./check-pr-has-required-labels.ts", "close-release-bug-report-issue": "ts-node ./close-release-bug-report-issue.ts", "check-template-and-add-labels": "ts-node ./check-template-and-add-labels.ts", + "create-bug-report-issue": "ts-node ./create-bug-report-issue.ts", "run-bitrise-e2e-check": "ts-node ./bitrise/run-bitrise-e2e-check.ts" }, "dependencies": { diff --git a/.github/scripts/shared/issue.ts b/.github/scripts/shared/issue.ts index e5c6804630e..809667a24b8 100644 --- a/.github/scripts/shared/issue.ts +++ b/.github/scripts/shared/issue.ts @@ -1,6 +1,30 @@ import { GitHub } from '@actions/github/lib/utils'; import { LabelableType, Labelable } from './labelable'; +import { retrieveRepo } from './repo'; + +interface RawIssue { + id: string; + title: string; + number: number; + createdAt: string; + body: string; + author: { + login: string; + }; + labels: { + nodes: { + id: string; + name: string; + }[]; + }; + repository: { + name: string; + owner: { + login: string; + }; + }; +} // This function retrieves an issue on a specific repo export async function retrieveIssue( @@ -14,6 +38,8 @@ export async function retrieveIssue( repository(owner: $repoOwner, name: $repoName) { issue(number: $issueNumber) { id + title + number createdAt body author { @@ -25,6 +51,12 @@ export async function retrieveIssue( name } } + repository { + name + owner { + login + } + } } } } @@ -32,20 +64,7 @@ export async function retrieveIssue( const retrieveIssueResult: { repository: { - issue: { - id: string; - createdAt: string; - body: string; - author: { - login: string; - }; - labels: { - nodes: { - id: string; - name: string; - }[]; - }; - }; + issue: RawIssue; }; } = await octokit.graphql(retrieveIssueQuery, { repoOwner, @@ -68,6 +87,78 @@ export async function retrieveIssue( return issue; } +// This function retrieves an issue by title on a specific repo +export async function retrieveIssueByTitle( + octokit: InstanceType, + repoOwner: string, + repoName: string, + issueTitle: string, +): Promise { + const searchQuery = `repo:${repoOwner}/${repoName} type:issue in:title ${issueTitle}`; + + const retrieveIssueByTitleQuery = ` + query GetIssueByTitle($searchQuery: String!) { + search( + query: $searchQuery + type: ISSUE + first: 10 + ) { + nodes { + ... on Issue { + id + title + number + createdAt + body + author { + login + } + labels(first: 100) { + nodes { + id + name + } + } + repository { + name + owner { + login + } + } + } + } + issueCount + } + } + `; + + const retrieveIssueByTitleResult: { + search: { + nodes: RawIssue[]; + }; + } = await octokit.graphql(retrieveIssueByTitleQuery, { + searchQuery, + }); + + const issueWithSameTitle = retrieveIssueByTitleResult?.search?.nodes?.find(rawIssue => rawIssue.title === issueTitle); + + const issue: Labelable | undefined = issueWithSameTitle + ? { + id: issueWithSameTitle?.id, + type: LabelableType.Issue, + number: issueWithSameTitle?.number, + repoOwner: repoOwner, + repoName: repoName, + createdAt: issueWithSameTitle?.createdAt, + body: issueWithSameTitle?.body, + author: issueWithSameTitle?.author?.login, + labels: issueWithSameTitle?.labels?.nodes, + } + : undefined; + + return issue; +} + // This function retrieves the list of linked issues for a pull request export async function retrieveLinkedIssues( octokit: InstanceType, @@ -75,6 +166,7 @@ export async function retrieveLinkedIssues( repoName: string, prNumber: number, ): Promise { + // We assume there won't be more than 100 linked issues const retrieveLinkedIssuesQuery = ` query ($repoOwner: String!, $repoName: String!, $prNumber: Int!) { @@ -83,6 +175,7 @@ export async function retrieveLinkedIssues( closingIssuesReferences(first: 100) { nodes { id + title number createdAt body @@ -112,27 +205,7 @@ export async function retrieveLinkedIssues( repository: { pullRequest: { closingIssuesReferences: { - nodes: Array<{ - id: string; - number: number; - createdAt: string; - body: string; - author: { - login: string; - }; - labels: { - nodes: { - id: string; - name: string; - }[]; - }; - repository: { - name: string; - owner: { - login: string; - }; - }; - }>; + nodes: RawIssue[]; }; }; }; @@ -144,27 +217,7 @@ export async function retrieveLinkedIssues( const linkedIssues: Labelable[] = retrieveLinkedIssuesResult?.repository?.pullRequest?.closingIssuesReferences?.nodes?.map( - (issue: { - id: string; - number: number; - createdAt: string; - body: string; - author: { - login: string; - }; - labels: { - nodes: { - id: string; - name: string; - }[]; - }; - repository: { - name: string; - owner: { - login: string; - }; - }; - }) => { + (issue: RawIssue) => { return { id: issue?.id, type: LabelableType.Issue, @@ -181,3 +234,60 @@ export async function retrieveLinkedIssues( return linkedIssues; } + +// This function creates an issue on a specific repo +export async function createIssue( + octokit: InstanceType, + repoOwner: string, + repoName: string, + issueTitle: string, + issueBody: string, + labelIds: string[], +): Promise { + // Retrieve PR's repo + const repoId = await retrieveRepo(octokit, repoOwner, repoName); + + const createIssueMutation = ` + mutation CreateIssue($repoId: ID!, $issueTitle: String!, $issueBody: String!, $labelIds: [ID!]) { + createIssue(input: {repositoryId: $repoId, title: $issueTitle, body: $issueBody, labelIds: $labelIds}) { + issue { + id + title + number + createdAt + body + author { + login + } + labels(first: 100) { + nodes { + id + name + } + } + repository { + name + owner { + login + } + } + } + } + } + `; + + const createIssueResult: { + createIssue: { + issue: RawIssue; + }; + } = await octokit.graphql(createIssueMutation, { + repoId, + issueTitle, + issueBody, + labelIds, + }); + + const issueId = createIssueResult?.createIssue?.issue?.id; + + return issueId; +} diff --git a/.github/scripts/shared/label.ts b/.github/scripts/shared/label.ts index 7b9111f7680..6aa1fbfacc6 100644 --- a/.github/scripts/shared/label.ts +++ b/.github/scripts/shared/label.ts @@ -2,12 +2,26 @@ import { GitHub } from '@actions/github/lib/utils'; import { retrieveRepo } from './repo'; +export enum RegressionStage { + DevelopmentFeature, + DevelopmentMain, + Testing, + Beta, + Production +} + export interface Label { name: string; color: string; description: string; } +export const typeBugLabel: Label = { + name: 'type-bug', + color: 'D73A4A', + description: `Something isn't working`, +}; + export const externalContributorLabel: Label = { name: 'external-contributor', color: '7057FF', @@ -26,6 +40,79 @@ export const invalidPullRequestTemplateLabel: Label = { description: "PR's body doesn't match template", }; +// This function crafts appropriate label, corresponding to regression stage and release version. +export function craftRegressionLabel(regressionStage: RegressionStage | undefined, releaseVersion: string | undefined): Label { + switch (regressionStage) { + case RegressionStage.DevelopmentFeature: + return { + name: `feature-branch-bug`, + color: '5319E7', // violet + description: `bug that was found on a feature branch, but not yet merged in main branch`, + }; + + case RegressionStage.DevelopmentMain: + return { + name: `regression-develop`, + color: '5319E7', // violet + description: `Regression bug that was found on main branch, but not yet present in production`, + }; + + case RegressionStage.Testing: + return { + name: `regression-RC-${releaseVersion || '*'}`, + color: '744C11', // orange + description: releaseVersion ? `Regression bug that was found in release candidate (RC) for release ${releaseVersion}` : `TODO: Unknown release version. Please replace with correct 'regression-RC-x.y.z' label, where 'x.y.z' is the number of the release where bug was found.`, + }; + + case RegressionStage.Beta: + return { + name: `regression-beta-${releaseVersion || '*'}`, + color: 'D94A83', // pink + description: releaseVersion ? `Regression bug that was found in beta in release ${releaseVersion}` : `TODO: Unknown release version. Please replace with correct 'regression-beta-x.y.z' label, where 'x.y.z' is the number of the release where bug was found.`, + }; + + case RegressionStage.Production: + return { + name: `regression-prod-${releaseVersion || '*'}`, + color: '5319E7', // violet + description: releaseVersion ? `Regression bug that was found in production in release ${releaseVersion}` : `TODO: Unknown release version. Please replace with correct 'regression-prod-x.y.z' label, where 'x.y.z' is the number of the release where bug was found.`, + }; + + default: + return { + name: `regression-*`, + color: 'EDEDED', // grey + description: `TODO: Unknown regression stage. Please replace with correct regression label: 'regression-develop', 'regression-RC-x.y.z', or 'regression-prod-x.y.z' label, where 'x.y.z' is the number of the release where bug was found.`, + }; + } +} + +// This function crafts appropriate label, corresponding to team name. +export function craftTeamLabel(teamName: string): Label { + switch (teamName) { + case 'extension-platform': + return { + name: `team-${teamName}`, + color: '#BFD4F2', // light blue + description: `Extension Platform team`, + }; + + case 'mobile-platform': + return { + name: `team-${teamName}`, + color: '#76E9D0', // light green + description: `Mobile Platform team`, + }; + + default: + return { + name: `team-*`, + color: 'EDEDED', // grey + description: `TODO: Unknown team. Please replace with correct team label.`, + }; + } +} + // This function creates or retrieves the label on a specific repo export async function createOrRetrieveLabel( octokit: InstanceType, diff --git a/.github/scripts/shared/project.ts b/.github/scripts/shared/project.ts new file mode 100644 index 00000000000..5b8763af7c9 --- /dev/null +++ b/.github/scripts/shared/project.ts @@ -0,0 +1,268 @@ +import { GitHub } from '@actions/github/lib/utils'; +import { isValidDateFormat } from './utils'; + +const MAX_NB_FETCHES = 10; // For protection against infinite loops. + +export interface GithubProject { + id: string; + fields: GithubProjectField[]; +} + +export interface GithubProjectField { + id: string; + name: string; +} + +export interface GithubProjectIssueFieldValues { + id: string; // ID of the issue (unrelated to the Github Project board) + itemId: string; // ID of the issue, as an item of the Github Project board + cutDate: string; // "RC cut date" field value of the issue, as an item of the Github Project board +} + +interface RawGithubProjectIssueFieldValues { + id: string; + content: { + id: string; + }; + cutDate: { + date: string; + }; +} + +interface RawGithubProjectIssuesFieldValues { + pageInfo: { + endCursor: string; + }; + nodes: RawGithubProjectIssueFieldValues[]; +} + +// This function retrieves a Github Project +export async function retrieveGithubProject( + octokit: InstanceType, + projectNumber: number, +): Promise { + const retrieveProjectQuery = ` + query ($projectNumber: Int!) { + organization(login: "MetaMask") { + projectV2(number: $projectNumber) { + id + fields(first: 20) { + nodes { + ... on ProjectV2Field { + id + name + } + } + } + } + } + } + `; + + const retrieveProjectResult: { + organization: { + projectV2: { + id: string; + fields: { + nodes: { + id: string; + name: string; + }[]; + }; + }; + }; + } = await octokit.graphql(retrieveProjectQuery, { + projectNumber, + }); + + const project: GithubProject = { + id: retrieveProjectResult?.organization?.projectV2?.id, + fields: retrieveProjectResult?.organization.projectV2?.fields?.nodes, + }; + + if (!project) { + throw new Error(`Project with number ${projectNumber} was not found.`); + } + + if (!project.id) { + throw new Error(`Project with number ${projectNumber} was found, but it has no 'id' property.`); + } + + if (!project.fields) { + throw new Error(`Project with number ${projectNumber} was found, but it has no 'fields' property.`); + } + + return project; +} + +// This function retrieves a Github Project's issues' field values +export async function retrieveGithubProjectIssuesFieldValues( + octokit: InstanceType, + projectId: string, + cursor: string | undefined, +): Promise { + const after = cursor ? `after: "${cursor}"` : ''; + + const retrieveProjectIssuesFieldValuesQuery = ` + query ($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + items( + first: 100 + ${after} + ) { + pageInfo { + endCursor + } + nodes { + id + content { + ... on Issue { + id + } + } + cutDate: fieldValueByName(name: "RC Cut") { + ... on ProjectV2ItemFieldDateValue { + date + } + } + } + } + } + } + } + `; + + const retrieveProjectIssuesFieldValuesResult: { + node: { + items: { + totalCount: number; + pageInfo: { + endCursor: string; + }; + nodes: { + id: string; + content: { + id: string; + }; + cutDate: { + date: string; + }; + }[]; + }; + }; + } = await octokit.graphql(retrieveProjectIssuesFieldValuesQuery, { + projectId, + }); + + const projectIssuesFieldValues: RawGithubProjectIssuesFieldValues = retrieveProjectIssuesFieldValuesResult.node.items; + + return projectIssuesFieldValues; +} + +// This function retrieves a Github Project's issue field values recursively +export async function retrieveGithubProjectIssueFieldValuesRecursively( + nbFetches: number, + octokit: InstanceType, + projectId: string, + issueId: string, + cursor: string | undefined, +): Promise { + if (nbFetches >= MAX_NB_FETCHES) { + throw new Error(`Forbidden: Trying to do more than ${MAX_NB_FETCHES} fetches (${nbFetches}).`); + } + + const projectIssuesFieldValuesResponse: RawGithubProjectIssuesFieldValues = await retrieveGithubProjectIssuesFieldValues( + octokit, + projectId, + cursor, + ); + + const projectIssueFieldValuesResponseWithSameId: RawGithubProjectIssueFieldValues | undefined = + projectIssuesFieldValuesResponse.nodes.find((issue) => issue.content.id === issueId); + + if (projectIssueFieldValuesResponseWithSameId) { + const projectIssueFieldValues: GithubProjectIssueFieldValues = { + id: projectIssueFieldValuesResponseWithSameId.content?.id, + itemId: projectIssueFieldValuesResponseWithSameId.id, + cutDate: projectIssueFieldValuesResponseWithSameId.cutDate?.date, + }; + return projectIssueFieldValues; + } + + const newCursor = projectIssuesFieldValuesResponse.pageInfo.endCursor; + if (newCursor) { + return await retrieveGithubProjectIssueFieldValuesRecursively(nbFetches + 1, octokit, projectId, issueId, newCursor); + } else { + return undefined; + } +} + +// This function adds an issue to a Github Project +export async function addIssueToGithubProject( + octokit: InstanceType, + projectId: string, + issueId: string, +): Promise { + const addIssueToProjectMutation = ` + mutation ($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + clientMutationId + } + } + `; + + await octokit.graphql(addIssueToProjectMutation, { + projectId: projectId, + contentId: issueId, + }); +} + +// This function updates Github Project issue's date field value +export async function updateGithubProjectDateFieldValue( + octokit: InstanceType, + projectId: string, + projectFieldId: string, + issueId: string, + newDatePropertyValue: string, +): Promise { + if (!isValidDateFormat(newDatePropertyValue)) { + throw new Error(`Invalid input: date ${newDatePropertyValue} doesn't match "YYYY-MM-DD" format.`); + } + + const issue: GithubProjectIssueFieldValues | undefined = await retrieveGithubProjectIssueFieldValuesRecursively( + 0, + octokit, + projectId, + issueId, + undefined, + ); + + if (!issue) { + throw new Error(`Issue with ID ${issueId} was not found on Github Project with ID ${projectId}.`); + } + + const updateGithubProjectDatePropertyMutation = ` + mutation ($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { date: $date } + } + ) { + projectV2Item { + id + } + } + } + `; + + await octokit.graphql(updateGithubProjectDatePropertyMutation, { + projectId: projectId, + itemId: issue.itemId, + fieldId: projectFieldId, + date: newDatePropertyValue, + }); +} diff --git a/.github/scripts/shared/repo.ts b/.github/scripts/shared/repo.ts index 09e47535001..d87a7fb5a19 100644 --- a/.github/scripts/shared/repo.ts +++ b/.github/scripts/shared/repo.ts @@ -25,5 +25,9 @@ export async function retrieveRepo( const repoId = retrieveRepoResult?.repository?.id; + if (!repoId) { + throw new Error(`Repo with owner ${repoOwner} and name ${repoName} was not found.`); + } + return repoId; } diff --git a/.github/scripts/shared/utils.ts b/.github/scripts/shared/utils.ts new file mode 100644 index 00000000000..6ecc4f4d092 --- /dev/null +++ b/.github/scripts/shared/utils.ts @@ -0,0 +1,50 @@ +// This helper function checks if version has the correct format: "x.y.z" where "x", "y" and "z" are numbers. +export function isValidVersionFormat(str: string): boolean { + const regex = /^\d+\.\d+\.\d+$/; + return regex.test(str); +} + +// This helper function checks if a string has the date format "YYYY-MM-DD". +export function isValidDateFormat(dateString: string): boolean { + // Regular expression to match the date format "YYYY-MM-DD" + const dateFormatRegex = /^\d{4}-\d{2}-\d{2}$/; + + // Check if the dateString matches the regex + if (!dateFormatRegex.test(dateString)) { + return false; + } + + // Parse the date components + const [year, month, day] = dateString.split('-').map(Number); + + // Check if the date components form a valid date + const date = new Date(year, month - 1, day); + return ( + date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day + ); +} + +// This helper function generates the current date in that format: "YYYY-MM-DD" +export function getCurrentDateFormatted(): string { + const date = new Date(); + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are zero-based, so add 1 + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +// This mapping is used to know what planning repo is used for each code repo +export const codeRepoToPlanningRepo: { [key: string]: string } = { + "metamask-extension": "MetaMask-planning", + "metamask-mobile": "mobile-planning" +} + +// This mapping is used to know what platform each code repo is used for +export const codeRepoToPlatform: { [key: string]: string } = { + "metamask-extension": "extension", + "metamask-mobile": "mobile", +} diff --git a/.github/workflows/create-bug-report.yml b/.github/workflows/create-bug-report.yml index 3de17a8f872..7133d0e3732 100644 --- a/.github/workflows/create-bug-report.yml +++ b/.github/workflows/create-bug-report.yml @@ -12,24 +12,32 @@ jobs: if [[ "$GITHUB_REF" =~ ^refs/heads/release/[0-9]+\.[0-9]+\.[0-9]+$ ]]; then version="${GITHUB_REF#refs/heads/release/}" echo "New release branch($version), continue next steps" - echo "version=$version" >> "$GITHUB_ENV" + echo "version=$version" >> "$GITHUB_OUTPUT" else echo "Not a release branch, skip next steps" + exit 1 fi + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 1 # This retrieves only the latest commit. + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + cache: yarn + + - name: Install dependencies + run: yarn --immutable + - name: Create bug report issue on planning repo - if: env.version - run: | - payload=$(cat < { accessibilityRole="button" accessible onPress={handleChangeRegion} + testID={BuildQuoteSelectors.REGION_DROPDOWN} > {selectedRegion?.emoji} @@ -835,7 +837,7 @@ const BuildQuote = () => { )} {!hasInsufficientBalance && amountIsBelowMinimum && limits && ( - + {isBuy ? ( <> {strings('fiat_on_ramp_aggregator.minimum')}{' '} @@ -850,7 +852,7 @@ const BuildQuote = () => { )} {!hasInsufficientBalance && amountIsAboveMaximum && limits && ( - + {isBuy ? ( <> {strings('fiat_on_ramp_aggregator.maximum')}{' '} diff --git a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index 822f6978430..cdf68bb6632 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -8406,6 +8406,7 @@ exports[`BuildQuote View renders correctly 1`] = ` accessibilityRole="button" accessible={true} onPress={[Function]} + testID="region-dropdown" > Minimum deposit is @@ -11445,6 +11449,7 @@ exports[`BuildQuote View renders correctly 2`] = ` accessibilityRole="button" accessible={true} onPress={[Function]} + testID="region-dropdown" > Enter a larger amount to continue diff --git a/app/components/UI/Ramp/Views/Quotes/Quotes.tsx b/app/components/UI/Ramp/Views/Quotes/Quotes.tsx index 20476b2c9f6..2f679e1803c 100644 --- a/app/components/UI/Ramp/Views/Quotes/Quotes.tsx +++ b/app/components/UI/Ramp/Views/Quotes/Quotes.tsx @@ -71,6 +71,7 @@ import { PROVIDER_LINKS, ScreenLocation } from '../../types'; import Logger from '../../../../../util/Logger'; import { isBuyQuote } from '../../utils'; import { getOrdersProviders } from './../../../../../reducers/fiatOrders'; +import { QuoteSelectors } from '../../../../../../e2e/selectors/Ramps/Quotes.selectors'; const HIGHLIGHTED_QUOTES_COUNT = 2; export interface QuotesParams { @@ -762,7 +763,7 @@ function Quotes() { /> )} - + {isFetchingQuotes && isInPolling ? ( ) : ( diff --git a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap index 1fc726e4293..84261dd2f52 100644 --- a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap @@ -3499,6 +3499,7 @@ exports[`Quotes renders correctly after animation with quotes 1`] = ` collapsable={false} onGestureHandlerEvent={[Function]} onGestureHandlerStateChange={[Function]} + testID="quotes" > = ({ accessibilityRole="button" onPress={onPress} hitSlop={{ top: 20, left: 20, right: 20, bottom: 20 }} + testID={BuildQuoteSelectors.AMOUNT_INPUT} > = ({ disabled={!onCurrencyPress} onPress={onCurrencyPress} hitSlop={{ top: 20, left: 20, right: 20, bottom: 20 }} + testID={BuildQuoteSelectors.SELECT_CURRENCY} > diff --git a/app/components/Views/confirmations/Confirm/Confirm.styles.ts b/app/components/Views/confirmations/Confirm/Confirm.styles.ts index 212d4cfd2d0..14c6d22b1ef 100644 --- a/app/components/Views/confirmations/Confirm/Confirm.styles.ts +++ b/app/components/Views/confirmations/Confirm/Confirm.styles.ts @@ -40,8 +40,8 @@ const styleSheet = (params: { minHeight: '100%', }, scrollWrapper: { - minHeight: isFlatConfirmation ? '80%' : '75%', - maxHeight: isFlatConfirmation ? '80%' : '75%', + minHeight: isFlatConfirmation ? '100%' : '75%', + maxHeight: isFlatConfirmation ? '100%' : '75%', margin: 0, }, }); diff --git a/app/components/Views/confirmations/Confirm/Confirm.tsx b/app/components/Views/confirmations/Confirm/Confirm.tsx index 367dd910955..f6e5c9a31bf 100644 --- a/app/components/Views/confirmations/Confirm/Confirm.tsx +++ b/app/components/Views/confirmations/Confirm/Confirm.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; import { TransactionType } from '@metamask/transaction-controller'; import { useStyles } from '../../../../component-library/hooks'; @@ -57,12 +56,12 @@ const Confirm = () => { if (isFlatConfirmation) { return ( - - + ); } diff --git a/app/components/Views/confirmations/components/Confirm/FlatNavHeader/FlatNavHeader.styles.ts b/app/components/Views/confirmations/components/Confirm/FlatNavHeader/FlatNavHeader.styles.ts new file mode 100644 index 00000000000..95d577e66ac --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/FlatNavHeader/FlatNavHeader.styles.ts @@ -0,0 +1,20 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 4, + marginBottom: 16, + }, + left: { + position: 'absolute', + left: 0, + }, + title: { + textAlign: 'center', + }, + }); + +export default styleSheet; diff --git a/app/components/Views/confirmations/components/Confirm/FlatNavHeader/FlatNavHeader.test.tsx b/app/components/Views/confirmations/components/Confirm/FlatNavHeader/FlatNavHeader.test.tsx new file mode 100644 index 00000000000..fef65aeb2f7 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/FlatNavHeader/FlatNavHeader.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; + +import { useConfirmActions } from '../../../hooks/useConfirmActions'; +import FlatNavHeader from './FlatNavHeader'; + +jest.mock('../../../hooks/useConfirmActions', () => ({ + useConfirmActions: jest.fn(), +})); + +describe('FlatNavHeader', () => { + const mockUseConfirmActions = jest.mocked(useConfirmActions); + const mockReject = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseConfirmActions.mockReturnValue({ + onReject: mockReject, + } as unknown as ReturnType); + }); + + it('renders the title correctly', () => { + const { getByText } = render(); + expect(getByText('Title')).toBeTruthy(); + }); + + it('calls onLeftPress when the button is pressed', () => { + const onLeftPressMock = jest.fn(); + const { getByTestId } = render( + , + ); + + const button = getByTestId('flat-nav-header-back-button'); + fireEvent.press(button); + + expect(onLeftPressMock).toHaveBeenCalled(); + }); + + it('calls onReject when the button is pressed and onLeftPress is not provided', () => { + const { getByTestId } = render(); + + const button = getByTestId('flat-nav-header-back-button'); + fireEvent.press(button); + + expect(mockReject).toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/FlatNavHeader/FlatNavHeader.tsx b/app/components/Views/confirmations/components/Confirm/FlatNavHeader/FlatNavHeader.tsx new file mode 100644 index 00000000000..029f89918e4 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/FlatNavHeader/FlatNavHeader.tsx @@ -0,0 +1,48 @@ +import React, { useCallback } from 'react'; +import { View } from 'react-native'; + +import ButtonIcon, { + ButtonIconSizes, +} from '../../../../../../component-library/components/Buttons/ButtonIcon'; +import { IconName } from '../../../../../../component-library/components/Icons/Icon'; +import { useStyles } from '../../../../../../component-library/hooks'; +import { + default as MorphText, + TextVariant, +} from '../../../../../../component-library/components/Texts/Text'; +import { useConfirmActions } from '../../../hooks/useConfirmActions'; +import styleSheet from './FlatNavHeader.styles'; + +interface FlatNavHeaderProps { + title: string; + onLeftPress?: () => void; +} + +const FlatNavHeader = ({ title, onLeftPress }: FlatNavHeaderProps) => { + const { onReject } = useConfirmActions(); + const { styles } = useStyles(styleSheet, {}); + + const handleLeftPress = useCallback(() => { + if (onLeftPress) { + onLeftPress(); + return; + } + + onReject(); + }, [onLeftPress, onReject]); + + return ( + + + {title} + + ); +}; + +export default FlatNavHeader; diff --git a/app/components/Views/confirmations/components/Confirm/FlatNavHeader/index.tsx b/app/components/Views/confirmations/components/Confirm/FlatNavHeader/index.tsx new file mode 100644 index 00000000000..e75d1f3699e --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/FlatNavHeader/index.tsx @@ -0,0 +1 @@ +export { default } from './FlatNavHeader'; diff --git a/app/components/Views/confirmations/components/Confirm/Info/Info.test.tsx b/app/components/Views/confirmations/components/Confirm/Info/Info.test.tsx index 87831c3d618..59c7d40e9c5 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/Info.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/Info.test.tsx @@ -1,7 +1,10 @@ import React from 'react'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; -import { personalSignatureConfirmationState } from '../../../../../../util/test/confirm-data-helpers'; +import { + personalSignatureConfirmationState, + stakingDepositConfirmationState, +} from '../../../../../../util/test/confirm-data-helpers'; // eslint-disable-next-line import/no-namespace import * as QRHardwareHook from '../../../context/QRHardwareContext/QRHardwareContext'; import Info from './Info'; @@ -43,4 +46,12 @@ describe('Info', () => { }); expect(getByText('QR Scanning Component')).toBeTruthy(); }); + describe('Staking Deposit', () => { + it('should render correctly', async () => { + const { getByText } = renderWithProvider(, { + state: stakingDepositConfirmationState, + }); + expect(getByText('Stake')).toBeDefined(); + }); + }); }); diff --git a/app/components/Views/confirmations/components/Confirm/Info/StakingDeposit/StakingDeposit.tsx b/app/components/Views/confirmations/components/Confirm/Info/StakingDeposit/StakingDeposit.tsx index 1f7b5b4b474..2ac48208913 100644 --- a/app/components/Views/confirmations/components/Confirm/Info/StakingDeposit/StakingDeposit.tsx +++ b/app/components/Views/confirmations/components/Confirm/Info/StakingDeposit/StakingDeposit.tsx @@ -1,6 +1,13 @@ import React from 'react'; -import { Text } from 'react-native'; -const StakingDeposit = () => Staking Deposit; +import { strings } from '../../../../../../../../locales/i18n'; +import FlatNavHeader from '../../FlatNavHeader'; +import TokenHero from '../../TokenHero'; +const StakingDeposit = () => ( + <> + + + +); export default StakingDeposit; diff --git a/app/components/Views/confirmations/components/Confirm/TokenHero/TokenHero.styles.ts b/app/components/Views/confirmations/components/Confirm/TokenHero/TokenHero.styles.ts new file mode 100644 index 00000000000..ab07733b5b5 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/TokenHero/TokenHero.styles.ts @@ -0,0 +1,36 @@ +import { StyleSheet } from 'react-native'; + +import { Theme } from '../../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + + return StyleSheet.create({ + assetAmountContainer: { + paddingTop: 4, + }, + assetAmountText: { + textAlign: 'center', + }, + assetFiatConversionContainer: { + }, + assetFiatConversionText: { + textAlign: 'center', + color: theme.colors.text.alternative, + }, + networkAndTokenContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + networkLogo: { + width: 48, + height: 48, + }, + container:{ + paddingVertical: 8 + } + }); +}; + +export default styleSheet; diff --git a/app/components/Views/confirmations/components/Confirm/TokenHero/TokenHero.test.tsx b/app/components/Views/confirmations/components/Confirm/TokenHero/TokenHero.test.tsx new file mode 100644 index 00000000000..04d301f58b6 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/TokenHero/TokenHero.test.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { stakingDepositConfirmationState } from '../../../../../../util/test/confirm-data-helpers'; +import TokenHero from './TokenHero'; + +describe('TokenHero', () => { + it('contains token and fiat values for staking deposit', async () => { + const { getByText } = renderWithProvider(, { + state: stakingDepositConfirmationState, + }); + expect(getByText('0.0001 ETH')).toBeDefined(); + expect(getByText('$0.36')).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/Confirm/TokenHero/TokenHero.tsx b/app/components/Views/confirmations/components/Confirm/TokenHero/TokenHero.tsx new file mode 100644 index 00000000000..6a7699573ee --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/TokenHero/TokenHero.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; + +import Badge, { + BadgeVariant, +} from '../../../../../../component-library/components/Badges/Badge'; +import BadgeWrapper from '../../../../../../component-library/components/Badges/BadgeWrapper'; +import { useStyles } from '../../../../../../component-library/hooks'; +import images from '../../../../../../images/image-icons'; +import TokenIcon from '../../../../../UI/Swaps/components/TokenIcon'; +import Text, { + TextVariant, +} from '../../../../../../component-library/components/Texts/Text'; +import { useTokenValues } from '../../../hooks/useTokenValues'; +import styleSheet from './TokenHero.styles'; + +const NetworkAndTokenImage = ({ + tokenSymbol, + styles, +}: { + tokenSymbol: string; + styles: StyleSheet.NamedStyles>; +}) => ( + + + } + > + + + + ); + +const AssetAmount = ({ + tokenAmountDisplayValue, + tokenSymbol, + styles, +}: { + tokenAmountDisplayValue: string; + tokenSymbol: string; + styles: StyleSheet.NamedStyles>; +}) => ( + + + {tokenAmountDisplayValue} {tokenSymbol} + + + ); + +const AssetFiatConversion = ({ + fiatDisplayValue, + styles, +}: { + fiatDisplayValue: string; + styles: StyleSheet.NamedStyles>; +}) => ( + + + {fiatDisplayValue} + + + ); + +const TokenHero = () => { + const { styles } = useStyles(styleSheet, {}); + const { fiatDisplayValue, tokenAmountDisplayValue } = useTokenValues(); + + const tokenSymbol = 'ETH'; + + return ( + + + + + + ); +}; + +export default TokenHero; diff --git a/app/components/Views/confirmations/components/Confirm/TokenHero/index.ts b/app/components/Views/confirmations/components/Confirm/TokenHero/index.ts new file mode 100644 index 00000000000..05c91f01d91 --- /dev/null +++ b/app/components/Views/confirmations/components/Confirm/TokenHero/index.ts @@ -0,0 +1 @@ +export { default } from './TokenHero'; diff --git a/app/components/Views/confirmations/hooks/useTokenValues.test.ts b/app/components/Views/confirmations/hooks/useTokenValues.test.ts new file mode 100644 index 00000000000..73d89b74d57 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTokenValues.test.ts @@ -0,0 +1,18 @@ +import { useTokenValues } from './useTokenValues'; +import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; +import { stakingDepositConfirmationState } from '../../../../util/test/confirm-data-helpers'; + +describe('useTokenValues', () => { + describe('staking deposit', () => { + it('returns token and fiat values', () => { + const { result } = renderHookWithProvider(useTokenValues, { + state: stakingDepositConfirmationState, + }); + + expect(result.current).toEqual({ + tokenAmountDisplayValue: '0.0001', + fiatDisplayValue: '$0.36', + }); + }); + }); +}); diff --git a/app/components/Views/confirmations/hooks/useTokenValues.ts b/app/components/Views/confirmations/hooks/useTokenValues.ts new file mode 100644 index 00000000000..25f8127e2a2 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTokenValues.ts @@ -0,0 +1,36 @@ +import { useSelector } from 'react-redux'; +import { BigNumber } from 'bignumber.js'; +import { Hex } from '@metamask/utils'; + +import { useTransactionMetadataRequest } from '../hooks/useTransactionMetadataRequest'; +import { selectConversionRateByChainId } from '../../../../selectors/currencyRateController'; +import I18n from '../../../../../locales/i18n'; +import { formatAmount } from '../../../../components/UI/SimulationDetails/formatAmount'; +import { fromWei, hexToBN } from '../../../../util/number'; +import useFiatFormatter from '../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { RootState } from '../../../../reducers'; + +// TODO: This hook will be extended to calculate token and fiat information from transaction metadata on upcoming redesigned confirmations +export const useTokenValues = () => { + const transactionMetadata = useTransactionMetadataRequest(); + const fiatFormatter = useFiatFormatter(); + const locale = I18n.locale; + const nativeConversionRate = useSelector((state: RootState) => + selectConversionRateByChainId(state, transactionMetadata?.chainId as Hex), + ); + const nativeConversionRateInBN = new BigNumber(nativeConversionRate || 1); + + const ethAmountInWei = hexToBN(transactionMetadata?.txParams?.value); + const ethAmountInBN = new BigNumber(fromWei(ethAmountInWei, 'ether')); + + const preciseFiatValue = ethAmountInBN.times(nativeConversionRateInBN); + + const tokenAmountDisplayValue = formatAmount(locale, ethAmountInBN); + const fiatDisplayValue = + preciseFiatValue && fiatFormatter(preciseFiatValue.toNumber()); + + return { + tokenAmountDisplayValue, + fiatDisplayValue, + }; +}; diff --git a/app/util/test/confirm-data-helpers.ts b/app/util/test/confirm-data-helpers.ts index 9dc3c7253fe..d6752fec18c 100644 --- a/app/util/test/confirm-data-helpers.ts +++ b/app/util/test/confirm-data-helpers.ts @@ -452,6 +452,21 @@ export const stakingDepositConfirmationState = { pendingApprovalCount: 1, approvalFlows: [], }, + CurrencyRateController: { + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate: 1732887955.694, + conversionRate: 3596.25, + usdConversionRate: 3596.25, + }, + LineaETH: { + conversionDate: 1732887955.694, + conversionRate: 3596.25, + usdConversionRate: 3596.25, + }, + }, + }, TransactionController: { transactions: [ { diff --git a/bitrise.yml b/bitrise.yml index 0f8d8bdf89e..da9f1ad1360 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -160,8 +160,9 @@ stages: - run_tag_smoke_assets_android: {} - run_tag_smoke_confirmations_ios: {} - run_tag_smoke_confirmations_android: {} - - run_tag_smoke_confirmations_redesigned_ios: {} - run_tag_smoke_ramps_android: {} + # run_tag_smoke_ramps_ios: {} + - run_tag_smoke_confirmations_redesigned_ios: {} - run_tag_smoke_swaps_ios: {} - run_tag_smoke_swaps_android: {} - run_tag_smoke_stake_ios: {} @@ -188,6 +189,7 @@ stages: - run_tag_smoke_accounts_ios: {} - run_tag_smoke_accounts_android: {} - run_tag_smoke_ramps_android: {} + # run_tag_smoke_ramps_ios: {} # - run_tag_smoke_identity_ios: {} # - run_tag_smoke_identity_android: {} # - run_tag_smoke_assets_ios: {} @@ -727,6 +729,12 @@ workflows: - TEST_SUITE_TAG: '.*SmokeMultiChainPermissions.*' after_run: - android_e2e_test + run_tag_smoke_ramps_ios: + envs: + - TEST_SUITE_FOLDER: './e2e/specs/ramps/*' + - TEST_SUITE_TAG: '.*SmokeRamps.*' + after_run: + - ios_e2e_test run_tag_smoke_ramps_android: meta: bitrise.io: diff --git a/e2e/pages/Ramps/BuildQuoteView.js b/e2e/pages/Ramps/BuildQuoteView.js index e7bcd2f1ba1..150a3721336 100644 --- a/e2e/pages/Ramps/BuildQuoteView.js +++ b/e2e/pages/Ramps/BuildQuoteView.js @@ -19,12 +19,76 @@ class BuildQuoteView { return Matchers.getElementByText(BuildQuoteSelectors.CANCEL_BUTTON_TEXT); } + get selectRegionDropdown() { + return Matchers.getElementByText(BuildQuoteSelectors.SELECT_REGION); + } + + get selectPaymentMethodDropdown() { + return Matchers.getElementByText(BuildQuoteSelectors.SELECT_PAYMENT_METHOD); + } + + get selectCurrencyDropdown() { + return Matchers.getElementByID(BuildQuoteSelectors.SELECT_CURRENCY); + } + + get amountInput() { + return Matchers.getElementByID(BuildQuoteSelectors.AMOUNT_INPUT); + } + + get regionDropdown() { + return Matchers.getElementByID(BuildQuoteSelectors.REGION_DROPDOWN); + } + + get minLimitErrorMessage() { + return Matchers.getElementByID(BuildQuoteSelectors.MIN_LIMIT_ERROR); + } + + get maxLimitErrorMessage() { + return Matchers.getElementByID(BuildQuoteSelectors.MAX_LIMIT_ERROR); + } + async tapCancelButton() { await Gestures.waitAndTap(this.cancelButton); } - async tapDefaultToken(token) { - const tokenName = await Matchers.getElementByText(token); - await Gestures.waitAndTap(tokenName); + + async selectToken(token) { + const tokenOption = Matchers.getElementByText(token); + await Gestures.waitAndTap(tokenOption); + } + + async tapTokenDropdown(token) { + const tokenOption = Matchers.getElementByText(token); + await Gestures.waitAndTap(tokenOption); + } + + async tapSelectRegionDropdown() { + await Gestures.waitAndTap(this.selectRegionDropdown); + } + + async tapCurrencySelector() { + await Gestures.waitAndTap(this.selectCurrencyDropdown); + } + + async enterFiatAmount(amount) { + await Gestures.waitAndTap(Matchers.getElementByID(BuildQuoteSelectors.AMOUNT_INPUT)) + for (let digit = 0; digit < amount.length; digit++) { + const numberButton = Matchers.getElementByText(amount[digit]); + await Gestures.waitAndTap(numberButton); + } + await Gestures.waitAndTap(Matchers.getElementByText(BuildQuoteSelectors.DONE_BUTTON)) + } + + async tapGetQuotesButton() { + await Gestures.waitAndTap(this.getQuotesButton); + } + + async tapPaymentMethodDropdown(paymentMethod) { + const paymentMethodOption = Matchers.getElementByText(paymentMethod); + await Gestures.waitAndTap(paymentMethodOption); + } + + async tapRegionSelector() { + await Gestures.waitAndTap(this.regionDropdown); } } diff --git a/e2e/pages/Ramps/QuotesView.js b/e2e/pages/Ramps/QuotesView.js new file mode 100644 index 00000000000..ee01cb74e12 --- /dev/null +++ b/e2e/pages/Ramps/QuotesView.js @@ -0,0 +1,23 @@ +import Matchers from '../../utils/Matchers'; +import Gestures from '../../utils/Gestures'; +import { QuoteSelectors } from '../../selectors/Ramps/Quotes.selectors'; + +class QuotesView { + get selectAQuoteLabel() { + return Matchers.getElementByText(QuoteSelectors.SELECT_A_QUOTE); + } + + get quoteAmountLabel() { + return Matchers.getElementByID(QuoteSelectors.QUOTE_AMOUNT_LABEL); + } + + get quotes() { + return Matchers.getElementByID(QuoteSelectors.QUOTES); + } + + async closeQuotesSection() { + await Gestures.swipe(this.selectAQuoteLabel, 'down', 'fast', 1, 0, 0); + } +} + +export default new QuotesView(); diff --git a/e2e/pages/Ramps/SelectCurrencyView.js b/e2e/pages/Ramps/SelectCurrencyView.js new file mode 100644 index 00000000000..6264a0a209b --- /dev/null +++ b/e2e/pages/Ramps/SelectCurrencyView.js @@ -0,0 +1,12 @@ +import Matchers from '../../utils/Matchers'; +import Gestures from '../../utils/Gestures'; + + +class SelectCurrencyView { + async tapCurrencyOption(currency) { + const currencyOption = Matchers.getElementByText(currency); + await Gestures.waitAndTap(currencyOption); + } +} + +export default new SelectCurrencyView(); diff --git a/e2e/pages/Ramps/SelectRegionView.js b/e2e/pages/Ramps/SelectRegionView.js index 6e54a228c8a..f2474428f63 100644 --- a/e2e/pages/Ramps/SelectRegionView.js +++ b/e2e/pages/Ramps/SelectRegionView.js @@ -3,18 +3,10 @@ import Gestures from '../../utils/Gestures'; import { SelectRegionSelectors } from '../../selectors/Ramps/SelectRegion.selectors'; class SelectRegionView { - get selectRegionDropdown() { - return Matchers.getElementByText(SelectRegionSelectors.SELECT_REGION); - } - get continueButton() { return Matchers.getElementByText(SelectRegionSelectors.CONTINUE_BUTTON); } - async tapSelectRegionDropdown() { - await Gestures.waitAndTap(this.selectRegionDropdown); - } - async tapRegionOption(region) { const regionOption = Matchers.getElementByText(region); await Gestures.waitAndTap(regionOption); diff --git a/e2e/selectors/Ramps/BuildQuote.selectors.js b/e2e/selectors/Ramps/BuildQuote.selectors.js index e8eaf68ca4f..984ab9c5c98 100644 --- a/e2e/selectors/Ramps/BuildQuote.selectors.js +++ b/e2e/selectors/Ramps/BuildQuote.selectors.js @@ -1,8 +1,16 @@ import enContent from '../../../locales/languages/en.json'; export const BuildQuoteSelectors = { + AMOUNT_INPUT: 'amount-input', AMOUNT_TO_BUY_LABEL: enContent.fiat_on_ramp_aggregator.amount_to_buy, AMOUNT_TO_SELL_LABEL: enContent.fiat_on_ramp_aggregator.amount_to_sell, GET_QUOTES_BUTTON: enContent.fiat_on_ramp_aggregator.get_quotes, CANCEL_BUTTON_TEXT: enContent.navigation.cancel, + SELECT_REGION: enContent.fiat_on_ramp_aggregator.region.select_region, + SELECT_PAYMENT_METHOD: enContent.fiat_on_ramp_aggregator.update_payment_method, + SELECT_CURRENCY: 'select-currency', + REGION_DROPDOWN: 'region-dropdown', + DONE_BUTTON: enContent.fiat_on_ramp_aggregator.done, + MIN_LIMIT_ERROR: 'min-limit-error', + MAX_LIMIT_ERROR: 'max-limit-error', }; diff --git a/e2e/selectors/Ramps/Quotes.selectors.js b/e2e/selectors/Ramps/Quotes.selectors.js new file mode 100644 index 00000000000..e6867fff5d5 --- /dev/null +++ b/e2e/selectors/Ramps/Quotes.selectors.js @@ -0,0 +1,7 @@ +import enContent from '../../../locales/languages/en.json'; + +export const QuoteSelectors = { + SELECT_A_QUOTE: enContent.fiat_on_ramp_aggregator.select_a_quote, + QUOTE_AMOUNT_LABEL: 'quote-amount', + QUOTES: 'quotes', +}; \ No newline at end of file diff --git a/e2e/specs/ramps/deeplink-to-buy-flow.spec.js b/e2e/specs/ramps/deeplink-to-buy-flow.spec.js index df9a62b634e..bcfac92f993 100644 --- a/e2e/specs/ramps/deeplink-to-buy-flow.spec.js +++ b/e2e/specs/ramps/deeplink-to-buy-flow.spec.js @@ -46,7 +46,7 @@ describe(SmokeRamps('Buy Crypto Deeplinks'), () => { await BuyGetStartedView.tapGetStartedButton(); await Assertions.checkIfVisible(BuildQuoteView.getQuotesButton); - await BuildQuoteView.tapDefaultToken('Ethereum'); + await BuildQuoteView.tapTokenDropdown('Ethereum'); await TokenSelectBottomSheet.tapTokenByName('DAI'); await Assertions.checkIfTextIsDisplayed('Dai Stablecoin'); diff --git a/e2e/specs/ramps/offramp.spec.js b/e2e/specs/ramps/offramp.spec.js index dab60443aba..1e04a68a08d 100644 --- a/e2e/specs/ramps/offramp.spec.js +++ b/e2e/specs/ramps/offramp.spec.js @@ -62,7 +62,7 @@ describe(SmokeRamps('Off-Ramp'), () => { await TabBarComponent.tapActions(); await WalletActionsBottomSheet.tapSellButton(); await SellGetStartedView.tapGetStartedButton(); - await SelectRegionView.tapSelectRegionDropdown(); + await BuildQuoteView.tapSelectRegionDropdown(); await SelectRegionView.tapRegionOption(Regions.USA); await SelectRegionView.tapRegionOption(Regions.CALIFORNIA); await SelectRegionView.tapContinueButton(); diff --git a/e2e/specs/ramps/onramp-limits.spec.js b/e2e/specs/ramps/onramp-limits.spec.js new file mode 100644 index 00000000000..4eda9106976 --- /dev/null +++ b/e2e/specs/ramps/onramp-limits.spec.js @@ -0,0 +1,54 @@ +'use strict'; +import { loginToApp } from '../../viewHelper'; +import FixtureBuilder from '../../fixtures/fixture-builder'; +import { withFixtures } from '../../fixtures/fixture-helper'; +import TestHelpers from '../../helpers'; +import { SmokeRamps } from '../../tags'; +import BuildQuoteView from '../../pages/Ramps/BuildQuoteView'; +import Assertions from '../../utils/Assertions'; +import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import WalletActionsBottomSheet from '../../pages/wallet/WalletActionsBottomSheet'; +import BuyGetStartedView from '../../pages/Ramps/BuyGetStartedView'; + +describe(SmokeRamps('On-Ramp Limits'), () => { + beforeAll(async () => { + await TestHelpers.reverseServerPort(); + }); + + beforeEach(async () => { + jest.setTimeout(150000); + }); + + it('should check order min and maxlimits', async () => { + const franceRegion = { + currencies: ['/currencies/fiat/eur'], + emoji: 'πŸ‡«πŸ‡·', + id: '/regions/fr', + name: 'France', + support: { buy: true, sell: true, recurringBuy: true }, + unsupported: false, + recommended: false, + detected: false, + }; + await withFixtures( + { + fixture: new FixtureBuilder() + .withRampsSelectedRegion(franceRegion) + .withRampsSelectedPaymentMethod() + .build(), + restartDevice: true, + }, + async () => { + await loginToApp(); + await TabBarComponent.tapActions(); + await WalletActionsBottomSheet.tapBuyButton(); + await BuyGetStartedView.tapGetStartedButton(); + await BuildQuoteView.enterFiatAmount('1'); + await Assertions.checkIfVisible(BuildQuoteView.minLimitErrorMessage); + await BuildQuoteView.enterFiatAmount('55555'); + await Assertions.checkIfVisible(BuildQuoteView.maxLimitErrorMessage); + await BuildQuoteView.tapCancelButton(); + }, + ); + }); +}); diff --git a/e2e/specs/ramps/onramp.spec.js b/e2e/specs/ramps/onramp.spec.js index be78ab9d9e9..c71bad11775 100644 --- a/e2e/specs/ramps/onramp.spec.js +++ b/e2e/specs/ramps/onramp.spec.js @@ -16,8 +16,10 @@ import BuyGetStartedView from '../../pages/Ramps/BuyGetStartedView'; import SelectRegionView from '../../pages/Ramps/SelectRegionView'; import SelectPaymentMethodView from '../../pages/Ramps/SelectPaymentMethodView'; import BuildQuoteView from '../../pages/Ramps/BuildQuoteView'; +import QuotesView from '../../pages/Ramps/QuotesView'; import Assertions from '../../utils/Assertions'; - +import TokenSelectBottomSheet from '../../pages/Ramps/TokenSelectBottomSheet'; +import SelectCurrencyView from '../../pages/Ramps/SelectCurrencyView'; const fixtureServer = new FixtureServer(); describe(SmokeRamps('Buy Crypto'), () => { @@ -46,7 +48,7 @@ describe(SmokeRamps('Buy Crypto'), () => { await TabBarComponent.tapActions(); await WalletActionsBottomSheet.tapBuyButton(); await BuyGetStartedView.tapGetStartedButton(); - await SelectRegionView.tapSelectRegionDropdown(); + await BuildQuoteView.tapSelectRegionDropdown(); await SelectRegionView.tapRegionOption('United States of America'); await SelectRegionView.tapRegionOption('California'); await SelectRegionView.tapContinueButton(); @@ -62,5 +64,35 @@ describe(SmokeRamps('Buy Crypto'), () => { await WalletActionsBottomSheet.tapBuyButton(); await Assertions.checkIfVisible(BuildQuoteView.amountToBuyLabel); await Assertions.checkIfVisible(BuildQuoteView.getQuotesButton); + await BuildQuoteView.tapCancelButton(); }); + + it('should change parameters and select a quote', async () => { + const paymentMethod = device.getPlatform() === 'ios' ? 'Apple Pay' : 'Google Pay'; + + await TabBarComponent.tapActions(); + await WalletActionsBottomSheet.tapBuyButton(); + await BuildQuoteView.tapCurrencySelector(); + await SelectCurrencyView.tapCurrencyOption('Euro'); + await BuildQuoteView.tapTokenDropdown('Ethereum'); + await TokenSelectBottomSheet.tapTokenByName('DAI'); + await BuildQuoteView.tapRegionSelector(); + await SelectRegionView.tapRegionOption('France'); + await BuildQuoteView.tapPaymentMethodDropdown('Debit or Credit'); + await SelectPaymentMethodView.tapPaymentMethodOption(paymentMethod); + await Assertions.checkIfTextIsDisplayed('€0'); + await Assertions.checkIfTextIsNotDisplayed('$0'); + await Assertions.checkIfTextIsDisplayed('Dai Stablecoin'); + await Assertions.checkIfTextIsNotDisplayed('Ethereum'); + await Assertions.checkIfTextIsNotDisplayed('Debit or Credit'); + await Assertions.checkIfTextIsDisplayed(paymentMethod); + await Assertions.checkIfTextIsNotDisplayed('πŸ‡ΊπŸ‡Έ'); + await Assertions.checkIfTextIsDisplayed('πŸ‡«πŸ‡·'); + await BuildQuoteView.enterFiatAmount('100'); + await BuildQuoteView.tapGetQuotesButton(); + await Assertions.checkIfVisible(QuotesView.quotes); + await QuotesView.closeQuotesSection(); + await BuildQuoteView.tapCancelButton(); + }); + });