mirror of
https://github.com/graphql-hive/console
synced 2026-05-24 09:38:26 +00:00
Add E2E test for usage reporting (#6009)
This commit is contained in:
parent
6cb4281450
commit
f5fd160efe
17 changed files with 876 additions and 44 deletions
|
|
@ -232,6 +232,9 @@ module.exports = {
|
|||
{
|
||||
files: 'cypress/**',
|
||||
extends: 'plugin:cypress/recommended',
|
||||
rules: {
|
||||
'cypress/no-unnecessary-waiting': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
321
cypress/e2e/usage.cy.ts
Normal file
321
cypress/e2e/usage.cy.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
39
cypress/support/testkit.ts
Normal file
39
cypress/support/testkit.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -37,14 +37,18 @@ export function OrganizationSelector(props: {
|
|||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger variant="default">
|
||||
<SelectTrigger variant="default" data-cy="organization-picker-trigger">
|
||||
<div className="font-medium" data-cy="organization-picker-current">
|
||||
{currentOrganization?.slug}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizations.map(org => (
|
||||
<SelectItem key={org.slug} value={org.slug}>
|
||||
<SelectItem
|
||||
key={org.slug}
|
||||
value={org.slug}
|
||||
data-cy={`organization-picker-option-${org.slug}`}
|
||||
>
|
||||
{org.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -208,7 +208,12 @@ export function OrganizationLayout({
|
|||
)}
|
||||
{currentOrganization?.viewerCanCreateProject ? (
|
||||
<>
|
||||
<Button onClick={toggleModalOpen} variant="link" className="text-orange-500">
|
||||
<Button
|
||||
onClick={toggleModalOpen}
|
||||
variant="link"
|
||||
className="text-orange-500"
|
||||
data-cy="new-project-button"
|
||||
>
|
||||
<PlusIcon size={16} className="mr-2" />
|
||||
New project
|
||||
</Button>
|
||||
|
|
@ -370,7 +375,7 @@ export function CreateProjectModalContent(props: {
|
|||
<Dialog open={props.isOpen} onOpenChange={props.toggleModalOpen}>
|
||||
<DialogContent className="container w-4/5 max-w-[600px] md:w-3/5">
|
||||
<Form {...props.form}>
|
||||
<form onSubmit={props.form.handleSubmit(props.onSubmit)}>
|
||||
<form onSubmit={props.form.handleSubmit(props.onSubmit)} data-cy="create-project-form">
|
||||
<DialogHeader className="mb-8">
|
||||
<DialogTitle>Create a project</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
@ -386,7 +391,12 @@ export function CreateProjectModalContent(props: {
|
|||
<FormItem className="mt-0">
|
||||
<FormLabel>Slug of your project</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-project" autoComplete="off" {...field} />
|
||||
<Input
|
||||
placeholder="my-project"
|
||||
data-cy="slug"
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -441,6 +451,7 @@ export function CreateProjectModalContent(props: {
|
|||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
data-cy="submit"
|
||||
disabled={props.form.formState.isSubmitting || !props.form.formState.isValid}
|
||||
>
|
||||
{props.form.formState.isSubmitting ? 'Submitting...' : 'Create Project'}
|
||||
|
|
|
|||
|
|
@ -64,12 +64,18 @@ export function ProjectSelector(props: {
|
|||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger variant="default">
|
||||
<div className="font-medium">{currentProject.slug}</div>
|
||||
<SelectTrigger variant="default" data-cy="project-picker-trigger">
|
||||
<div className="font-medium" data-cy="project-picker-current">
|
||||
{currentProject.slug}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map(project => (
|
||||
<SelectItem key={project.slug} value={project.slug}>
|
||||
<SelectItem
|
||||
key={project.slug}
|
||||
value={project.slug}
|
||||
data-cy={`project-picker-option-${project.slug}`}
|
||||
>
|
||||
{project.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -92,12 +92,18 @@ export function TargetSelector(props: {
|
|||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger variant="default">
|
||||
<div className="font-medium">{currentTarget.slug}</div>
|
||||
<SelectTrigger variant="default" data-cy="target-picker-trigger">
|
||||
<div className="font-medium" data-cy="target-picker-current">
|
||||
{currentTarget.slug}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targets.map(target => (
|
||||
<SelectItem key={target.slug} value={target.slug}>
|
||||
<SelectItem
|
||||
key={target.slug}
|
||||
value={target.slug}
|
||||
data-cy={`target-picker-option-${target.slug}`}
|
||||
>
|
||||
{target.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export const PermissionScopeItem = <
|
|||
canManageScope: boolean;
|
||||
noDowngrade?: boolean;
|
||||
possibleScope: T[];
|
||||
dataCy?: string;
|
||||
}): React.ReactElement => {
|
||||
const initialScope = props.initialScope ?? NoAccess;
|
||||
|
||||
|
|
@ -107,6 +108,7 @@ export const PermissionScopeItem = <
|
|||
'flex flex-row items-center justify-between space-x-4 py-2',
|
||||
props.canManageScope === false ? 'cursor-not-allowed opacity-50' : null,
|
||||
)}
|
||||
data-cy={props.dataCy}
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-white">{props.scope.name}</div>
|
||||
|
|
@ -119,10 +121,10 @@ export const PermissionScopeItem = <
|
|||
props.onChange(value as T | typeof NoAccess);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[150px] shrink-0">
|
||||
<SelectTrigger className="w-[150px] shrink-0" data-cy="select-trigger">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent data-cy={props.dataCy ? `${props.dataCy}-select-content` : ''}>
|
||||
{[
|
||||
{ value: NoAccess, label: 'No access' },
|
||||
props.scope.mapping['read-only'] &&
|
||||
|
|
@ -149,7 +151,12 @@ export const PermissionScopeItem = <
|
|||
|
||||
return (
|
||||
<>
|
||||
<SelectItem key={item.value} value={item.value} disabled={isDisabled}>
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
disabled={isDisabled}
|
||||
data-cy={`select-option-${item.value}`}
|
||||
>
|
||||
{item.label}
|
||||
{isDisabled ? (
|
||||
<span className="block text-xs italic">Can't downgrade</span>
|
||||
|
|
|
|||
|
|
@ -221,7 +221,10 @@ export function CreatedTokenContent(props: {
|
|||
toggleModalOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DialogContent className="container w-4/5 max-w-[600px] md:w-3/5">
|
||||
<DialogContent
|
||||
className="container w-4/5 max-w-[600px] md:w-3/5"
|
||||
data-cy="registry-token-created"
|
||||
>
|
||||
<DialogHeader className="flex flex-col gap-5">
|
||||
<DialogTitle>Token successfully created!</DialogTitle>
|
||||
<DialogDescription className="flex flex-col gap-5">
|
||||
|
|
@ -233,7 +236,9 @@ export function CreatedTokenContent(props: {
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={props.toggleModalOpen}>Ok, got it!</Button>
|
||||
<Button data-cy="close" onClick={props.toggleModalOpen}>
|
||||
Ok, got it!
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
|
|
@ -253,6 +258,7 @@ export function GenerateTokenContent(props: {
|
|||
<Form {...props.form}>
|
||||
<form
|
||||
className="flex grow flex-col gap-5"
|
||||
data-cy="create-registry-token-form"
|
||||
onSubmit={props.form.handleSubmit(props.onSubmit)}
|
||||
>
|
||||
<DialogHeader>
|
||||
|
|
@ -267,7 +273,12 @@ export function GenerateTokenContent(props: {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder="Token description" autoComplete="off" {...field} />
|
||||
<Input
|
||||
placeholder="Token description"
|
||||
data-cy="description"
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -279,6 +290,7 @@ export function GenerateTokenContent(props: {
|
|||
<Accordion.Header>Registry & Usage</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<PermissionScopeItem
|
||||
dataCy="registry-access-scope"
|
||||
key={props.selectedScope}
|
||||
scope={RegistryAccessScope}
|
||||
canManageScope={
|
||||
|
|
@ -314,6 +326,7 @@ export function GenerateTokenContent(props: {
|
|||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
data-cy="submit"
|
||||
disabled={!props.form.formState.isValid || props.noPermissionsSelected}
|
||||
>
|
||||
Generate Token
|
||||
|
|
|
|||
|
|
@ -146,10 +146,17 @@ function RegistryAccessTokens(props: {
|
|||
}
|
||||
/>
|
||||
{canManage && (
|
||||
<div className="my-3.5 flex justify-between">
|
||||
<Button onClick={toggleModalOpen}>Create new registry token</Button>
|
||||
<div className="my-3.5 flex justify-between" data-cy="target-settings-registry-token">
|
||||
<Button data-cy="new-button" onClick={toggleModalOpen}>
|
||||
Create new registry token
|
||||
</Button>
|
||||
{checked.length === 0 ? null : (
|
||||
<Button variant="destructive" disabled={deleting} onClick={deleteTokens}>
|
||||
<Button
|
||||
data-cy="delete-button"
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={deleteTokens}
|
||||
>
|
||||
Delete ({checked.length || null})
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -1224,6 +1231,7 @@ function TargetSettingsContent(props: {
|
|||
return (
|
||||
<Button
|
||||
key={subPage.key}
|
||||
data-cy={`target-settings-${subPage.key}-link`}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
void router.navigate({
|
||||
|
|
|
|||
Loading…
Reference in a new issue