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"]
}