diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 840097b..4a316b7 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,6 +5,7 @@ on: branches: [ "main", "dev" ] pull_request: branches: [ "main", "dev" ] + workflow_dispatch: jobs: build: @@ -64,3 +65,59 @@ jobs: timeout-minutes: 1 run: npx concurrently --kill-others --success first "npm run dev" "npx wait-on http://localhost:8050/api/game/health --timeout 20000" working-directory: ./packages/multiplayer-template + + + e2e: + needs: build + runs-on: ubuntu-latest + container: mcr.microsoft.com/playwright:v1.52.0-noble + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: 'npm' + cache-dependency-path: | + package-lock.json + packages/bootstrap/package-lock.json + packages/multiplayer-template/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install multiplayer-template dependencies + run: npm ci + working-directory: ./packages/multiplayer-template + + - name: npm link core locally + run: npm link + working-directory: ./packages/core + + - name: Link core in editor + run: npm link @mavonengine/core + working-directory: ./packages/editor + + - name: npm link editor locally + run: npm link + working-directory: ./packages/editor + + - name: Link core and editor in multiplayer-template + run: npm link @mavonengine/core @mavonengine/editor + working-directory: ./packages/multiplayer-template + + - name: Run editor e2e tests + run: npx playwright test ${{ github.event_name == 'workflow_dispatch' && '--update-snapshots' || '' }} + working-directory: ./packages/editor + + - name: Upload updated snapshots + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: playwright-snapshots + path: packages/editor/e2e/editor.spec.ts-snapshots/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index e1631e1..e3aa041 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,6 @@ dist-ssr .env coverage *.tsbuildinfo +test-results +playwright-report packages/core/src/version.ts diff --git a/package-lock.json b/package-lock.json index 36dc583..e6cb670 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10969,6 +10969,7 @@ "devDependencies": { "@dimforge/rapier3d-compat": "0.18.2", "@mavonengine/core": "*", + "@playwright/test": "1.52.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.10", @@ -10990,6 +10991,69 @@ "three": "^0.176.0" } }, + "packages/editor/node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "packages/editor/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "packages/editor/node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "packages/editor/node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "packages/editor/node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", diff --git a/packages/core/src/Types/IEditor.ts b/packages/core/src/Types/IEditor.ts index b014e92..ae8be0d 100644 --- a/packages/core/src/Types/IEditor.ts +++ b/packages/core/src/Types/IEditor.ts @@ -16,6 +16,7 @@ export default interface IEditor { on(event: string, callback: (event?: any) => void): void off(event: string, callback: (event?: any) => void): void trigger(name: string, event?: any): void + ready: boolean availableAssetCategories: string[] activeAssetCategory: 'Object' | '_texture' | 'CubeTexture' | 'AudioBuffer' | 'Font' setActiveAssetCategory(value: 'Object' | '_texture' | 'CubeTexture' | 'AudioBuffer' | 'Font'): void diff --git a/packages/core/src/Utils/LoadingScreen.ts b/packages/core/src/Utils/LoadingScreen.ts index 30a803b..6442fd4 100644 --- a/packages/core/src/Utils/LoadingScreen.ts +++ b/packages/core/src/Utils/LoadingScreen.ts @@ -11,6 +11,7 @@ export default class LoadingScreen extends EventEmitter { private overlay: Mesh | undefined loaded = false + finished = false progress = 0 duration = 2 @@ -73,8 +74,10 @@ export default class LoadingScreen extends EventEmitter { Game.instance().uiRoot.style.opacity = `${progress}` // Optional: remove overlay from scene after fade completes - if (progress >= 1) { + if (progress >= 1 && !this.finished) { + this.finished = true Game.instance().scene.remove(this.overlay!) + this.trigger('finished') } } } diff --git a/packages/core/tests/Utils/LoadingScreen.test.ts b/packages/core/tests/Utils/LoadingScreen.test.ts new file mode 100644 index 0000000..fea611a --- /dev/null +++ b/packages/core/tests/Utils/LoadingScreen.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import EventEmitter from '../../src/Utils/EventEmitter' +import LoadingScreen from '../../src/Utils/LoadingScreen' + +vi.mock('../../src/Particles/Shaders/LoadingScreen/Fragment.glsl', () => ({ default: '' })) +vi.mock('../../src/Particles/Shaders/LoadingScreen/Vertex.glsl', () => ({ default: '' })) + +vi.mock('three', async () => { + const actual = await vi.importActual('three') + return { + ...actual, + PlaneGeometry: vi.fn(), + ShaderMaterial: vi.fn().mockImplementation(({ uniforms }: any) => ({ uniforms })), + Mesh: vi.fn().mockImplementation((_geo: any, material: any) => ({ material })), + } +}) + +const mockScene = { add: vi.fn(), remove: vi.fn() } +const mockUiRoot = { style: { opacity: '0', removeProperty: vi.fn() } } +const gameListeners: Record void> = {} +const mockGame = { + on: vi.fn((event: string, cb: (...args: any[]) => void) => { gameListeners[event] = cb }), + scene: mockScene, + uiRoot: mockUiRoot, +} + +vi.mock('../../src/Game', () => ({ + default: { instance: vi.fn(() => mockGame) }, +})) + +describe('loadingScreen', () => { + let resources: EventEmitter + let loadingBar: HTMLDivElement + let loadingScreen: LoadingScreen + + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + Object.keys(gameListeners).forEach(k => delete gameListeners[k]) + + loadingBar = document.createElement('div') + loadingBar.id = 'loadingBar' + document.body.appendChild(loadingBar) + + resources = new EventEmitter() + loadingScreen = new LoadingScreen(resources as any) + gameListeners.uiMounted?.() + }) + + afterEach(() => { + document.body.innerHTML = '' + vi.useRealTimers() + }) + + it('does nothing in update() before resources are loaded', () => { + loadingScreen.update(0.016) + expect(mockScene.remove).not.toHaveBeenCalled() + expect(loadingScreen.progress).toBe(0) + }) + + it('sets loaded=true when resources emit loaded', () => { + resources.trigger('loaded') + expect(loadingScreen.loaded).toBe(true) + }) + + it('updates loadingBar transform on progress event', () => { + resources.trigger('progress', { loaded: 1, total: 4 }) + expect(loadingBar.style.transform).toBe('scaleX(0.25)') + }) + + it('adds ended class and removes visibility after 500ms on loaded', () => { + resources.trigger('loaded') + vi.advanceTimersByTime(500) + expect(loadingBar.classList.contains('ended')).toBe(true) + expect(mockUiRoot.style.removeProperty).toHaveBeenCalledWith('visibility') + }) + + it('fades the overlay during update() after loading', () => { + resources.trigger('loaded') + loadingScreen.update(1) // 1s into a 2s fade + expect(loadingScreen.progress).toBeGreaterThan(0) + expect(loadingScreen.progress).toBeLessThan(1) + }) + + it('emits finished, sets finished flag, removes overlay, and zeroes progress when fade completes', () => { + resources.trigger('loaded') + const onFinished = vi.fn() + loadingScreen.on('finished', onFinished) + loadingScreen.update(3) // past the 2s duration + expect(onFinished).toHaveBeenCalledOnce() + expect(loadingScreen.finished).toBe(true) + expect(mockScene.remove).toHaveBeenCalled() + expect(loadingScreen.progress).toBe(0) + }) + + it('does not emit finished or remove overlay more than once', () => { + resources.trigger('loaded') + loadingScreen.update(3) + loadingScreen.update(1) // extra update after completion + expect(mockScene.remove).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/editor/README.md b/packages/editor/README.md new file mode 100644 index 0000000..00e6ccd --- /dev/null +++ b/packages/editor/README.md @@ -0,0 +1,39 @@ +# @mavonengine/editor + +**Early WIP** — map saving and loading are not yet implemented. Currently useful mainly for browsing and viewing assets in a 3D scene. + +An in-engine editor for [MavonEngine](https://mavonengine.com) — a [threejs game engine](https://mavonengine.com) — that provides a 3D scene editing interface. It can be activated at runtime by pressing `Insert` or `.` (Period), which boots the editor, destroys the current world, and mounts a React-based UI overlay. + +Current features include: +- Fly camera controls for scene navigation +- Object selection and transform controls (translate, rotate, scale) +- Shade modes: solid, flat, and wireframe +- Light and object helpers +- Asset browser with category filtering +- Primitives and lights available for placement in the scene + +## Testing + +### E2E Tests + +E2E tests **must** be run via the official Playwright Docker image. Running them directly on your local system will cause snapshot diffs to fail due to rendering differences between environments (fonts, etc.). + +```bash +docker run --rm \ + -v $(pwd):/app \ + -w /app/packages/editor \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + npm run test:e2e +``` + +### Updating Snapshots + +To update snapshots, pass the `--update-snapshots` flag via the same Docker container: + +```bash +docker run --rm \ + -v $(pwd):/app \ + -w /app/packages/editor \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + npm run test:e2e -- --update-snapshots +``` diff --git a/packages/editor/e2e/editor.spec.ts b/packages/editor/e2e/editor.spec.ts new file mode 100644 index 0000000..e45bb30 --- /dev/null +++ b/packages/editor/e2e/editor.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test' + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => localStorage.clear()) + await page.goto('/') + // Returns a Promise so Playwright waits for it rather than re-polling. + // Safe because editorRegistered fires after RAPIER.init() + dynamic import, + // both of which start after page load — so it can't have fired before we listen. + await page.waitForFunction(() => + new Promise((resolve) => { + const tryRegister = () => { + const game = (window as any).Game + if (game) { + game.on('editorRegistered', () => resolve(true)) + } + else { + requestAnimationFrame(tryRegister) + } + } + tryRegister() + }), + ) +}) + +test('editor opens on Insert key press', async ({ page }) => { + await page.keyboard.press('Insert') + + // The editor mounts its React UI into #ui and sets the page title + await expect(page).toHaveTitle('MavonEngine | Editor') + + await page.waitForFunction(() => window.Game?.loadingScreen?.finished === true) + + await page.keyboard.press('Insert') + // The first 'insert' triggers hmr causing a page refresh. We need to trigger the editor again + + // Wait for Game to re-initialise after the HMR refresh before polling editor.ready + await page.waitForTimeout(1000) + await page.waitForFunction(() => window.Game?.editor?.ready === true) + + await expect(page).toHaveScreenshot('editor-boot.png') +}) + +test('editor opens on Period key press', async ({ page }) => { + await page.keyboard.press('.') + + await expect(page).toHaveTitle('MavonEngine | Editor') +}) + +test('shade mode switches to wireframe', async ({ page }) => { + await page.keyboard.press('Insert') + await expect(page).toHaveTitle('MavonEngine | Editor') + + const wireframeBtn = page.getByTitle('Wireframe') + await wireframeBtn.click() + + // Active button gets an extra CSS class — verify it's no longer the only button without it + const solidBtn = page.getByTitle('Solid') + await expect(solidBtn).not.toHaveClass(/active/i) + await expect(wireframeBtn).toHaveClass(/active/i) +}) + +test('scene explorer renders initial objects', async ({ page }) => { + await page.keyboard.press('Insert') + await expect(page).toHaveTitle('MavonEngine | Editor') + + // Verify AmbientLight exists in the actual Three.js scene by traversing it. + const hasAmbientLight = await page.evaluate(() => { + let found = false + window.Game?.scene?.traverse((obj) => { + if (obj.type === 'AmbientLight') + found = true + }) + return found + }) + expect(hasAmbientLight).toBe(true) + + // Also confirm the scene explorer UI reflects it. + // toBeAttached (not toBeVisible) because the panel ancestor uses overflow:hidden for sizing. + await expect(page.getByTestId('scene-explorer').getByText('AmbientLight')).toBeAttached() +}) diff --git a/packages/editor/e2e/editor.spec.ts-snapshots/editor-boot-darwin.png b/packages/editor/e2e/editor.spec.ts-snapshots/editor-boot-darwin.png new file mode 100644 index 0000000..2e79ceb Binary files /dev/null and b/packages/editor/e2e/editor.spec.ts-snapshots/editor-boot-darwin.png differ diff --git a/packages/editor/e2e/editor.spec.ts-snapshots/editor-boot-linux.png b/packages/editor/e2e/editor.spec.ts-snapshots/editor-boot-linux.png new file mode 100644 index 0000000..600cfbc Binary files /dev/null and b/packages/editor/e2e/editor.spec.ts-snapshots/editor-boot-linux.png differ diff --git a/packages/editor/package.json b/packages/editor/package.json index 769d753..6bb1490 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -35,7 +35,9 @@ "test": "vitest", "test:ui": "vitest --ui --coverage.enabled true --coverage.reporter html", "test:coverage": "vitest --coverage.enabled true --coverage.reporter json-summary", - "test:dev": "vitest" + "test:dev": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "peerDependencies": { "@mavonengine/core": "*", @@ -53,6 +55,7 @@ "devDependencies": { "@dimforge/rapier3d-compat": "0.18.2", "@mavonengine/core": "*", + "@playwright/test": "^1.52.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.10", diff --git a/packages/editor/playwright.config.ts b/packages/editor/playwright.config.ts new file mode 100644 index 0000000..a87ceb6 --- /dev/null +++ b/packages/editor/playwright.config.ts @@ -0,0 +1,15 @@ +import process from 'node:process' +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + webServer: { + command: 'npm run dev --prefix ../multiplayer-template/client', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, + use: { + baseURL: 'http://localhost:5173', + ...devices['Desktop Chrome'], + }, +}) diff --git a/packages/editor/src/Editor.ts b/packages/editor/src/Editor.ts index 8a45131..b78ddec 100644 --- a/packages/editor/src/Editor.ts +++ b/packages/editor/src/Editor.ts @@ -17,6 +17,7 @@ import { applyShadeMode } from './Editor/applyShadeMode' export type ShadeMode = 'solid' | 'flat' | 'wireframe' export default class Editor extends EventEmitter implements IEditor, GameObjectInterface { + ready = false flyControls!: FlyControls reactRoot!: Root activeItem?: Object3D | null diff --git a/packages/editor/src/Editor/UI/SceneExplorer.tsx b/packages/editor/src/Editor/UI/SceneExplorer.tsx index 9f31dab..6b40967 100644 --- a/packages/editor/src/Editor/UI/SceneExplorer.tsx +++ b/packages/editor/src/Editor/UI/SceneExplorer.tsx @@ -177,7 +177,7 @@ export default function SceneExplorer() { } return ( -
+
{items.map(item => ( { + if (window.Game.editor) { + window.Game.editor.ready = true + window.Game.editor.trigger('ready') + } + }, []) + return (
diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json index d79a9ee..9d49185 100644 --- a/packages/editor/tsconfig.json +++ b/packages/editor/tsconfig.json @@ -22,5 +22,5 @@ "references": [ { "path": "../core" } ], - "include": ["src"] + "include": ["src", "e2e", "Global.d.ts"] } diff --git a/packages/editor/vite.config.js b/packages/editor/vite.config.js index 4e6aa1f..21cfe5e 100644 --- a/packages/editor/vite.config.js +++ b/packages/editor/vite.config.js @@ -105,6 +105,7 @@ export default defineConfig(({ command }) => ({ }, }, test: { + include: ['tests/**/*.test.ts'], coverage: { provider: 'v8', include: ['src'], diff --git a/packages/multiplayer-template/client/src/main.ts b/packages/multiplayer-template/client/src/main.ts index 4fee084..6e6a51c 100644 --- a/packages/multiplayer-template/client/src/main.ts +++ b/packages/multiplayer-template/client/src/main.ts @@ -8,7 +8,10 @@ import './game.css' if (import.meta.env.DEV) { Game.devModeHook = () => { - import('@mavonengine/editor').then(({ default: Editor }) => Editor.registerListener()) + import('@mavonengine/editor').then(({ default: Editor }) => { + Editor.registerListener() + Game.instance().trigger('editorRegistered') + }) } } diff --git a/packages/package-lock.json b/packages/package-lock.json new file mode 100644 index 0000000..4ca926f --- /dev/null +++ b/packages/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "app", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}