diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 770159c83..406bbfd03 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -232,6 +232,9 @@ module.exports = { { files: 'cypress/**', extends: 'plugin:cypress/recommended', + rules: { + 'cypress/no-unnecessary-waiting': 'off', + }, }, ], }; diff --git a/cypress/e2e/app.cy.ts b/cypress/e2e/app.cy.ts index 039756fe1..1605a0217 100644 --- a/cypress/e2e/app.cy.ts +++ b/cypress/e2e/app.cy.ts @@ -1,21 +1,11 @@ -function randomSlug() { - return Math.random().toString(36).substring(2); -} - -const getUser = () => - ({ - email: `${crypto.randomUUID()}@local.host`, - password: 'Loc@l.h0st', - firstName: 'Local', - lastName: 'Host', - }) as const; +import { generateRandomSlug, getUserData } from '../support/testkit'; Cypress.on('uncaught:exception', (_err, _runnable) => { return false; }); describe('basic user flow', () => { - const user = getUser(); + const user = getUserData(); it('should be visitable', () => { cy.visit('/'); @@ -37,7 +27,7 @@ describe('basic user flow', () => { it('should log in and log out', () => { cy.login(user); - const slug = randomSlug(); + const slug = generateRandomSlug(); cy.get('input[name="slug"]').type(slug); cy.get('button[type="submit"]').click(); @@ -49,8 +39,8 @@ describe('basic user flow', () => { }); it('create organization', () => { - const slug = randomSlug(); - const user = getUser(); + const slug = generateRandomSlug(); + const user = getUserData(); cy.visit('/'); cy.signup(user); cy.get('input[name="slug"]').type(slug); @@ -60,11 +50,11 @@ it('create organization', () => { describe('oidc', () => { it('oidc login for organization', () => { - const organizationAdminUser = getUser(); + const organizationAdminUser = getUserData(); cy.visit('/'); cy.signup(organizationAdminUser); - const slug = randomSlug(); + const slug = generateRandomSlug(); cy.createOIDCIntegration(slug).then(({ loginUrl }) => { cy.visit('/logout'); @@ -82,11 +72,11 @@ describe('oidc', () => { }); it('oidc login with organization slug', () => { - const organizationAdminUser = getUser(); + const organizationAdminUser = getUserData(); cy.visit('/'); cy.signup(organizationAdminUser); - const slug = randomSlug(); + const slug = generateRandomSlug(); cy.createOIDCIntegration(slug).then(({ organizationSlug }) => { cy.visit('/logout'); @@ -108,11 +98,11 @@ describe('oidc', () => { }); it('first time oidc login of non-admin user', () => { - const organizationAdminUser = getUser(); + const organizationAdminUser = getUserData(); cy.visit('/'); cy.signup(organizationAdminUser); - const slug = randomSlug(); + const slug = generateRandomSlug(); cy.createOIDCIntegration(slug).then(({ organizationSlug }) => { cy.visit('/logout'); diff --git a/cypress/e2e/usage.cy.ts b/cypress/e2e/usage.cy.ts new file mode 100644 index 000000000..017253b0c --- /dev/null +++ b/cypress/e2e/usage.cy.ts @@ -0,0 +1,321 @@ +import { + createProject, + createUserAndOrganization, + generateRandomSlug, + waitForOrganizationPage, + waitForProjectPage, + waitForTargetPage, +} from '../support/testkit'; +import type { Report } from './../../packages/libraries/core/src/client/usage.js'; + +Cypress.on('uncaught:exception', (_err, _runnable) => { + return false; +}); + +function createRegistryAccessToken(params: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; +}) { + // Visit Registry Tokens settings + cy.get( + `a[href="/${params.organizationSlug}/${params.projectSlug}/${params.targetSlug}/settings"]`, + ).click(); + cy.get('[data-cy="target-settings-registry-token-link"]').click(); + // Open the form + cy.get('[data-cy="target-settings-registry-token"] [data-cy="new-button"]').click(); + // Fill in the token description + cy.get('[data-cy="create-registry-token-form"] [data-cy="description"]').type('test-token'); + // Pick the permissions + cy.get('[data-cy="registry-access-scope"] [data-cy="select-trigger"]').click(); + cy.get( + '[data-cy="registry-access-scope-select-content"] [data-cy="select-option-REGISTRY_WRITE"]', + ).click(); + // Submit + cy.get('[data-cy="create-registry-token-form"] [data-cy="submit"]').click(); + + // assert the token is created + cy.get('[data-cy="registry-token-created"] input[type="text"]') + .invoke('val') + .then(value => { + if (typeof value !== 'string') { + throw new Error('Expected a string'); + } + + return value; + }) + .as('token'); + cy.get('@token').should('have.length', 32); + + // close the modal + cy.get('[data-cy="registry-token-created"] [data-cy="close"]').contains('Ok, got it!').click(); +} + +function sendUsageReport(params: { report: Report }) { + // send a usage report + cy.get('@token') + .then(async token => { + const res = await fetch(`http://localhost:8081`, { + method: 'POST', + body: JSON.stringify(params.report), + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + // it is a string, Cypress Hill just doesn't know it + Authorization: `Bearer ${token as unknown as string}`, + }, + }); + + expect(res.status).to.equal(200); + }) + .wait(2000); +} + +describe('usage reporting', () => { + it('usage report should be visible in Insights', () => { + const organizationSlug = generateRandomSlug(); + const projectSlug = generateRandomSlug(); + const targetSlug = 'development'; + + createUserAndOrganization(organizationSlug); + waitForOrganizationPage(organizationSlug); + + createProject(projectSlug); + waitForProjectPage(projectSlug); + + // go to the development target + cy.get(`a[href="/${organizationSlug}/${projectSlug}/${targetSlug}"]`).click(); + waitForTargetPage(targetSlug); + + createRegistryAccessToken({ organizationSlug, projectSlug, targetSlug }); + + sendUsageReport({ + report: { + size: 1, + map: { + op1: { + operation: 'query ping { ping }', + operationName: 'ping', + fields: ['Query', 'Query.ping'], + }, + }, + operations: [ + { + operationMapKey: 'op1', + timestamp: Date.now(), + execution: { + ok: true, + duration: 200_000_000, + errorsTotal: 0, + }, + metadata: { + client: { + name: 'ios', + version: 'v1.2.3', + }, + }, + }, + ], + }, + }); + + // visit Insights + cy.visit(`/${organizationSlug}/${projectSlug}/${targetSlug}/insights`); + cy.get('h3').contains('Operations').parent().get('a').contains('_ping'); + // visit Insights of "unknown" client + cy.visit(`/${organizationSlug}/${projectSlug}/${targetSlug}/insights/client/ios`); + cy.get('h3').contains('Operations').parent().get('a').contains('_ping'); + cy.get('h3').contains('Versions').parent().get('p').contains('v1.2.3'); + }); + + it('usage report with "unknown" client should be visible in Insights', () => { + const organizationSlug = generateRandomSlug(); + const projectSlug = generateRandomSlug(); + const targetSlug = 'development'; + + createUserAndOrganization(organizationSlug); + waitForOrganizationPage(organizationSlug); + + createProject(projectSlug); + waitForProjectPage(projectSlug); + + // go to the development target + cy.get(`a[href="/${organizationSlug}/${projectSlug}/${targetSlug}"]`).click(); + waitForTargetPage(targetSlug); + + createRegistryAccessToken({ organizationSlug, projectSlug, targetSlug }); + + sendUsageReport({ + report: { + size: 1, + map: { + op1: { + operation: 'query ping { ping }', + operationName: 'ping', + fields: ['Query', 'Query.ping'], + }, + }, + operations: [ + { + operationMapKey: 'op1', + timestamp: Date.now(), + execution: { + ok: true, + duration: 200_000_000, + errorsTotal: 0, + }, + metadata: { + client: { + name: 'unknown', + version: 'v1.2.3', + }, + }, + }, + ], + }, + }); + + // visit Insights + cy.visit(`/${organizationSlug}/${projectSlug}/${targetSlug}/insights`); + cy.get('h3').contains('Operations').parent().get('a').contains('_ping'); + // visit Insights of "unknown" client + cy.visit(`/${organizationSlug}/${projectSlug}/${targetSlug}/insights/client/unknown`); + cy.get('h3').contains('Operations').parent().get('a').contains('_ping'); + cy.get('h3').contains('Versions').parent().get('p').contains('v1.2.3'); + }); + + it('usage report with missing client name should be visible in Insights', () => { + const organizationSlug = generateRandomSlug(); + const projectSlug = generateRandomSlug(); + const targetSlug = 'development'; + + createUserAndOrganization(organizationSlug); + waitForOrganizationPage(organizationSlug); + + createProject(projectSlug); + waitForProjectPage(projectSlug); + + // go to the development target + cy.get(`a[href="/${organizationSlug}/${projectSlug}/${targetSlug}"]`).click(); + waitForTargetPage(targetSlug); + + createRegistryAccessToken({ organizationSlug, projectSlug, targetSlug }); + + sendUsageReport({ + report: { + size: 1, + map: { + op1: { + operation: 'query ping { ping }', + operationName: 'ping', + fields: ['Query', 'Query.ping'], + }, + }, + operations: [ + { + operationMapKey: 'op1', + timestamp: Date.now(), + execution: { + ok: true, + duration: 200_000_000, + errorsTotal: 0, + }, + metadata: { + client: { + name: undefined, + version: 'v1.2.3', + }, + }, + }, + ], + }, + }); + + // visit Insights + cy.visit(`/${organizationSlug}/${projectSlug}/${targetSlug}/insights`); + cy.get('h3').contains('Operations').parent().get('a').contains('_ping'); + // visit Insights of "unknown" client (we use "unknown" as a fallback for missing client name) + cy.visit(`/${organizationSlug}/${projectSlug}/${targetSlug}/insights/client/unknown`); + cy.get('h3').contains('Operations').parent().get('a').contains('_ping'); + cy.get('h3').contains('Versions').parent().get('p').contains('v1.2.3'); + }); + + it('usage report with missing and "unknown" client names should be visible in Insights', () => { + const organizationSlug = generateRandomSlug(); + const projectSlug = generateRandomSlug(); + const targetSlug = 'development'; + + createUserAndOrganization(organizationSlug); + waitForOrganizationPage(organizationSlug); + + createProject(projectSlug); + waitForProjectPage(projectSlug); + + // go to the development target + cy.get(`a[href="/${organizationSlug}/${projectSlug}/${targetSlug}"]`).click(); + waitForTargetPage(targetSlug); + + createRegistryAccessToken({ organizationSlug, projectSlug, targetSlug }); + + sendUsageReport({ + report: { + size: 1, + map: { + op1: { + operation: 'query ping { ping }', + operationName: 'ping', + fields: ['Query', 'Query.ping'], + }, + op2: { + operation: 'query pong { pong }', + operationName: 'pong', + fields: ['Query', 'Query.pong'], + }, + }, + operations: [ + { + operationMapKey: 'op1', + timestamp: Date.now(), + execution: { + ok: true, + duration: 200_000_000, + errorsTotal: 0, + }, + metadata: { + client: { + name: undefined, + version: 'vUndefined', + }, + }, + }, + { + operationMapKey: 'op2', + timestamp: Date.now(), + execution: { + ok: true, + duration: 200_000_000, + errorsTotal: 0, + }, + metadata: { + client: { + name: 'unknown', + version: 'vUnknown', + }, + }, + }, + ], + }, + }); + + // visit Insights + cy.visit(`/${organizationSlug}/${projectSlug}/${targetSlug}/insights`); + cy.get('h3').contains('Operations').parent().get('a').contains('_ping'); + cy.get('h3').contains('Operations').parent().get('a').contains('_pong'); + // visit Insights of "unknown" client (we use "unknown" as a fallback for missing client name) + cy.visit(`/${organizationSlug}/${projectSlug}/${targetSlug}/insights/client/unknown`); + cy.get('h3').contains('Operations').parent().get('a').contains('_ping'); + cy.get('h3').contains('Operations').parent().get('a').contains('_pong'); + cy.get('h3').contains('Versions').parent().get('p').contains('vUndefined'); + cy.get('h3').contains('Versions').parent().get('p').contains('vUnknown'); + }); +}); diff --git a/cypress/local.sh b/cypress/local.sh index 6339967a0..b7ecdf503 100755 --- a/cypress/local.sh +++ b/cypress/local.sh @@ -30,3 +30,7 @@ docker compose -f ./docker/docker-compose.community.yml -f ./docker/docker-compo echo "✅ E2E tests environment is ready. To run tests now, use:" echo "" echo " HIVE_APP_BASE_URL=http://localhost:8080 pnpm test:e2e" +echo "" +echo " or to open Cypress GUI:" +echo "" +echo " HIVE_APP_BASE_URL=http://localhost:8080 pnpm test:e2e:open" diff --git a/cypress/support/testkit.ts b/cypress/support/testkit.ts new file mode 100644 index 000000000..10e09ca0d --- /dev/null +++ b/cypress/support/testkit.ts @@ -0,0 +1,39 @@ +export function generateRandomSlug() { + return Math.random().toString(36).substring(2); +} + +export function getUserData() { + return { + email: `${crypto.randomUUID()}@local.host`, + password: 'Loc@l.h0st', + firstName: 'Local', + lastName: 'Host', + }; +} + +export function waitForTargetPage(targetSlug: string) { + cy.get(`[data-cy="target-picker-current"]`).contains(targetSlug); +} + +export function waitForProjectPage(projectSlug: string) { + cy.get(`[data-cy="project-picker-current"]`).contains(projectSlug); +} + +export function waitForOrganizationPage(organizationSlug: string) { + cy.get(`[data-cy="organization-picker-current"]`).contains(organizationSlug); +} + +export function createUserAndOrganization(organizationSlug: string) { + const user = getUserData(); + + cy.visit('/'); + cy.signup(user); + cy.get('input[name="slug"]').type(organizationSlug); + cy.get('button[type="submit"]').click(); +} + +export function createProject(projectSlug: string) { + cy.get('[data-cy="new-project-button"]').click(); + cy.get('form[data-cy="create-project-form"] [data-cy="slug"]').type(projectSlug); + cy.get('form[data-cy="create-project-form"] [data-cy="submit"]').click(); +} diff --git a/integration-tests/testkit/flow.ts b/integration-tests/testkit/flow.ts index 4ed58143f..537e5a743 100644 --- a/integration-tests/testkit/flow.ts +++ b/integration-tests/testkit/flow.ts @@ -4,6 +4,7 @@ import type { AddAlertInput, AnswerOrganizationTransferRequestInput, AssignMemberRoleInput, + ClientStatsInput, CreateMemberRoleInput, CreateOrganizationInput, CreateProjectInput, @@ -1037,6 +1038,35 @@ export function updateBaseSchema(input: UpdateBaseSchemaInput, token: string) { }); } +export function readClientStats(selector: ClientStatsInput, token: string) { + return execute({ + document: graphql(` + query IntegrationTests_ClientStat($selector: ClientStatsInput!) { + clientStats(selector: $selector) { + totalRequests + totalVersions + operations { + nodes { + id + name + operationHash + count + } + } + versions(limit: 25) { + version + count + } + } + } + `), + token, + variables: { + selector, + }, + }); +} + export function readOperationsStats(input: OperationsStatsSelectorInput, token: string) { return execute({ document: graphql(` @@ -1059,6 +1089,16 @@ export function readOperationsStats(input: OperationsStatsSelectorInput, token: } } } + clients { + nodes { + name + versions { + version + count + } + count + } + } } } `), diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 53beb6c24..5ee9b6150 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -7,8 +7,8 @@ import { RegistryModel, SchemaPolicyInput, TargetAccessScope, - TargetSelectorInput, } from 'testkit/gql/graphql'; +import type { Report } from '../../packages/libraries/core/src/client/usage.js'; import { authenticate, userEmail } from './auth'; import { CreateCollectionMutation, @@ -46,6 +46,7 @@ import { inviteToOrganization, joinOrganization, publishSchema, + readClientStats, readOperationBody, readOperationsStats, readTokenInfo, @@ -58,7 +59,7 @@ import { } from './flow'; import { execute } from './graphql'; import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from './schema-policy'; -import { CollectedOperation, legacyCollect } from './usage'; +import { collect, CollectedOperation, legacyCollect } from './usage'; import { generateUnique } from './utils'; export function initSeed() { @@ -496,7 +497,12 @@ export function initSeed() { authorizationHeader: headerName, }); }, - async collectUsage() {}, + collectUsage(report: Report) { + return collect({ + report, + accessToken: secret, + }); + }, async checkSchema( sdl: string, service?: string, @@ -686,6 +692,23 @@ export function initSeed() { return statsResult.operationsStats; }, + async readClientStats(params: { clientName: string; from: string; to: string }) { + const statsResult = await readClientStats( + { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + client: params.clientName, + period: { + from: params.from, + to: params.to, + }, + }, + ownerToken, + ).then(r => r.expectNoGraphQLErrors()); + + return statsResult.clientStats; + }, async updateBaseSchema(newBase: string, ttarget: TargetOverwrite = target) { const result = await updateBaseSchema( { diff --git a/integration-tests/testkit/usage.ts b/integration-tests/testkit/usage.ts index eae15e111..95545a5d2 100644 --- a/integration-tests/testkit/usage.ts +++ b/integration-tests/testkit/usage.ts @@ -1,3 +1,4 @@ +import type { Report } from '../../packages/libraries/core/src/client/usage.js'; import { getServiceHost } from './utils'; export interface CollectedOperation { @@ -18,6 +19,32 @@ export interface CollectedOperation { }; } +export async function collect(params: { report: Report; accessToken: string }) { + const usageAddress = await getServiceHost('usage', 8081); + const res = await fetch(`http://${usageAddress}`, { + method: 'POST', + body: JSON.stringify(params.report), + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }, + }); + + return { + status: res.status, + body: + res.status === 200 + ? ((await res.json()) as { + operations: { + accepted: number; + rejected: number; + }; + }) + : await res.text(), + }; +} + export async function legacyCollect(params: { operations: CollectedOperation[]; token: string; diff --git a/integration-tests/tests/api/target/usage.spec.ts b/integration-tests/tests/api/target/usage.spec.ts index da484091a..64caabf8a 100644 --- a/integration-tests/tests/api/target/usage.spec.ts +++ b/integration-tests/tests/api/target/usage.spec.ts @@ -2779,3 +2779,332 @@ test.concurrent('ensure percentage precision up to 2 decimal places', async ({ e .then(r => r.expectNoGraphQLErrors()); expect(unusedCheckResult199.schemaCheck.__typename).toEqual('SchemaCheckError'); }); + +test.concurrent('(legacy) collect an operation from "unknown" client', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, readOperationBody, readOperationsStats, readClientStats } = + await createProject(ProjectType.Single); + const writeToken = await createTargetAccessToken({}); + + const collectResult = await writeToken.collectLegacyOperations([ + { + operation: 'query ping { ping }', + operationName: 'ping', + fields: ['Query', 'Query.ping'], + execution: { + ok: true, + duration: 200_000_000, + errorsTotal: 0, + }, + metadata: { + client: { + name: 'unknown', + version: 'v1.2.3', + }, + }, + }, + ]); + expect(collectResult.status).toEqual(200); + await waitFor(8000); + + const from = formatISO(subHours(Date.now(), 6)); + const to = formatISO(Date.now()); + const operationsStats = await readOperationsStats(from, to); + expect(operationsStats.operations.nodes).toHaveLength(1); + const op = operationsStats.operations.nodes[0]; + + expect(operationsStats).toMatchInlineSnapshot(` + { + clients: { + nodes: [ + { + count: 1, + name: unknown, + versions: [ + { + count: 1, + version: v1.2.3, + }, + ], + }, + ], + }, + operations: { + nodes: [ + { + count: 1, + duration: { + p75: 200, + p90: 200, + p95: 200, + p99: 200, + }, + id: 8f87d0bc9744ad3d50af125d20c355c0, + kind: query, + name: 798a_ping, + operationHash: 798ae10ebeef9f632ceec2fbe85a2052, + percentage: 100, + }, + ], + }, + totalOperations: 1, + } + `); + + await expect(readOperationBody(op.operationHash!)).resolves.toEqual('query ping{ping}'); + + const clientStats = await readClientStats({ + clientName: 'unknown', + from, + to, + }); + expect(clientStats).toMatchInlineSnapshot(` + { + operations: { + nodes: [ + { + count: 1, + id: 8f87d0bc9744ad3d50af125d20c355c0, + name: 798a_ping, + operationHash: 798ae10ebeef9f632ceec2fbe85a2052, + }, + ], + }, + totalRequests: 1, + totalVersions: 1, + versions: [ + { + count: 1, + version: v1.2.3, + }, + ], + } + `); +}); + +test.concurrent('collect an operation from "unknown" client', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, readOperationBody, readOperationsStats, readClientStats } = + await createProject(ProjectType.Single); + const writeToken = await createTargetAccessToken({}); + + const collectResult = await writeToken.collectUsage({ + size: 1, + map: { + op1: { + operation: 'query ping { ping }', + operationName: 'ping', + fields: ['Query', 'Query.ping'], + }, + }, + operations: [ + { + operationMapKey: 'op1', + timestamp: Date.now(), + execution: { + ok: true, + duration: 200_000_000, + errorsTotal: 0, + }, + metadata: { + client: { + name: 'unknown', + version: 'v1.2.3', + }, + }, + }, + ], + }); + expect(collectResult.status).toEqual(200); + await waitFor(8000); + + const from = formatISO(subHours(Date.now(), 6)); + const to = formatISO(Date.now()); + const operationsStats = await readOperationsStats(from, to); + expect(operationsStats.operations.nodes).toHaveLength(1); + + const op = operationsStats.operations.nodes[0]; + + expect(operationsStats).toMatchInlineSnapshot(` + { + clients: { + nodes: [ + { + count: 1, + name: unknown, + versions: [ + { + count: 1, + version: v1.2.3, + }, + ], + }, + ], + }, + operations: { + nodes: [ + { + count: 1, + duration: { + p75: 200, + p90: 200, + p95: 200, + p99: 200, + }, + id: 8f87d0bc9744ad3d50af125d20c355c0, + kind: query, + name: 798a_ping, + operationHash: 798ae10ebeef9f632ceec2fbe85a2052, + percentage: 100, + }, + ], + }, + totalOperations: 1, + } + `); + + await expect(readOperationBody(op.operationHash!)).resolves.toEqual('query ping{ping}'); + + const clientStats = await readClientStats({ + clientName: 'unknown', + from, + to, + }); + expect(clientStats).toMatchInlineSnapshot(` + { + operations: { + nodes: [ + { + count: 1, + id: 8f87d0bc9744ad3d50af125d20c355c0, + name: 798a_ping, + operationHash: 798ae10ebeef9f632ceec2fbe85a2052, + }, + ], + }, + totalRequests: 1, + totalVersions: 1, + versions: [ + { + count: 1, + version: v1.2.3, + }, + ], + } + `); +}); + +test.concurrent('collect an operation from undefined client', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, readOperationBody, readOperationsStats, readClientStats } = + await createProject(ProjectType.Single); + const writeToken = await createTargetAccessToken({}); + + const collectResult = await writeToken.collectUsage({ + size: 1, + map: { + op1: { + operation: 'query ping { ping }', + operationName: 'ping', + fields: ['Query', 'Query.ping'], + }, + }, + operations: [ + { + operationMapKey: 'op1', + timestamp: Date.now(), + execution: { + ok: true, + duration: 200_000_000, + errorsTotal: 0, + }, + metadata: { + client: { + name: undefined as any, + version: 'v1.2.3', + }, + }, + }, + ], + }); + expect(collectResult.status).toEqual(200); + await waitFor(8000); + + const from = formatISO(subHours(Date.now(), 6)); + const to = formatISO(Date.now()); + const operationsStats = await readOperationsStats(from, to); + expect(operationsStats.operations.nodes).toHaveLength(1); + + const op = operationsStats.operations.nodes[0]; + + expect(operationsStats).toMatchInlineSnapshot(` + { + clients: { + nodes: [ + { + count: 1, + name: unknown, + versions: [ + { + count: 1, + version: v1.2.3, + }, + ], + }, + ], + }, + operations: { + nodes: [ + { + count: 1, + duration: { + p75: 200, + p90: 200, + p95: 200, + p99: 200, + }, + id: 8f87d0bc9744ad3d50af125d20c355c0, + kind: query, + name: 798a_ping, + operationHash: 798ae10ebeef9f632ceec2fbe85a2052, + percentage: 100, + }, + ], + }, + totalOperations: 1, + } + `); + + await expect(readOperationBody(op.operationHash!)).resolves.toEqual('query ping{ping}'); + + const clientStats = await readClientStats({ + clientName: 'unknown', + from, + to, + }); + expect(clientStats).toMatchInlineSnapshot(` + { + operations: { + nodes: [ + { + count: 1, + id: 8f87d0bc9744ad3d50af125d20c355c0, + name: 798a_ping, + operationHash: 798ae10ebeef9f632ceec2fbe85a2052, + }, + ], + }, + totalRequests: 1, + totalVersions: 1, + versions: [ + { + count: 1, + version: v1.2.3, + }, + ], + } + `); +}); diff --git a/package.json b/package.json index a5e4df4b9..25ef86625 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "start": "pnpm run local:setup", "test": "vitest", "test:e2e": "CYPRESS_BASE_URL=$HIVE_APP_BASE_URL cypress run", + "test:e2e:open": "CYPRESS_BASE_URL=$HIVE_APP_BASE_URL cypress open", "test:integration": "cd integration-tests && pnpm test:integration", "typecheck": "pnpm run -r --filter '!hive' typecheck", "upload-sourcemaps": "./scripts/upload-sourcemaps.sh", diff --git a/packages/web/app/src/components/layouts/organization-selectors.tsx b/packages/web/app/src/components/layouts/organization-selectors.tsx index d553e6276..38454d010 100644 --- a/packages/web/app/src/components/layouts/organization-selectors.tsx +++ b/packages/web/app/src/components/layouts/organization-selectors.tsx @@ -37,14 +37,18 @@ export function OrganizationSelector(props: { }); }} > - +
{currentOrganization?.slug}
{organizations.map(org => ( - + {org.slug} ))} diff --git a/packages/web/app/src/components/layouts/organization.tsx b/packages/web/app/src/components/layouts/organization.tsx index 1a8c3dc0f..df8d9c3c9 100644 --- a/packages/web/app/src/components/layouts/organization.tsx +++ b/packages/web/app/src/components/layouts/organization.tsx @@ -208,7 +208,12 @@ export function OrganizationLayout({ )} {currentOrganization?.viewerCanCreateProject ? ( <> - @@ -370,7 +375,7 @@ export function CreateProjectModalContent(props: {
- + Create a project @@ -386,7 +391,12 @@ export function CreateProjectModalContent(props: { Slug of your project - + @@ -441,6 +451,7 @@ export function CreateProjectModalContent(props: { + ); @@ -253,6 +258,7 @@ export function GenerateTokenContent(props: { @@ -267,7 +273,12 @@ export function GenerateTokenContent(props: { render={({ field }) => ( - + @@ -279,6 +290,7 @@ export function GenerateTokenContent(props: { Registry & Usage +
+ {checked.length === 0 ? null : ( - )} @@ -1224,6 +1231,7 @@ function TargetSettingsContent(props: { return (