Fix tests

Signed-off-by: solimant <solimant@users.noreply.github.com>
This commit is contained in:
solimant
2024-12-29 19:33:15 +00:00
parent 9d2bd50113
commit 29d8a18841
23 changed files with 336 additions and 81 deletions
@@ -105,8 +105,8 @@ export type ListActions = {};
export type ListTasks = {
query: {
createdBy?: Array<string>;
limit?: Array<number>;
offset?: Array<number>;
limit?: number;
offset?: number;
order?: Array<string>;
status?: Array<string>;
};
@@ -339,7 +339,7 @@ export class DefaultApiClient {
): Promise<TypedResponse<ListTasksResponse>> {
const baseUrl = await this.discoveryApi.getBaseUrl(pluginId);
const uriTemplate = `/v2/tasks{?createdBy*,limit*,offset*,order*,status*}`;
const uriTemplate = `/v2/tasks{?createdBy*,limit,offset,order*,status*}`;
const uri = parser.parse(uriTemplate).expand({
...request.query,
@@ -0,0 +1,27 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
/**
* @public
*/
export interface Autocomplete400Response {
message?: string;
name?: string;
}
@@ -17,13 +17,12 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { JsonValue } from '../models/JsonValue.model';
/**
* @public
*/
export interface TemplateParameterSchemaStepsInner {
title: JsonValue;
description?: JsonValue;
title: string;
description?: string;
schema: any;
}
@@ -17,6 +17,7 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { ValidationErrorArgument } from '../models/ValidationErrorArgument.model';
import { ValidationErrorPathInner } from '../models/ValidationErrorPathInner.model';
/**
@@ -30,6 +31,6 @@ export interface ValidationError {
message: string;
instance: any;
name: string;
argument: any;
argument: ValidationErrorArgument;
stack: string;
}
@@ -0,0 +1,24 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
/**
* @public
*/
export type ValidationErrorArgument = any | boolean | number | string;
@@ -19,6 +19,7 @@ export * from '../models/ActionExample.model';
export * from '../models/ActionSchema.model';
export * from '../models/Autocomplete200Response.model';
export * from '../models/Autocomplete200ResponseResultsInner.model';
export * from '../models/Autocomplete400Response.model';
export * from '../models/AutocompleteRequest.model';
export * from '../models/CancelTask200Response.model';
export * from '../models/DryRun200Response.model';
@@ -61,4 +62,5 @@ export * from '../models/TemplateInfoEntity.model';
export * from '../models/TemplateParameterSchema.model';
export * from '../models/TemplateParameterSchemaStepsInner.model';
export * from '../models/ValidationError.model';
export * from '../models/ValidationErrorArgument.model';
export * from '../models/ValidationErrorPathInner.model';
+2 -1
View File
@@ -61,7 +61,6 @@
},
"dependencies": {
"@backstage/catalog-model": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
@@ -73,7 +72,9 @@
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/test-utils": "workspace:^",
"cross-fetch": "^4.0.0",
"msw": "^1.0.0",
"uri-template": "^2.0.0"
}
}
@@ -0,0 +1,434 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ScmIntegrations } from '@backstage/integration';
import {
MockFetchApi,
mockApis,
registerMswTestHooks,
} from '@backstage/test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ScaffolderClient } from './ScaffolderClient';
import { fetchEventSource } from '@microsoft/fetch-event-source';
jest.mock('@microsoft/fetch-event-source');
const mockFetchEventSource = fetchEventSource as jest.MockedFunction<
typeof fetchEventSource
>;
const server = setupServer();
describe('api', () => {
registerMswTestHooks(server);
const githubIntegrationConfig = mockApis.config({
data: {
integrations: {
github: [
{
host: 'hello.com',
},
],
},
},
});
const mockBaseUrl = 'http://backstage/api';
const discoveryApi = { getBaseUrl: async () => mockBaseUrl };
const fetchApi = new MockFetchApi();
const identityApi = {
getBackstageIdentity: jest.fn(),
getProfileInfo: jest.fn(),
getCredentials: jest.fn(),
signOut: jest.fn(),
};
const scmIntegrationsApi = ScmIntegrations.fromConfig(
githubIntegrationConfig,
);
let apiClient: ScaffolderClient;
beforeEach(() => {
apiClient = new ScaffolderClient({
scmIntegrationsApi,
discoveryApi,
fetchApi,
identityApi,
});
jest.restoreAllMocks();
identityApi.getBackstageIdentity.mockReturnValue({});
});
it('should return default and custom integrations', async () => {
const allowedHosts = [
'hello.com',
'gitlab.com',
'github.com',
'dev.azure.com',
'bitbucket.org',
];
const { integrations } = await apiClient.getIntegrationsList({
allowedHosts,
});
integrations.forEach(integration =>
expect(allowedHosts).toContain(integration.host),
);
});
describe('streamEvents', () => {
describe('eventsource', () => {
it('should work', async () => {
mockFetchEventSource.mockImplementation(async (_url, options) => {
const { onopen, onmessage } = options;
await Promise.resolve();
await onopen?.({ ok: true } as Response);
await Promise.resolve();
onmessage?.({
id: '',
event: 'log',
data: '{"id":1,"taskId":"a-random-id","type":"log","createdAt":"","body":{"message":"My log message"}}',
});
await Promise.resolve();
onmessage?.({
id: '',
event: 'completion',
data: '{"id":2,"taskId":"a-random-id","type":"completion","createdAt":"","body":{"message":"Finished!"}}',
});
});
const next = jest.fn();
await new Promise<void>(complete => {
apiClient
.streamLogs({ taskId: 'a-random-task-id' })
.subscribe({ next, complete });
});
expect(mockFetchEventSource).toHaveBeenCalledWith(
'http://backstage/api/v2/tasks/a-random-task-id/eventstream',
{
fetch: fetchApi.fetch,
onmessage: expect.any(Function),
onerror: expect.any(Function),
signal: expect.any(AbortSignal),
},
);
expect(next).toHaveBeenCalledTimes(2);
expect(next).toHaveBeenCalledWith({
id: 1,
taskId: 'a-random-id',
type: 'log',
createdAt: '',
body: { message: 'My log message' },
});
expect(next).toHaveBeenCalledWith({
id: 2,
taskId: 'a-random-id',
type: 'completion',
createdAt: '',
body: { message: 'Finished!' },
});
});
});
describe('longPolling', () => {
beforeEach(() => {
apiClient = new ScaffolderClient({
scmIntegrationsApi,
discoveryApi,
fetchApi,
identityApi,
useLongPollingLogs: true,
});
});
it('should work', async () => {
server.use(
rest.get(
`${mockBaseUrl}/v2/tasks/:taskId/events`,
(req, res, ctx) => {
const { taskId } = req.params;
const after = req.url.searchParams.get('after');
if (taskId === 'a-random-task-id') {
if (!after) {
return res(
ctx.json([
{
id: 1,
taskId: 'a-random-id',
type: 'log',
createdAt: '',
body: { message: 'My log message' },
},
]),
);
} else if (after === '1') {
return res(
ctx.json([
{
id: 2,
taskId: 'a-random-id',
type: 'completion',
createdAt: '',
body: { message: 'Finished!' },
},
]),
);
}
}
return res(ctx.status(500));
},
),
);
const next = jest.fn();
await new Promise<void>(complete =>
apiClient
.streamLogs({ taskId: 'a-random-task-id' })
.subscribe({ next, complete }),
);
expect(next).toHaveBeenCalledTimes(2);
expect(next).toHaveBeenCalledWith({
id: 1,
taskId: 'a-random-id',
type: 'log',
createdAt: '',
body: { message: 'My log message' },
});
expect(next).toHaveBeenCalledWith({
id: 2,
taskId: 'a-random-id',
type: 'completion',
createdAt: '',
body: { message: 'Finished!' },
});
});
it('should unsubscribe', async () => {
expect.assertions(3);
server.use(
rest.get(
`${mockBaseUrl}/v2/tasks/:taskId/events`,
(req, res, ctx) => {
const { taskId } = req.params;
const after = req.url.searchParams.get('after');
// use assertion to make sure it is not called after unsubscribing
expect(after).toBe(null);
if (taskId === 'a-random-task-id') {
return res(
ctx.json([
{
id: 1,
taskId: 'a-random-id',
type: 'log',
createdAt: '',
body: { message: 'My log message' },
},
]),
);
}
return res(ctx.status(500));
},
),
);
const next = jest.fn();
await new Promise<void>(complete => {
const subscription = apiClient
.streamLogs({ taskId: 'a-random-task-id' })
.subscribe({
next: (...args) => {
next(...args);
subscription.unsubscribe();
complete();
},
});
});
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith({
id: 1,
taskId: 'a-random-id',
type: 'log',
createdAt: '',
body: { message: 'My log message' },
});
});
it('should continue after error', async () => {
const called = jest.fn();
server.use(
rest.get(
`${mockBaseUrl}/v2/tasks/:taskId/events`,
(_req, res, ctx) => {
called();
if (called.mock.calls.length > 1) {
return res(
ctx.json([
{
id: 2,
taskId: 'a-random-id',
type: 'completion',
createdAt: '',
body: { message: 'Finished!' },
},
]),
);
}
return res(ctx.status(500));
},
),
);
const next = jest.fn();
await new Promise<void>(complete =>
apiClient
.streamLogs({ taskId: 'a-random-task-id' })
.subscribe({ next, complete }),
);
expect(called).toHaveBeenCalledTimes(2);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith({
id: 2,
taskId: 'a-random-id',
type: 'completion',
createdAt: '',
body: { message: 'Finished!' },
});
});
});
});
describe('listTasks', () => {
it('should list all tasks', async () => {
server.use(
rest.get(`${mockBaseUrl}/v2/tasks`, (req, res, ctx) => {
const createdBy = req.url.searchParams.get('createdBy');
if (createdBy) {
return res(
ctx.json([
{
createdBy,
},
]),
);
}
return res(
ctx.json([
{
createdBy: null,
},
{
createdBy: null,
},
]),
);
}),
);
const result = await apiClient.listTasks({ filterByOwnership: 'all' });
expect(result).toHaveLength(2);
});
it('should list tasks with limit and offset', async () => {
server.use(
rest.get(
`${mockBaseUrl}/v2/tasks?limit=5&offset=0`,
(_req, res, ctx) => {
return res(
ctx.json([
{
createdBy: null,
},
{
createdBy: null,
},
]),
);
},
),
);
const result = await apiClient.listTasks({
filterByOwnership: 'all',
limit: 5,
offset: 0,
});
expect(result).toHaveLength(2);
});
it('should list task using the current user as owner', async () => {
server.use(
rest.get(`${mockBaseUrl}/v2/tasks`, (req, res, ctx) => {
const createdBy = req.url.searchParams.get('createdBy');
if (createdBy) {
return res(
ctx.json({
tasks: [
{
createdBy,
},
],
}),
);
}
return res(
ctx.json({
tasks: [
{
createdBy: null,
},
{
createdBy: null,
},
],
}),
);
}),
);
identityApi.getBackstageIdentity.mockResolvedValueOnce({
userEntityRef: 'user:default/foo',
});
const result = await apiClient.listTasks({ filterByOwnership: 'owned' });
expect(identityApi.getBackstageIdentity).toHaveBeenCalled();
expect(result.tasks).toHaveLength(1);
});
});
});
@@ -15,11 +15,6 @@
*/
import { parseEntityRef } from '@backstage/catalog-model';
import {
DiscoveryApi,
FetchApi,
IdentityApi,
} from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { Observable } from '@backstage/types';
@@ -50,6 +45,9 @@ import {
TaskStatus,
TypedResponse,
} from '../client/src/schema/openapi';
import { DiscoveryApi } from '../client/src/schema/openapi/generated/types/discovery';
import { FetchApi } from '../client/src/schema/openapi/generated/types/fetch';
import { IdentityApi } from './types/IdentityApi';
/**
* An API to interact with the scaffolder backend.
@@ -106,8 +104,8 @@ export class ScaffolderClient implements ScaffolderApi {
request.filterByOwnership === 'owned'
? [userEntityRef]
: undefined,
limit: request.limit ? [request.limit] : undefined,
offset: request.offset ? [request.offset] : undefined,
limit: request.limit,
offset: request.offset,
},
},
options,
@@ -0,0 +1,47 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* This is a copy of the BackstageUserIdentity, to avoid importing core-plugin-api.
*/
type BackstageUserIdentity = {
/**
* The type of identity that this structure represents. In the frontend app
* this will currently always be 'user'.
*/
type: 'user';
/**
* The entityRef of the user in the catalog.
* For example User:default/sandra
*/
userEntityRef: string;
/**
* The user and group entities that the user claims ownership through
*/
ownershipEntityRefs: string[];
};
/**
* This is a partial copy of the IdentityApi, to avoid importing core-plugin-api.
*/
export type IdentityApi = {
/**
* User identity information within Backstage.
*/
getBackstageIdentity(): Promise<BackstageUserIdentity>;
};