diff --git a/e2e-tests/fixtures/Extension.ts b/e2e-tests/fixtures/Extension.ts new file mode 100644 index 0000000000..2ef9f41ed8 --- /dev/null +++ b/e2e-tests/fixtures/Extension.ts @@ -0,0 +1,67 @@ +import { APIRequestContext, Page } from '@playwright/test'; +import { getUserCookieValue } from '../utilities/helpers'; + +export class Extension { + async createExtension(page: Page, request: APIRequestContext, extensionName: string): Promise { + const cookie = getUserCookieValue(await page.context().cookies()); + + if (cookie !== undefined) { + const response = await request.post('http://localhost:8080/v1/graphql/', { + data: JSON.stringify({ + query: ` + mutation InsertExtension { + insert_extensions(objects: { + label: "${extensionName}", + description: "Description for ${extensionName}", + url: "http://localhost:8080", + extension_roles: { + data: { + role: aerie_admin + } + } + }) { + returning { + id + } + } + }`, + }), + headers: { + Authorization: `Bearer ${cookie}`, + 'Content-Type': 'application/json', + }, + }); + + const data = (await response.json()).data.insert_extensions.returning; + + return data[0].id as number; + } + } + + async deleteExtension(page: Page, request: APIRequestContext, id: number): Promise { + const cookie = getUserCookieValue(await page.context().cookies()); + + if (cookie !== undefined) { + await request.post('http://localhost:8080/v1/graphql/', { + data: JSON.stringify({ + query: ` + mutation DeleteExtension { + delete_extensions(where: { + id: { + _eq: ${id} + } + }) { + returning { + id + } + } + }`, + }), + headers: { + Authorization: `Bearer ${cookie}`, + 'Content-Type': 'application/json', + }, + }); + } + } +} diff --git a/e2e-tests/fixtures/Plan.ts b/e2e-tests/fixtures/Plan.ts index 21bffc778f..084d3b011c 100644 --- a/e2e-tests/fixtures/Plan.ts +++ b/e2e-tests/fixtures/Plan.ts @@ -28,6 +28,8 @@ export class Plan { navButtonConstraintsMenu: Locator; navButtonExpansion: Locator; navButtonExpansionMenu: Locator; + navButtonExtension: Locator; + navButtonExtensionMenu: Locator; navButtonScheduling: Locator; navButtonSchedulingMenu: Locator; navButtonSimulation: Locator; @@ -513,6 +515,8 @@ export class Plan { this.navButtonActivityCheckingMenu = this.navButtonActivityChecking.getByRole('menu'); this.navButtonExpansion = page.locator(`.nav-button:has-text("Expansion")`); this.navButtonExpansionMenu = this.navButtonExpansion.getByRole('menu'); + this.navButtonExtension = page.locator(`.nav-button:has-text("Extensions")`); + this.navButtonExtensionMenu = this.navButtonExtension.getByRole('menu'); this.navButtonConstraints = page.locator(`.nav-button:has-text("Constraints")`); this.navButtonConstraintsMenu = this.navButtonConstraints.getByRole('menu'); this.navButtonScheduling = page.locator(`.nav-button:has-text("Scheduling")`); diff --git a/e2e-tests/tests/extension.test.ts b/e2e-tests/tests/extension.test.ts new file mode 100644 index 0000000000..bfb2a3f25a --- /dev/null +++ b/e2e-tests/tests/extension.test.ts @@ -0,0 +1,84 @@ +import test, { BrowserContext, expect, Page } from '@playwright/test'; +import { adjectives, animals, colors, uniqueNamesGenerator } from 'unique-names-generator'; +import { Constraints } from '../fixtures/Constraints'; +import { Extension } from '../fixtures/Extension'; +import { Models } from '../fixtures/Models'; +import { Plan } from '../fixtures/Plan'; +import { Plans } from '../fixtures/Plans'; +import { SchedulingConditions } from '../fixtures/SchedulingConditions'; +import { SchedulingGoals } from '../fixtures/SchedulingGoals'; + +let extension: Extension; +let extensionName: string; +let extensionId: number | undefined; +let constraints: Constraints; +let context: BrowserContext; +let models: Models; +let page: Page; +let plan: Plan; +let plans: Plans; +let schedulingConditions: SchedulingConditions; +let schedulingGoals: SchedulingGoals; + +test.beforeAll(async ({ baseURL, browser, request }) => { + context = await browser.newContext(); + page = await context.newPage(); + + extension = new Extension(); + extensionName = uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals] }); + extensionId = await extension.createExtension(page, request, extensionName); + + models = new Models(page); + plans = new Plans(page, models); + constraints = new Constraints(page); + schedulingConditions = new SchedulingConditions(page); + schedulingGoals = new SchedulingGoals(page); + plan = new Plan(page, plans, constraints, schedulingGoals, schedulingConditions); + + await models.goto(); + await models.createModel(baseURL); + await plans.goto(); + await plans.createPlan(); + await plan.goto(); +}); + +test.afterAll(async () => { + await plan.deleteAllActivities(); + await plans.goto(); + await plans.deletePlan(); + await models.goto(); + await models.deleteModel(); + await page.close(); + await context.close(); +}); + +test.describe.serial('Extensions', () => { + test(`Hovering on 'Extensions' in the top navigation bar should show the extension menu`, async () => { + await expect(plan.navButtonExtensionMenu).not.toBeVisible(); + plan.navButtonExtension.hover(); + await expect(plan.navButtonExtensionMenu).toBeVisible(); + plan.planTitle.hover(); + await expect(plan.navButtonExtensionMenu).not.toBeVisible(); + }); + + test(`The extension that we created before the tests should be in the extension menu`, async () => { + plan.navButtonExtension.hover(); + await expect(plan.navButtonExtensionMenu).toBeVisible(); + await expect(plan.navButtonExtensionMenu.getByRole('menuitem', { name: extensionName })).toBeVisible(); + }); + + test(`Clicking the extension should invoke the http call`, async () => { + plan.navButtonExtension.hover(); + const extensionRequest = page.waitForRequest('http://localhost:3000/extensions'); + plan.navButtonExtensionMenu.getByRole('menuitem', { name: extensionName }).click(); + expect((await (await extensionRequest).response())?.ok).toBeTruthy(); + }); + + test(`Delete an extension`, async ({ page, request }) => { + if (extensionId !== undefined) { + await extension.deleteExtension(page, request, extensionId); + await expect(plan.navButtonExtensionMenu).not.toBeVisible(); + await expect(plan.navButtonExtension).not.toBeVisible(); + } + }); +}); diff --git a/e2e-tests/tests/plan.test.ts b/e2e-tests/tests/plan.test.ts index aa3f3c44b6..deece1d64d 100644 --- a/e2e-tests/tests/plan.test.ts +++ b/e2e-tests/tests/plan.test.ts @@ -137,6 +137,11 @@ test.describe.serial('Plan', () => { await expect(plan.navButtonSchedulingMenu).not.toBeVisible(); }); + test(`By default the extension menu should not show because there are no extensions`, async () => { + await expect(plan.navButtonExtension).not.toBeVisible(); + await expect(plan.navButtonExtensionMenu).not.toBeVisible(); + }); + test(`Changing to a new plan should clear the selected activity`, async ({ baseURL }) => { await plan.showPanel(PanelNames.TIMELINE_ITEMS); diff --git a/e2e-tests/utilities/helpers.ts b/e2e-tests/utilities/helpers.ts new file mode 100644 index 0000000000..58f282ba1a --- /dev/null +++ b/e2e-tests/utilities/helpers.ts @@ -0,0 +1,11 @@ +import { Cookie } from '@playwright/test'; + +export function getUserCookieValue(cookies: Cookie[]): string | undefined { + for (const cookie of cookies) { + if (cookie.name === 'user') { + return JSON.parse(atob(cookie.value)).token; + } + } + + return undefined; +} diff --git a/src/routes/plans/[id]/+page.svelte b/src/routes/plans/[id]/+page.svelte index 869aa0d4e9..a012c53b16 100644 --- a/src/routes/plans/[id]/+page.svelte +++ b/src/routes/plans/[id]/+page.svelte @@ -61,6 +61,7 @@ simulationDatasetErrors, } from '../../../stores/errors'; import { planExpansionStatus, resetExpansionStores, selectedExpansionSetId } from '../../../stores/expansion'; + import { extensions } from '../../../stores/extensions'; import { activityTypes, initialPlan, @@ -791,7 +792,7 @@ { initialPlan.model.view, ); const initialPlanSnapshotId = getSearchParameterNumber(SearchParameters.SNAPSHOT_ID, url.searchParams); - const extensions = await effects.getExtensions(user); return { - extensions, initialActivityTypes, initialPlan, initialPlanSnapshotId, diff --git a/src/stores/extensions.ts b/src/stores/extensions.ts new file mode 100644 index 0000000000..c5f9302bcd --- /dev/null +++ b/src/stores/extensions.ts @@ -0,0 +1,5 @@ +import type { Extension } from '../types/extension'; +import gql from '../utilities/gql'; +import { gqlSubscribable } from './subscribable'; + +export const extensions = gqlSubscribable(gql.SUB_EXTENSIONS, {}, [], null); diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index cbfd4327bb..d70dbc2ef2 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -3627,21 +3627,6 @@ const effects = { } }, - async getExtensions(user: User | null): Promise { - try { - const data = await reqHasura(gql.GET_EXTENSIONS, {}, user); - const { extensions = [] } = data; - if (extensions != null) { - return extensions; - } else { - throw Error('Unable to retrieve extensions'); - } - } catch (e) { - catchError(e as Error); - return []; - } - }, - async getExternalEventTypes(plan_id: number, user: User | null): Promise { try { const sourceData = await reqHasura< diff --git a/src/utilities/gql.ts b/src/utilities/gql.ts index 51b39e758c..fa3efde585 100644 --- a/src/utilities/gql.ts +++ b/src/utilities/gql.ts @@ -1460,22 +1460,6 @@ const gql = { } `, - GET_EXTENSIONS: `#graphql - query GetExtensions { - ${Queries.EXTENSIONS} { - description - extension_roles { - extension_id - role - } - id - label - updated_at - url - } - } - `, - GET_EXTERNAL_EVENTS: `#graphql query GetExternalEvents( $sourceKey: String!, @@ -2467,6 +2451,22 @@ const gql = { } `, + SUB_EXTENSIONS: `#graphql + subscription SubExtensions { + ${Queries.EXTENSIONS} { + description + extension_roles { + extension_id + role + } + id + label + updated_at + url + } + } + `, + SUB_EXTERNAL_EVENT_TYPES: `#graphql subscription SubExternalEventTypes { models: ${Queries.EXTERNAL_EVENT_TYPES}(order_by: { name: asc }) { diff --git a/src/utilities/permissions.ts b/src/utilities/permissions.ts index 1a0fa57992..d96523f96d 100644 --- a/src/utilities/permissions.ts +++ b/src/utilities/permissions.ts @@ -683,7 +683,6 @@ const queryPermissions: Record b return isUserAdmin(user) || getPermission([Queries.SEQUENCE_TO_SIMULATED_ACTIVITY], user); }, GET_EXPANSION_SEQUENCE_SEQ_JSON: () => true, - GET_EXTENSIONS: () => true, GET_EXTERNAL_EVENTS: () => true, GET_EXTERNAL_EVENT_TYPE_BY_SOURCE: () => true, GET_MODELS: () => true, @@ -858,6 +857,7 @@ const queryPermissions: Record b SUB_EXPANSION_SETS: (user: User | null): boolean => { return isUserAdmin(user) || getPermission([Queries.EXPANSION_SETS], user); }, + SUB_EXTENSIONS: () => true, SUB_EXTERNAL_EVENT_TYPES: () => true, SUB_EXTERNAL_SOURCE: () => true, SUB_EXTERNAL_SOURCES: () => true,