Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: [M3-7083 & M3-7175] MarketPlace regex fix + end to end coverage #9704

Merged
merged 16 commits into from
Sep 28, 2023
Merged
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-9704-fixed-1695752085559.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

Overly permissive regex in One Click Apps flow ([#9704](https://github.com/linode/manager/pull/9704))
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { containsClick, containsVisible } from 'support/helpers';
import { ui } from 'support/ui';
import { authenticate } from 'support/api/authentication';
import { cleanUp } from 'support/util/cleanup';
import {
interceptGetStackScripts,
mockGetStackScripts,
} from 'support/intercepts/stackscripts';
import { interceptCreateLinode } from 'support/intercepts/linodes';
import {
filterOneClickApps,
handleAppLabel,
} from 'src/features/Linodes/LinodesCreate/utilities';
import { randomLabel, randomString } from 'support/util/random';
import { chooseRegion } from 'support/util/regions';
import {
mockAppendFeatureFlags,
mockGetFeatureFlagClientstream,
} from 'support/intercepts/feature-flags';
import { makeFeatureFlagData } from 'support/util/feature-flags';
import { mapStackScriptLabelToOCA } from 'src/features/OneClickApps/utils';
import { baseApps } from 'src/features/StackScripts/stackScriptUtils';
import { stackScriptFactory } from 'src/factories/stackscripts';
import { oneClickApps } from 'src/features/OneClickApps/oneClickApps';

import type { StackScript } from '@linode/api-v4';
import type { OCA } from '@src/features/OneClickApps/types';

authenticate();

describe('OneClick Apps (OCA)', () => {
before(() => {
cleanUp(['linodes']);
});

it('Lists all the OneClick Apps', () => {
interceptGetStackScripts().as('getStackScripts');

cy.visitWithLogin(`/linodes/create?type=One-Click`);

cy.wait('@getStackScripts').then((xhr) => {
const stackScripts: StackScript[] = xhr.response?.body.data ?? [];

const trimmedApps: StackScript[] = filterOneClickApps({
baseApps,
newApps: {},
queryResults: stackScripts,
});

// Check the content of the OCA listing
cy.findByTestId('one-click-apps-container').within(() => {
// Check that all sections are present (note: New apps can be empty so not asserting its presence)
cy.findByTestId('Popular apps').should('exist');
cy.findByTestId('All apps').should('exist');

trimmedApps.forEach((stackScript) => {
const { decodedLabel, label } = handleAppLabel(stackScript);

// Check that every OCA is listed with the correct label
cy.get(`[data-qa-select-card-heading="${label}"]`).should('exist');

// Check that every OCA has a drawer match
// This validates the regex in `mapStackScriptLabelToOCA`
// and ensures every app listed has a corresponding populated drawer
// This is only true for the apps defined in `oneClickApps.ts`
expect(
mapStackScriptLabelToOCA({
oneClickApps,
stackScriptLabel: decodedLabel,
})
).to.not.be.undefined;
});
});

// Check drawer content for one OCA candidate
const candidate = trimmedApps[0].label;
const stackScriptCandidate = cy
.get(`[data-qa-selection-card-info="${candidate}"]`)
.first();
stackScriptCandidate.should('exist').click();

const app: OCA | undefined = mapStackScriptLabelToOCA({
oneClickApps,
stackScriptLabel: candidate,
});

ui.drawer
.findByTitle(trimmedApps[0].label)
.should('be.visible')
.within(() => {
containsVisible(app?.description);
containsVisible(app?.summary);
containsVisible(app?.website);
});
ui.drawerCloseButton.find().click();
ui.drawer.find().should('not.exist');

// Check the filtering of the apps
cy.scrollTo(0, 0);
const initialNumberOfApps = trimmedApps.length;
cy.findByPlaceholderText('Search for app name')
.should('exist')
.type(candidate);
Comment on lines +99 to +103
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
cy.scrollTo(0, 0);
const initialNumberOfApps = trimmedApps.length;
cy.findByPlaceholderText('Search for app name')
.should('exist')
.type(candidate);
const initialNumberOfApps = trimmedApps.length;
cy.findByPlaceholderText('Search for app name')
.scrollIntoView()
.should('be.visible')
.type(candidate);

This might be slightly clearer about what the test is trying to do but very minor difference

cy.findByTestId('one-click-apps-container').within(() => {
cy.get('[data-qa-selection-card="true"]').should(
'have.length.below',
initialNumberOfApps
);
cy.get(`[data-qa-selection-card-info="${candidate}"]`).should(
'be.visible'
);
});
});
});

it('Deploys a Linode from a One Click App', () => {
const stackscriptId = 401709;
const stackScripts = stackScriptFactory.build({
id: stackscriptId,
username: 'linode',
user_gravatar_id: '9d4d301385af69ceb7ad658aad09c142',
label: 'E2E Test App',
description: 'Minecraft OCA',
ordinal: 10,
logo_url: 'assets/Minecraft.svg',
images: ['linode/debian11', 'linode/ubuntu20.04'],
deployments_total: 18854,
deployments_active: 412,
is_public: true,
mine: false,
created: '2019-03-08T21:13:32',
updated: '2023-09-26T15:00:45',
rev_note: 'remove maxplayers hard coded options [oca-707]',
script: '#!/usr/bin/env bash\n',
user_defined_fields: [
{
name: 'username',
label:
"The username for the Linode's non-root admin/SSH user(must be lowercase)",
example: 'lgsmuser',
},
{
name: 'password',
label: "The password for the Linode's non-root admin/SSH user",
example: 'S3cuReP@s$w0rd',
},
{
name: 'levelname',
label: 'World Name',
},
],
});

const firstName = randomLabel();
const password = randomString(16);
const image = 'linode/ubuntu20.04';
const rootPassword = randomString(16);
const region = chooseRegion();
const linodeLabel = randomLabel();
const levelName = 'Get the enderman!';

mockGetStackScripts(stackScripts).as('getStackScripts');
mockAppendFeatureFlags({
oneClickApps: makeFeatureFlagData({
401709: 'E2E Test App',
}),
}).as('getFeatureFlags');
mockGetFeatureFlagClientstream().as('getClientStream');

cy.visitWithLogin(`/linodes/create?type=One-Click`);

cy.wait('@getFeatureFlags');
cy.wait('@getStackScripts');

cy.findByTestId('one-click-apps-container').within(() => {
// Since it is mock data we can assert the New App section is present
cy.findByTestId('New apps').should('exist');

// Check that the app is listed and select it
cy.get('[data-qa-selection-card="true"]').should('have.length', 3);
cy.get(`[id=app-${stackscriptId}]`).first().should('be.visible').click();
});

// Input the user defined fields
const userFieldId =
"the-username-for-the-linode's-non-root-admin/ssh-user(must-be-lowercase)";
const passwordFieldId =
"the-password-for-the-linode's-non-root-admin/ssh-user";
const levelNameFieldId = 'world-name';

cy.findByTestId('user-defined-fields-panel').within(() => {
cy.get(`[id="${userFieldId}"]`)
.should('be.visible')
.click()
.type(`${firstName}{enter}`);
cy.get(`[id="${passwordFieldId}"]`)
.should('be.visible')
.click()
.type(`${password}{enter}`);
cy.get(`[id="${levelNameFieldId}"]`)
.should('be.visible')
.click()
.type(`${levelName}{enter}`);

// Check each field should persist when moving onto another field
cy.get(`[id="${userFieldId}"]`).should('have.value', firstName);
cy.get(`[id="${passwordFieldId}"]`).should('have.value', password);
cy.get(`[id="${levelNameFieldId}"]`).should('have.value', levelName);
});

// Choose an image
cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => {
containsClick('Choose an image').type(`${image}{enter}`);
});

// Choose a region
cy.get(`[data-qa-enhanced-select="Select a Region"]`).within(() => {
containsClick('Select a Region').type(`${region.id}{enter}`);
});

// Choose a Linode plan
cy.get('[data-qa-plan-row="Dedicated 8 GB"]')
.closest('tr')
.within(() => {
cy.get('[data-qa-radio]').click();
});

// Enter a label.
cy.findByText('Linode Label')
.should('be.visible')
.click()
.type('{selectAll}{backspace}')
.type(linodeLabel);

// Choose a Root Password
cy.get('[id="root-password"]').type(rootPassword);

// Create the Linode
interceptCreateLinode().as('createLinode');
ui.button
.findByTitle('Create Linode')
.should('be.visible')
.should('be.enabled')
.click();

cy.wait('@createLinode');
ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`);
});
});
1 change: 1 addition & 0 deletions packages/manager/cypress/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"moduleResolution": "node",
"lib": ["es6", "dom"],
"baseUrl": "..",
"jsx": "react",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was necessary to remove an import warning when importing a utility from a .tsx extension

"paths": {
"@src/*": ["src/*"],
"support/*": ["cypress/support/*"],
Expand Down
21 changes: 19 additions & 2 deletions packages/manager/src/factories/stackscripts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
import * as Factory from 'factory.ts';

import type {
StackScript,
UserDefinedField,
} from '@linode/api-v4/lib/stackscripts/types';
import * as Factory from 'factory.ts';
import type { OCA } from 'src/features/OneClickApps/types';

export const stackScriptFactory = Factory.Sync.makeFactory<StackScript>({
created: '2010-12-31T23:59:58',
Expand All @@ -24,6 +26,21 @@ export const stackScriptFactory = Factory.Sync.makeFactory<StackScript>({
username: 'Linode',
});

export const oneClickAppFactory = Factory.Sync.makeFactory<OCA>({
alt_description: 'A test app',
alt_name: 'Test App',
categories: ['App Creators'],
colors: {
end: '#000000',
start: '#000000',
},
description: 'A test app',
logo_url: 'nodejs.svg',
name: 'Test App',
summary: 'A test app',
website: 'https://www.linode.com',
});

export const userDefinedFieldFactory = Factory.Sync.makeFactory<UserDefinedField>(
{
label: Factory.each((i) => `Field${i}`),
Expand Down
8 changes: 3 additions & 5 deletions packages/manager/src/featureFlags.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { TPAProvider } from '@linode/api-v4/lib/profile';

import { NoticeVariant } from 'src/components/Notice/Notice';

import { Doc } from './features/OneClickApps/oneClickApps';
import type { Doc } from './features/OneClickApps/types';
import type { TPAProvider } from '@linode/api-v4/lib/profile';
import type { NoticeVariant } from 'src/components/Notice/Notice';

// These flags should correspond with active features flags in LD

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { StackScript, UserDefinedField } from '@linode/api-v4/lib/stackscripts';
import Grid from '@mui/material/Unstable_Grid2';
import { styled } from '@mui/material/styles';
import { decode } from 'he';
import * as React from 'react';

import { Box } from 'src/components/Box';
import { Chip } from 'src/components/Chip';
import { Divider } from 'src/components/Divider';
import { Typography } from 'src/components/Typography';
import { SelectionCardWrapper } from 'src/features/Linodes/LinodesCreate/SelectionCardWrapper';

import { handleAppLabel } from './utilities';

interface Props {
apps: StackScript[];
disabled: boolean;
Expand Down Expand Up @@ -41,24 +43,15 @@ export const AppPanelSection = (props: Props) => {
}

return (
<>
<Box data-testid={heading}>
<Typography variant="h2">{heading}</Typography>
{heading && heading.length > 0 ? (
<Divider spacingBottom={16} spacingTop={16} />
) : null}
{apps.length > 0 ? (
<AppPanelGrid container spacing={2}>
{apps.map((eachApp) => {
const decodedLabel = decode(eachApp.label);
const isCluster =
decodedLabel.endsWith('Cluster ') &&
eachApp.user_defined_fields.some(
(field) => field.name === 'cluster_size'
);

const label = isCluster
? decodedLabel.split(' Cluster')[0]
: decodedLabel;
const { decodedLabel, isCluster, label } = handleAppLabel(eachApp);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved this to a util so it's reusable and testable in isolation.


return (
<SelectionCardWrapper
Expand Down Expand Up @@ -86,7 +79,7 @@ export const AppPanelSection = (props: Props) => {
{`Sorry, no results matching "${searchValue}" were found.`}
</Typography>
)}
</>
</Box>
);
};

Expand Down
Loading