Skip to content

Commit

Permalink
feat(scene composer): setting up 3D test harness
Browse files Browse the repository at this point in the history
  • Loading branch information
mumanity authored and mitchlee-amzn committed Feb 12, 2024
1 parent 84d2ce4 commit df62eef
Show file tree
Hide file tree
Showing 32 changed files with 373 additions and 30 deletions.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**/e2e/**/*.png filter=lfs diff=lfs merge=lfs -text
2 changes: 1 addition & 1 deletion .github/workflows/ui-test-for-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:

- name: test UI
run:
npm run test:ui -w @iot-app-kit/dashboard --filter=[HEAD~1] && npm run test:ui -w @iot-app-kit/react-components --filter=[HEAD~1]
npm run test:ui -w @iot-app-kit/dashboard --filter=[HEAD~1] && npm run test:ui -w @iot-app-kit/react-components --filter=[HEAD~1] && npm run test:ui -w @iot-app-kit/scene-composer --filter=[HEAD~1]
- uses: actions/upload-artifact@v4
if: failure()
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ui-test-full-suite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:

- name: test UI
run:
npm run test:ui -w @iot-app-kit/dashboard && npm run test:ui -w @iot-app-kit/react-components
npm run test:ui -w @iot-app-kit/dashboard && npm run test:ui -w @iot-app-kit/react-components && npm run test:ui:reliability -w @iot-app-kit/scene-composer
- uses: actions/upload-artifact@v4
if: failure()
with:
Expand Down
3 changes: 3 additions & 0 deletions .husky/post-checkout
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; }
git lfs post-checkout "$@"
3 changes: 3 additions & 0 deletions .husky/post-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; }
git lfs post-commit "$@"
3 changes: 3 additions & 0 deletions .husky/post-merge
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; }
git lfs post-merge "$@"
3 changes: 3 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; }
git lfs pre-push "$@"
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { test, expect } from '@playwright/test';

import { PlaywrightHelper } from '../utils/sceneHelpers';

const localScene = '/iframe.html?args=&id=developer-scene-composer--local-scene';
const canvas = '#tm-scene-unselectable-canvas';

test.describe('scene-composer--local-scene', () => {
test('visual regression', async ({ page }) => {
await page.goto(localScene);
const frame = page.locator('#root');
expect(await frame.locator(canvas).screenshot()).toMatchSnapshot({
name: 'local-scene-canvas.png',
threshold: 1,
});
});

test('get object by name', async ({ page }) => {
const state = new PlaywrightHelper(page, localScene);
// find object named 'PalletJack'
const palletJack = await state.getObjecByName('PalletJack');

// assert expected values on object
expect(palletJack.isObject3D).toBeTruthy();
expect(palletJack.type).toEqual('Group');
expect(palletJack.visible).toBeTruthy();
});

test('select object', async ({ page }) => {
const state = new PlaywrightHelper(page, localScene);
// find object named 'PalletJack'
const palletJack = await state.getObjecByName('PalletJack');

// select object in hierarchy
const formattedName = palletJack.name.replace(/([A-Z])/g, ' $1').trim();
const handle = await page.$(`text=${formattedName}`);
await handle?.hover();
await handle?.click();

// assert that the associated selectedSceneNode.ref was selected in the Inspector Panel
expect(page.getByTestId('cb85148b-00ca-4006-8b0f-600890eaee46')).toBeDefined();
});
});
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test';

import { PlaywrightHelper } from '../utils/sceneHelpers';

const localScene = '/iframe.html?args=&id=tests-scene-viewer--motion-indicator';
const canvas = '#tm-scene-unselectable-canvas';

test.describe('scene-viewer--motion-indicator', () => {
test('visual regression', async ({ page }) => {
await page.goto(localScene);
const frame = page.locator('#root');
expect(await frame.locator(canvas).screenshot()).toMatchSnapshot({
name: 'motion-indicator-canvas.png',
threshold: 1,
});
});

test('get scene', async ({ page }) => {
const state = new PlaywrightHelper(page, localScene);
const scene = await state.getScene();
expect(scene.sceneId).toEqual('motion-indicator-view-options');
expect(scene.scene.type).toEqual('Scene');
});
});
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions packages/scene-composer/e2e/tests/utils/r3fTestHarness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Object3D } from 'three';

export default class R3FTestHarness {
scene;
sceneId;

constructor(scene: Object3D, sceneId: string) {
this.scene = scene;
this.sceneId = sceneId;
}

// Get Object By Name
/*
* Returns scene object
* `name` as string
*/
async getObjecByName(name: string) {
return this.scene.getObjectByName(name);
}
}
113 changes: 113 additions & 0 deletions packages/scene-composer/e2e/tests/utils/sceneHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Scene, WebGLRenderer } from 'three';
import type { Page, Locator } from '@playwright/test';

import R3FTestHarness from './r3fTestHarness';

declare global {
interface Window {
__twinmaker_tests: {
[key: string]: {
scene: Scene;
gl: WebGLRenderer;
};
};
}
}

type SceneLoadedEventDetail = { sceneComposerId: string; scene: Scene; gl: WebGLRenderer };

interface SceneLoadedEvent extends Event {
detail: SceneLoadedEventDetail;
}

type evaluateProps = {
sceneId: string;
arg?: string;
callback?: string;
callbackString?: string;
TMHarnessClass: string;
};
export class PlaywrightHelper {
localScene: string;

constructor(public readonly page: Page, localScene: string) {
this.page = page;
this.localScene = localScene;
}

async goto(localScene: string) {
await this.page.goto(localScene);
}

async getFrame(localFrame: string) {
return this.page.locator(localFrame);
}

async getSceneId(frame: Locator) {
const sceneId: string = await frame.evaluate(async () => {
return await new Promise((res, rej) => {
const timer = setTimeout(
() => rej(new Error('Timeout, twinmaker:scene-loaded was not reached in reasonable time.')),
30000,
);

window.addEventListener('twinmaker:scene-loaded', (evt: Event) => {
const { detail } = evt as SceneLoadedEvent;
const { sceneComposerId, scene, gl } = detail;
window['__twinmaker_tests'] = window['__twinmaker_tests'] || {};
window['__twinmaker_tests'][sceneComposerId] = { scene, gl };
clearTimeout(timer);
res(sceneComposerId);
});
});
});
return sceneId;
}

async tmScene(frame: Locator, sceneId: string) {
const sceneResult = await frame.evaluate((_element: HTMLElement, sceneId: string) => {
return Promise.resolve<Scene>(window['__twinmaker_tests'][sceneId].scene);
}, sceneId);
return sceneResult;
}

async getScene() {
await this.page.goto(this.localScene);
const frame = this.page.locator('#root');
const sceneId = await this.getSceneId(frame);
const scene = await this.tmScene(frame, sceneId);
return {
frame,
sceneId,
scene,
};
}

async playwrightState(...props) {
const result = await this.getScene();
const sceneId = result.sceneId;
return await this.page.evaluate(
async ({ arg, callback, sceneId, TMHarnessClass }: evaluateProps) => {
const { scene } = window['__twinmaker_tests'][sceneId];
if (!scene) throw new Error('Scene is not loaded');
const HarnessClass = await eval('window.TMHarnessClass = ' + TMHarnessClass);
const harness = await new HarnessClass(scene);

if (callback) {
return await Promise.resolve(harness[callback](arg));
}
},
{
sceneId: sceneId,
arg: props[1],
callback: props[0],
TMHarnessClass: R3FTestHarness.toString(),
},
);
}

// map harness functions here
async getObjecByName(name: string) {
return await this.playwrightState('getObjecByName', name);
}
}
1 change: 1 addition & 0 deletions packages/scene-composer/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default merge.recursive(tsPreset, awsuiPreset, {
'text',
],
setupFilesAfterEnv: ['<rootDir>/tests/setup-jest.ts'],
modulePathIgnorePatterns: ['e2e'],
transform: {
'^.+\\.(js|jsx)$': 'babel-jest',
'\\.(ts|tsx)$': 'ts-jest',
Expand Down
6 changes: 5 additions & 1 deletion packages/scene-composer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@
"copy:notice": "cp ../../NOTICE NOTICE",
"prepack": "npm run copy:license && npm run copy:notice",
"pack": "npm pack",
"svglint": "svglint src/assets/**/*.svg"
"svglint": "svglint src/assets/**/*.svg",
"test:ui": "npx playwright test --update-snapshots",
"test:ui:reliability": "npx playwright test --repeat-each 5 --workers=1",
"test:ui:dev": "npx playwright test --ui",
"test:ui-update": "npx playwright test --update-snapshots"
},
"peerDependencies": {
"react": "^18",
Expand Down
61 changes: 61 additions & 0 deletions packages/scene-composer/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { defineConfig, devices } from '@playwright/test';

/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 100 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
toMatchSnapshot: { maxDiffPixels: 200 },
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
viewport: { height: 1500, width: 1028 },
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',

/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
baseURL: 'http://localhost:6006/',
},

/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],

/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',

/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run start',
reuseExistingServer: true,
url: 'http://localhost:6006',
timeout: 300 * 1000, // 5 minutes
stdout: 'pipe',
stderr: 'pipe',
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import * as React from 'react';

export const sceneComposerIdContext = React.createContext('default');

export const useSceneComposerId = () => {
export const useSceneComposerId = (): string => {
return React.useContext(sceneComposerIdContext);
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('DefaultErrorFallback', () => {
innerError: new Error('Testing an innerError'),
});
const error = new Error('Testing an error');
const errorString = 'Testing a string' as any;
const errorString = 'Testing a string';
const other = {} as any;

[
Expand All @@ -27,7 +27,7 @@ describe('DefaultErrorFallback', () => {
['Unknown', other],
].forEach((value) => {
it(`should render correctly with a ${value[0]} type`, () => {
const container = create(<DefaultErrorFallback error={value[1] as any} />);
const container = create(<DefaultErrorFallback error={value[1]} />);

expect(container).toMatchSnapshot();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,16 @@ describe('RadioButton', () => {
],
].forEach((item) => {
it(`it should render the ${item[0]} as a radio button`, () => {
const data = item[1] as any;
const data = item[1] as {
testId: string;
selected: boolean;
toggle: () => null;
label: string;
};
const { selected, testId, toggle, label } = data;

const { getByTestId } = render(<RadioButton selected={selected} testId={testId} toggle={toggle} label={label} />);
const radioBtn: any = getByTestId(testId);
const radioBtn = getByTestId(testId);

expect(radioBtn).toMatchSnapshot();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { useStore } from '../store';
import { processQueries } from '../utils/entityModelUtils/processQueries';
import { KnownSceneProperty } from '../interfaces';
import { LAYER_DEFAULT_REFRESH_INTERVAL } from '../utils/entityModelUtils/sceneLayerUtils';
import { DEFAULT_ENTITY_BINDING_RELATIONSHIP_NAME } from '../common/entityModelConstants';

import { SceneLayers } from './SceneLayers';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const WebGLCanvasManager: React.FC = () => {
const { document, getSceneNodeByRef, getSceneProperty } = useSceneDocument(sceneComposerId);
const appendSceneNode = useStore(sceneComposerId)((state) => state.appendSceneNode);
const { gl } = useThree();

const domRef = useRef<HTMLElement>(gl.domElement.parentElement);
const environmentPreset = getSceneProperty<string>(KnownSceneProperty.EnvironmentPreset);
const rootNodeRefs = document.rootNodeRefs;
Expand Down

0 comments on commit df62eef

Please sign in to comment.