diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c9d56d371..73a3d83ca 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -16,7 +16,31 @@ jobs: run: | yarn install yarn build + ./packages/components/node_modules/cypress/bin/cypress install - name: Run unit tests run: yarn test + - name: cypress run + uses: cypress-io/github-action@v2 + with: + command: yarn test:cypress-ct + working-directory: packages/components + # after the test run completes + # store videos and any screenshots + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: cypress-screenshots + path: packages/components/cypress/screenshots + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: cypress-videos + path: packages/components/cypress/videos + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: cypress-snapshots + path: packages/components/cypress/snapshots + diff --git a/.gitignore b/.gitignore index 12b594dc1..4f1e604a4 100755 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,8 @@ UserInterfaceState.xcuserstate __diff_output__ # Cypress screenshots -cypress/screenshots +**/cypress/screenshots +**/cypress/videos # Local development hard-coded credentials for use with the AWS SDK. creds.json diff --git a/packages/components/cypress.json b/packages/components/cypress.json new file mode 100644 index 000000000..7bc263682 --- /dev/null +++ b/packages/components/cypress.json @@ -0,0 +1,12 @@ +{ + "component": { + "videoUploadOnPasses": false, + "video": true, + "viewportWidth": 400, + "viewportHeight": 500, + "watchForFileChanges": true, + "defaultCommandTimeout": 8000, + "componentFolder": "src", + "testFiles": "**/*.spec.component.ts" + } +} diff --git a/packages/components/cypress/fixtures/example.json b/packages/components/cypress/fixtures/example.json new file mode 100644 index 000000000..02e425437 --- /dev/null +++ b/packages/components/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/packages/components/cypress/plugins/index.js b/packages/components/cypress/plugins/index.js new file mode 100644 index 000000000..e0d78432b --- /dev/null +++ b/packages/components/cypress/plugins/index.js @@ -0,0 +1,16 @@ +const { startDevServer } = require('@cypress/webpack-dev-server') +const webpackConfig = require('@vue/cli-service/webpack.config.js') +const { addMatchImageSnapshotPlugin } = require('cypress-image-snapshot/plugin'); + +module.exports = (on, config) => { + on('dev-server:start', options => + startDevServer({ + options, + webpackConfig + }) + ) + + addMatchImageSnapshotPlugin(on, config); + + return config +} diff --git a/packages/components/cypress/snapshots/components/iot-connector/iot-connector.spec.component.ts/zooms in.snap.png b/packages/components/cypress/snapshots/components/iot-connector/iot-connector.spec.component.ts/zooms in.snap.png new file mode 100644 index 000000000..27a0e1094 Binary files /dev/null and b/packages/components/cypress/snapshots/components/iot-connector/iot-connector.spec.component.ts/zooms in.snap.png differ diff --git a/packages/components/cypress/snapshots/components/iot-connector/iot-connector.spec.component.ts/zooms out.snap.png b/packages/components/cypress/snapshots/components/iot-connector/iot-connector.spec.component.ts/zooms out.snap.png new file mode 100644 index 000000000..440697bc2 Binary files /dev/null and b/packages/components/cypress/snapshots/components/iot-connector/iot-connector.spec.component.ts/zooms out.snap.png differ diff --git a/packages/components/cypress/support/chartCommands.ts b/packages/components/cypress/support/chartCommands.ts new file mode 100644 index 000000000..f8bb8bd2f --- /dev/null +++ b/packages/components/cypress/support/chartCommands.ts @@ -0,0 +1,15 @@ +export const addChartCommands = () => { + Cypress.Commands.add( + 'matchImageSnapshotOnCI', + { prevSubject: 'optional' }, + (subject, nameOrOptions?: string | Object) => { + if (!Cypress.env('disableSnapshotTests')) { + if (subject) { + cy.wrap(subject).matchImageSnapshot(nameOrOptions); + } else { + cy.matchImageSnapshot(nameOrOptions); + } + } + } + ); +}; diff --git a/packages/components/cypress/support/commands.js b/packages/components/cypress/support/commands.js new file mode 100644 index 000000000..89feae1d2 --- /dev/null +++ b/packages/components/cypress/support/commands.js @@ -0,0 +1,9 @@ +import { addMatchImageSnapshotCommand } from 'cypress-image-snapshot/command'; +import { addChartCommands } from './chartCommands'; + +addChartCommands(); + +addMatchImageSnapshotCommand({ + failureThreshold: 0.025, + failureThresholdType: 'percent', +}); diff --git a/packages/components/cypress/support/index.d.ts b/packages/components/cypress/support/index.d.ts new file mode 100644 index 000000000..ee9d6788b --- /dev/null +++ b/packages/components/cypress/support/index.d.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +/// + +declare namespace Cypress { + interface Chainable { + /** + * Custom command to wait until a chart is done with it's initial rendering + */ + matchImageSnapshotOnCI(nameOrOptions?: string | Object): void; + } +} diff --git a/packages/components/cypress/support/index.js b/packages/components/cypress/support/index.js new file mode 100644 index 000000000..d68db96df --- /dev/null +++ b/packages/components/cypress/support/index.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/packages/components/cypress/tsconfig.json b/packages/components/cypress/tsconfig.json new file mode 100644 index 000000000..7e3715041 --- /dev/null +++ b/packages/components/cypress/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "baseUrl": "../node_modules", + "target": "es5", + "lib": ["es2019", "dom"], + "types": ["cypress", "@types/cypress-image-snapshot"] + }, + "include": [ + "**/*.ts" + ] +} diff --git a/packages/components/package.json b/packages/components/package.json index b74cb7056..3cff0ad50 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -18,27 +18,38 @@ "build": "stencil build", "start": "stencil build --dev --watch --serve", "test": "stencil test --spec", - "test.watch": "stencil test --spec --watchAll" + "test.watch": "stencil test --spec --watchAll", + "test:cypress-ct": "cypress run-ct", + "test:cypress-ct-dev": "cypress open-ct --env disableSnapshotTests=true" }, "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0" }, "dependencies": { - "@iot-app-kit/core": "^0.0.1", - "@iot-app-kit/related-table": "^1.0.0", "@awsui/collection-hooks": "^1.0.0", "@awsui/components-react": "^3.0.0", "@awsui/design-tokens": "^3.0.0", + "@iot-app-kit/core": "^0.0.1", + "@iot-app-kit/related-table": "^1.0.0", "@stencil/core": "^2.7.0", "@synchro-charts/core": "^1.0.8", "styled-components": "^5.3.0" }, "devDependencies": { + "@cypress/vue": "^3.1.0", + "@cypress/webpack-dev-server": "^1.8.0", "@rollup/plugin-replace": "^3.0.0", "@stencil/router": "^1.0.1", + "@types/cypress-image-snapshot": "^3.1.6", "@types/react": ">=16.9.0", - "@types/react-dom": ">=16.9.0" + "@types/react-dom": ">=16.9.0", + "@vue/cli-plugin-typescript": "^4.5.15", + "@vue/cli-service": "^4.5.15", + "@vue/compiler-dom": "^3.2.26", + "cypress": "7.7.0", + "cypress-image-snapshot": "^4.0.1", + "vue": "^3.2.26" }, "license": "Apache-2.0" } diff --git a/packages/components/src/components/iot-connector/iot-connector.spec.component.ts b/packages/components/src/components/iot-connector/iot-connector.spec.component.ts new file mode 100644 index 000000000..c9cb11f8f --- /dev/null +++ b/packages/components/src/components/iot-connector/iot-connector.spec.component.ts @@ -0,0 +1,28 @@ +import { renderChart, testChartContainerClassNameSelector } from '@iot-app-kit/components/src/testing/renderChart'; +import { SECOND_IN_MS } from '@iot-app-kit/core/src/common/time'; + +describe('handles gestures', () => { + it('zooms in and out', () => { + renderChart(); + + cy.wait(SECOND_IN_MS * 2); + + cy.get(testChartContainerClassNameSelector).dblclick(); + + cy.wait(SECOND_IN_MS * 2); + + cy.matchImageSnapshotOnCI('zooms in'); + + cy.get(testChartContainerClassNameSelector).dblclick({ shiftKey: true }); + + cy.wait(SECOND_IN_MS * 2); + + cy.get(testChartContainerClassNameSelector).dblclick({ shiftKey: true }); + + cy.wait(SECOND_IN_MS * 2); + + cy.matchImageSnapshotOnCI('zooms out'); + }); + + // TODO: Panning - can not get the chart to pan with cypress trigger https://docs.cypress.io/api/commands/trigger#Usage +}); diff --git a/packages/components/src/components/iot-connector/iot-connector.spec.ts b/packages/components/src/components/iot-connector/iot-connector.spec.ts index 4b859aff0..d9a88840a 100644 --- a/packages/components/src/components/iot-connector/iot-connector.spec.ts +++ b/packages/components/src/components/iot-connector/iot-connector.spec.ts @@ -101,15 +101,3 @@ it('updates with new query', async () => { ], }); }); - -//TODO: Backfill these tests. -// Onboard cypress and try the component test runner https://www.cypress.io/blog/2021/04/06/introducing-the-cypress-component-test-runner/ -describe('handles gestures', () => { - it('panning', () => { - //TODO: Make sure data is requested for new viewport range when panning back in time - }); - - it('zooming', () => { - //TODO: Make sure correct resolution is displayed for selected viewport range based on resolution mapping - }); -}); diff --git a/packages/components/src/testing/renderChart.tsx b/packages/components/src/testing/renderChart.tsx new file mode 100644 index 000000000..cde458566 --- /dev/null +++ b/packages/components/src/testing/renderChart.tsx @@ -0,0 +1,171 @@ +import { mount } from '@cypress/vue'; +import { h } from 'vue'; +import { DataModule, SiteWiseDataStreamQuery, TimeSeriesDataRequestSettings } from '@iot-app-kit/core'; +import { MinimalViewPortConfig } from '@synchro-charts/core'; +const { applyPolyfills, defineCustomElements } = require('@iot-app-kit/components/loader'); +import { DATA_STREAM } from '@iot-app-kit/components/src/testing/mockWidgetProperties'; +import { SITEWISE_DATA_SOURCE, TimeSeriesDataRequest } from '@iot-app-kit/core'; +import { SECOND_IN_MS, MINUTE_IN_MS, HOUR_IN_MS } from '@iot-app-kit/core/src/common/time'; +import { DataSource, DataSourceRequest } from '@iot-app-kit/core/src/data-module/types'; +import { toDataStreamId } from '@iot-app-kit/core/src/data-sources/site-wise/util/dataStreamId'; +import { IotAppKitDataModule } from '@iot-app-kit/core/src/data-module/IotAppKitDataModule'; +import { DataStream } from '@synchro-charts/core'; +import '@synchro-charts/core/dist/synchro-charts/synchro-charts.css'; + +applyPolyfills().then(() => defineCustomElements()); + +export const testChartContainerClassName = 'test-chart-container'; + +export const testChartContainerClassNameSelector = `.${testChartContainerClassName}`; + +const FIVE_MINUTES_IN_MS = MINUTE_IN_MS * 5; +const THREE_MINUTES_IN_MS = MINUTE_IN_MS * 3; + +const start = new Date(2022, 0, 0, 0, 0); +const end = new Date(start.getTime() + FIVE_MINUTES_IN_MS); + +const DEFAULT_RESOLUTION_MAPPING = { + [THREE_MINUTES_IN_MS]: MINUTE_IN_MS, +}; + +const sinusoid = (factor: number) => { + return Math.sin(factor / 10); +}; + +const data = Array.from(Array(HOUR_IN_MS / SECOND_IN_MS)).map((_, i) => ({ + x: new Date(start.getTime() - HOUR_IN_MS / 2 + SECOND_IN_MS * i).getTime(), + y: sinusoid(i), +})); + +const aggregates = { + [MINUTE_IN_MS]: Array.from(Array(HOUR_IN_MS / MINUTE_IN_MS)).map((_, i) => ({ + x: new Date(start.getTime() - HOUR_IN_MS / 2 + MINUTE_IN_MS * i).getTime(), + y: sinusoid(i), + })), +}; + +const determineResolution = ({ resolution, request }: { resolution: number; request: TimeSeriesDataRequest }) => { + const viewportRange = (request.viewport as any).end - (request.viewport as any).start; + + let determinedResolution = resolution; + + if (request.settings?.resolution == '0') { + return 0; + } + + if (request.settings?.resolution) { + const matchedResolution = Object.entries(request.settings.resolution) + .sort((a, b) => parseInt(b[0]) - parseInt(a[0])) + .find((resolutionMapping) => parseInt(resolutionMapping[0]) < viewportRange); + + if (matchedResolution) { + determinedResolution = matchedResolution[1] as number; + } + } + + return determinedResolution; +}; + +const createMockSiteWiseDataSource = ( + dataStreams: DataStream[], + resolution: number = 0 +): DataSource => ({ + name: SITEWISE_DATA_SOURCE, + initiateRequest: ({ query, request, onSuccess }: DataSourceRequest) => { + const start = (request.viewport as any).start.getTime(); + const end = (request.viewport as any).end.getTime(); + + let determinedResolution = determineResolution({ resolution, request }); + + if (determinedResolution === 0) { + onSuccess([ + { + ...dataStreams[0], + resolution: determinedResolution, + data: data.filter(({ x }) => x > start && x < end), + }, + ]); + } else { + onSuccess([ + { + ...dataStreams[0], + resolution: determinedResolution, + aggregates: { [MINUTE_IN_MS]: aggregates[MINUTE_IN_MS].filter(({ x }) => x > start && x < end) }, + }, + ]); + } + }, + getRequestsFromQuery: ({ query, request }) => { + let determinedResolution = determineResolution({ resolution, request }); + + return query.assets + .map(({ assetId, properties }) => + properties.map(({ propertyId }) => ({ + id: toDataStreamId({ assetId, propertyId }), + resolution: determinedResolution, + })) + ) + .flat(); + }, +}); + +const defaultAppKit = new IotAppKitDataModule(); +const dataSource = createMockSiteWiseDataSource([DATA_STREAM]); +defaultAppKit.registerDataSource(dataSource); + +const defaultChartType = 'iot-line-chart'; + +const defaultQuery = { + source: dataSource.name, + assets: [ + { + assetId: 'some-asset-id', + properties: [{ propertyId: 'some-property-id' }], + }, + ], +}; + +const defaultSettings = { resolution: DEFAULT_RESOLUTION_MAPPING, fetchAggregatedData: true }; + +const defaultViewport = { start, end }; + +export const renderChart = ( + { + chartType = defaultChartType, + appKit = defaultAppKit, + query = defaultQuery, + settings = defaultSettings, + viewport = defaultViewport, + }: { + chartType?: string; + appKit?: DataModule; + query?: SiteWiseDataStreamQuery; + settings?: TimeSeriesDataRequestSettings; + viewport?: MinimalViewPortConfig; + } = { + chartType: defaultChartType, + appKit: defaultAppKit, + query: defaultQuery, + settings: defaultSettings, + viewport: defaultViewport, + } +) => { + mount({ + data: () => { + return { + chartType, + }; + }, + render: function () { + const containerProps = { class: testChartContainerClassName, style: { width: '400px', height: '500px' } }; + const chartProps: any = { appKit, query, settings, viewport }; + + return ( +
+ + +
+ ); + }, + }); +}; diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index aefac0a67..b0784303c 100755 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -16,6 +16,6 @@ "skipLibCheck": true, "resolveJsonModule": true }, - "include": ["src", "types/jsx.d.ts"], + "include": ["src", "types/jsx.d.ts", "cypress"], "exclude": ["node_modules"] }