Add E2E test for usage reporting (#6009)

This commit is contained in:
Kamil Kisiela 2024-11-21 09:20:29 +01:00 committed by GitHub
parent 6cb4281450
commit f5fd160efe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 876 additions and 44 deletions

View file

@ -232,6 +232,9 @@ module.exports = {
{
files: 'cypress/**',
extends: 'plugin:cypress/recommended',
rules: {
'cypress/no-unnecessary-waiting': 'off',
},
},
],
};

View file

@ -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
View 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');
});
});

View file

@ -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"

View 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();
}

View file

@ -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
}
}
}
}
`),

View file

@ -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(
{

View file

@ -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;

View file

@ -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,
},
],
}
`);
});

View file

@ -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",

View file

@ -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>
))}

View file

@ -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'}

View file

@ -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>
))}

View file

@ -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>
))}

View file

@ -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>

View file

@ -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

View file

@ -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({