diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88d3cb25..2a33dc52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,26 +24,8 @@ jobs: - name: Install dependencies run: npm ci - - name: === Build testing === - run: npm run build - - - name: === Rebranding compatibility testing === - run: npm run test-rebranding - - - name: === Migration tooling testing === - run: npm run test-migrate - - - name: === Lint testing === - run: npm run lint - - - name: === Unit testing === - run: npm run test-unit - - - name: === Unit addons testing === - run: npm run test-unit-addons - - - name: === Examples ready for release === - run: npm run test-e2e-cov + - name: === Repository verification === + run: npm test node-26-parity: name: Node 26 build & entrypoint parity diff --git a/docs/pages/JoltPhysics.html b/docs/pages/JoltPhysics.html index 4f4b3d62..929678a3 100644 --- a/docs/pages/JoltPhysics.html +++ b/docs/pages/JoltPhysics.html @@ -123,6 +123,41 @@

..setMeshVelocity( mesh : Mesh, velocity : Vector3, index : number )

+
+
+

Sets the linear velocity of the given mesh which is part of the physics simulation.

+
+ + + + + + + + + + + + + + + +
+ mesh + +

The mesh to update the velocity for.

+
+ velocity + +

The new linear velocity.

+
+ index + +

If the mesh is instanced, the index represents the instanced ID.

+

Default is 0.

+
+

Source

examples/jsm/physics/JoltPhysics.js diff --git a/docs/pages/JoltPhysics.html.md b/docs/pages/JoltPhysics.html.md index 3ab16ef0..f4719ac7 100644 --- a/docs/pages/JoltPhysics.html.md +++ b/docs/pages/JoltPhysics.html.md @@ -70,6 +70,24 @@ If the mesh is instanced, the index represents the instanced ID. Default is `0`. +### .setMeshVelocity( mesh : Mesh, velocity : Vector3, index : number ) + +Sets the linear velocity of the given mesh which is part of the physics simulation. + +**mesh** + +The mesh to update the velocity for. + +**velocity** + +The new linear velocity. + +**index** + +If the mesh is instanced, the index represents the instanced ID. + +Default is `0`. + ## Source [examples/jsm/physics/JoltPhysics.js](../../examples/jsm/physics/JoltPhysics.js) \ No newline at end of file diff --git a/docs/search.json b/docs/search.json index 1f0a2084..16f053fb 100644 --- a/docs/search.json +++ b/docs/search.json @@ -19054,6 +19054,10 @@ "title": "JoltPhysics#setMeshPosition", "kind": "function" }, + { + "title": "JoltPhysics#setMeshVelocity", + "kind": "function" + }, { "title": "KMZLoader", "kind": "class" diff --git a/editor/js/Script.js b/editor/js/Script.js index f819dc7a..59c9e7fd 100644 --- a/editor/js/Script.js +++ b/editor/js/Script.js @@ -311,7 +311,7 @@ function Script( editor ) { const url = new URL( '../build/4.core.js', document.baseURI ).href; const source = await ( await fetch( url ) ).text(); - server.server.defs.push( buildFourDefs( source ) ); + server.server.defs.push( buildFourDefs( source ) ); server.server.reset(); } catch ( error ) { diff --git a/editor/js/libs/app.js b/editor/js/libs/app.js index 3714cdb4..fa8ca467 100644 --- a/editor/js/libs/app.js +++ b/editor/js/libs/app.js @@ -1,3 +1,5 @@ +/* global FOUR */ + const APP = { Player: function () { diff --git a/eslint.config.js b/eslint.config.js index d4168341..36dd8b99 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,6 +28,8 @@ export default [ '**/vendor/**', '**/fuse/**', '**/template/static/**', + 'manual/examples/resources/webgl-debug-helper.js', + 'test/treeshake/*.bundle*.js', ] }, diff --git a/examples/jsm/misc/RollerCoaster.js b/examples/jsm/misc/RollerCoaster.js index d59c342e..50d6fd66 100644 --- a/examples/jsm/misc/RollerCoaster.js +++ b/examples/jsm/misc/RollerCoaster.js @@ -223,7 +223,7 @@ class RollerCoasterGeometry extends BufferGeometry { let headingChange = Math.atan2( sample2.x, sample2.z ) - Math.atan2( sample1.x, sample1.z ); if ( headingChange > Math.PI ) headingChange -= Math.PI * 2; - if ( headingChange < -Math.PI ) headingChange += Math.PI * 2; + if ( headingChange < - Math.PI ) headingChange += Math.PI * 2; quaternion.premultiply( rollQuaternion.setFromAxisAngle( forward, - Math.atan( headingChange * 8 ) * 0.5 ) ); @@ -398,7 +398,7 @@ class RollerCoasterLiftersGeometry extends BufferGeometry { let headingChange = Math.atan2( sample2.x, sample2.z ) - Math.atan2( sample1.x, sample1.z ); if ( headingChange > Math.PI ) headingChange -= Math.PI * 2; - if ( headingChange < -Math.PI ) headingChange += Math.PI * 2; + if ( headingChange < - Math.PI ) headingChange += Math.PI * 2; bankedQuaternion.copy( quaternion ); rollQuaternion.setFromAxisAngle( tangent, - Math.atan( headingChange * 8 ) * 0.5 ); diff --git a/examples/jsm/objects/Sky.js b/examples/jsm/objects/Sky.js index e03435bd..d6d31008 100644 --- a/examples/jsm/objects/Sky.js +++ b/examples/jsm/objects/Sky.js @@ -25,9 +25,9 @@ import { * sky.scale.setScalar( 10000 ); * scene.add( sky ); * ``` - * + * * It can be useful to hide the sun disc when generating an environment map to avoid artifacts - * + * * ```js * // disable before rendering environment map * sky.material.uniforms.showSunDisc.value = false; diff --git a/examples/jsm/objects/SkyMesh.js b/examples/jsm/objects/SkyMesh.js index 8509cb28..18140c2e 100644 --- a/examples/jsm/objects/SkyMesh.js +++ b/examples/jsm/objects/SkyMesh.js @@ -27,7 +27,7 @@ import { Fn, float, vec2, vec3, acos, add, mul, clamp, cos, dot, exp, max, mix, * ``` * * It can be useful to hide the sun disc when generating an environment map to avoid artifacts - * + * * ```js * // disable before rendering environment map * sky.showSunDisc.value = false; diff --git a/examples/jsm/physics/JoltPhysics.js b/examples/jsm/physics/JoltPhysics.js index d233a5cd..1d8b483d 100644 --- a/examples/jsm/physics/JoltPhysics.js +++ b/examples/jsm/physics/JoltPhysics.js @@ -130,10 +130,11 @@ async function JoltPhysics() { ? createInstancedBody( mesh, mass, restitution, shape ) : createBody( mesh.position, mesh.quaternion, mass, restitution, shape ); + meshMap.set( mesh, body ); + if ( mass > 0 ) { meshes.push( mesh ); - meshMap.set( mesh, body ); } @@ -178,45 +179,58 @@ async function JoltPhysics() { } - function setMeshPosition( mesh, position, index = 0 ) { + function getBody( mesh, index ) { - if ( mesh.isInstancedMesh ) { + let body = meshMap.get( mesh ); - const bodies = meshMap.get( mesh ); + if ( body === undefined ) { - const body = bodies[ index ]; + throw new Error( 'JoltPhysics: Mesh has not been added to the physics simulation.' ); - bodyInterface.RemoveBody( body.GetID() ); - bodyInterface.DestroyBody( body.GetID() ); + } - const physics = mesh.userData.physics; + if ( mesh.isInstancedMesh ) { - const shape = body.GetShape(); - const body2 = createBody( position, { x: 0, y: 0, z: 0, w: 1 }, physics.mass, physics.restitution, shape ); + body = body[ index ]; - bodies[ index ] = body2; + if ( body === undefined ) { - } else { + throw new RangeError( `JoltPhysics: Instanced mesh index ${index} is out of range.` ); - // TODO: Implement this + } } + return body; + } - function setMeshVelocity( mesh, velocity, index = 0 ) { + function setMeshPosition( mesh, position, index = 0 ) { - /* - let body = meshMap.get( mesh ); + const body = getBody( mesh, index ); + const bodyID = body.GetID(); + const nextPosition = new Jolt.RVec3( position.x, position.y, position.z ); + const zeroVelocity = new Jolt.Vec3( 0, 0, 0 ); - if ( mesh.isInstancedMesh ) { + bodyInterface.SetLinearVelocity( bodyID, zeroVelocity ); + bodyInterface.SetAngularVelocity( bodyID, zeroVelocity ); + bodyInterface.SetPosition( bodyID, nextPosition, Jolt.EActivation_Activate ); - body = body[ index ]; + Jolt.destroy( nextPosition ); + Jolt.destroy( zeroVelocity ); - } + } + + function setMeshVelocity( mesh, velocity, index = 0 ) { + + const body = getBody( mesh, index ); + const bodyID = body.GetID(); + const nextVelocity = new Jolt.Vec3( velocity.x, velocity.y, velocity.z ); - body.setLinvel( velocity ); - */ + bodyInterface.SetLinearVelocity( bodyID, nextVelocity ); + bodyInterface.ActivateBody( bodyID ); + + Jolt.destroy( nextVelocity ); } @@ -325,7 +339,15 @@ async function JoltPhysics() { */ setMeshPosition: setMeshPosition, - // NOOP + /** + * Sets the linear velocity of the given mesh which is part of the physics simulation. + * + * @method + * @name JoltPhysics#setMeshVelocity + * @param {Mesh} mesh The mesh to update the velocity for. + * @param {Vector3} velocity - The new linear velocity. + * @param {number} [index=0] - If the mesh is instanced, the index represents the instanced ID. + */ setMeshVelocity: setMeshVelocity }; diff --git a/examples/jsm/tsl/display/RetroPassNode.js b/examples/jsm/tsl/display/RetroPassNode.js index b91d041e..90124269 100644 --- a/examples/jsm/tsl/display/RetroPassNode.js +++ b/examples/jsm/tsl/display/RetroPassNode.js @@ -158,7 +158,7 @@ class RetroPassNode extends PassNode { let finalUV; - if ( texture.isCubeTextureNode ) { + if ( texture.isCubeTextureNode ) { finalUV = reflectVector; diff --git a/examples/physics_ammo_break.html b/examples/physics_ammo_break.html index 30d4c397..9766b1c6 100644 --- a/examples/physics_ammo_break.html +++ b/examples/physics_ammo_break.html @@ -86,7 +86,7 @@ Ammo().then( function ( AmmoLib ) { - Ammo = AmmoLib; + globalThis.Ammo = AmmoLib; init(); diff --git a/examples/physics_ammo_cloth.html b/examples/physics_ammo_cloth.html index 8c221992..b3167332 100644 --- a/examples/physics_ammo_cloth.html +++ b/examples/physics_ammo_cloth.html @@ -55,7 +55,7 @@ Ammo().then( function ( AmmoLib ) { - Ammo = AmmoLib; + globalThis.Ammo = AmmoLib; init(); diff --git a/examples/physics_ammo_rope.html b/examples/physics_ammo_rope.html index fffa006e..6d992337 100644 --- a/examples/physics_ammo_rope.html +++ b/examples/physics_ammo_rope.html @@ -59,7 +59,7 @@ Ammo().then( function ( AmmoLib ) { - Ammo = AmmoLib; + globalThis.Ammo = AmmoLib; init(); diff --git a/examples/physics_ammo_terrain.html b/examples/physics_ammo_terrain.html index fad6cf81..46641eba 100644 --- a/examples/physics_ammo_terrain.html +++ b/examples/physics_ammo_terrain.html @@ -72,7 +72,7 @@ Ammo().then( function ( AmmoLib ) { - Ammo = AmmoLib; + globalThis.Ammo = AmmoLib; init(); diff --git a/examples/physics_ammo_volume.html b/examples/physics_ammo_volume.html index 69ea60ea..cea827f5 100644 --- a/examples/physics_ammo_volume.html +++ b/examples/physics_ammo_volume.html @@ -63,7 +63,7 @@ Ammo().then( function ( AmmoLib ) { - Ammo = AmmoLib; + globalThis.Ammo = AmmoLib; init(); diff --git a/examples/webgl_lines_fat.html b/examples/webgl_lines_fat.html index ed202ad5..136349d9 100644 --- a/examples/webgl_lines_fat.html +++ b/examples/webgl_lines_fat.html @@ -239,6 +239,7 @@ } else { widthController.name( 'width (pixels)' ).min( 1 ).max( 10 ).setValue( 10 ); + } } ); diff --git a/examples/webgl_materials_texture_html.html b/examples/webgl_materials_texture_html.html index 12a854bf..5091e434 100644 --- a/examples/webgl_materials_texture_html.html +++ b/examples/webgl_materials_texture_html.html @@ -76,6 +76,8 @@ import { RoomEnvironment } from '@tnb1j/4js/addons/environments/RoomEnvironment.js'; import { InteractionManager } from '@tnb1j/4js/addons/interaction/InteractionManager.js'; + const info = document.getElementById( 'info' ); + if ( ! ( 'requestPaint' in HTMLCanvasElement.prototype ) ) { installHtmlInCanvasPolyfill(); diff --git a/examples/webgl_multiple_elements_text.html b/examples/webgl_multiple_elements_text.html index 4cbebda7..2a835eab 100644 --- a/examples/webgl_multiple_elements_text.html +++ b/examples/webgl_multiple_elements_text.html @@ -338,16 +338,19 @@ @@ -359,15 +362,19 @@ @@ -480,26 +487,30 @@ @@ -511,26 +522,30 @@ @@ -641,27 +656,31 @@ @@ -673,27 +692,31 @@ diff --git a/examples/webgpu_custom_fog_scattering.html b/examples/webgpu_custom_fog_scattering.html index b2066518..a5500486 100644 --- a/examples/webgpu_custom_fog_scattering.html +++ b/examples/webgpu_custom_fog_scattering.html @@ -144,7 +144,7 @@ // a couple of dominant trunks close to the camera to anchor the depth - [ [ - 1.1, 4.9, 1.5, 1.1 ], [ 1.5, 4, 1.2, 0.3 ] ].forEach( ( [ x, z, s, ry ] ) => { + [[ - 1.1, 4.9, 1.5, 1.1 ], [ 1.5, 4, 1.2, 0.3 ]].forEach( ( [ x, z, s, ry ] ) => { const hero = new FOUR.Mesh( variants[ variants.length - 1 ], material ); hero.position.set( x, 0, z ); diff --git a/examples/webgpu_lines_fat.html b/examples/webgpu_lines_fat.html index e6aa2ddd..dcb2e163 100644 --- a/examples/webgpu_lines_fat.html +++ b/examples/webgpu_lines_fat.html @@ -255,6 +255,7 @@ } else { widthController.name( 'width (pixels)' ).min( 1 ).max( 10 ).setValue( 10 ); + } } ); diff --git a/examples/webgpu_materials_texture_html.html b/examples/webgpu_materials_texture_html.html index 5d92bed8..a68465f1 100644 --- a/examples/webgpu_materials_texture_html.html +++ b/examples/webgpu_materials_texture_html.html @@ -77,6 +77,8 @@ import { RoomEnvironment } from '@tnb1j/4js/addons/environments/RoomEnvironment.js'; import { InteractionManager } from '@tnb1j/4js/addons/interaction/InteractionManager.js'; + const info = document.getElementById( 'info' ); + if ( ! ( 'requestPaint' in HTMLCanvasElement.prototype ) ) { installHtmlInCanvasPolyfill(); diff --git a/examples/webgpu_shadertoy.html b/examples/webgpu_shadertoy.html index b1b14c81..185df0d4 100644 --- a/examples/webgpu_shadertoy.html +++ b/examples/webgpu_shadertoy.html @@ -228,7 +228,7 @@ } - setup( builder ) { + setup() { if ( this.mainImage === null ) { diff --git a/examples/webxr_vr_rollercoaster.html b/examples/webxr_vr_rollercoaster.html index 8031e5b7..f79ac933 100644 --- a/examples/webxr_vr_rollercoaster.html +++ b/examples/webxr_vr_rollercoaster.html @@ -251,7 +251,7 @@ let headingChange = Math.atan2( tangent2.x, tangent2.z ) - Math.atan2( tangent1.x, tangent1.z ); if ( headingChange > Math.PI ) headingChange -= Math.PI * 2; - if ( headingChange < -Math.PI ) headingChange += Math.PI * 2; + if ( headingChange < - Math.PI ) headingChange += Math.PI * 2; train.up.set( 0, 1, 0 ); bankQuaternion.setFromAxisAngle( tangent, - Math.atan( headingChange * 8 ) * 0.5 ); diff --git a/manual/examples/cameras-logarithmic-depth-buffer.html b/manual/examples/cameras-logarithmic-depth-buffer.html index eadc9fb2..e7790a72 100644 --- a/manual/examples/cameras-logarithmic-depth-buffer.html +++ b/manual/examples/cameras-logarithmic-depth-buffer.html @@ -78,7 +78,7 @@ set max( v ) { this.obj[ this.maxProp ] = v; - this.min = this.min; // this will call the min setter + this.min = this.obj[ this.minProp ]; // this will call the min setter } diff --git a/manual/examples/cameras-orthographic-2-scenes.html b/manual/examples/cameras-orthographic-2-scenes.html index 32a198a9..2185f363 100644 --- a/manual/examples/cameras-orthographic-2-scenes.html +++ b/manual/examples/cameras-orthographic-2-scenes.html @@ -95,7 +95,7 @@ set max( v ) { this.obj[ this.maxProp ] = v; - this.min = this.min; // this will call the min setter + this.min = this.obj[ this.minProp ]; // this will call the min setter } diff --git a/manual/examples/cameras-perspective-2-scenes.html b/manual/examples/cameras-perspective-2-scenes.html index 4bed76d9..8deb43f9 100644 --- a/manual/examples/cameras-perspective-2-scenes.html +++ b/manual/examples/cameras-perspective-2-scenes.html @@ -95,7 +95,7 @@ set max( v ) { this.obj[ this.maxProp ] = v; - this.min = this.min; // this will call the min setter + this.min = this.obj[ this.minProp ]; // this will call the min setter } diff --git a/manual/examples/cameras-perspective.html b/manual/examples/cameras-perspective.html index 7a6021c2..d3d94e85 100644 --- a/manual/examples/cameras-perspective.html +++ b/manual/examples/cameras-perspective.html @@ -75,7 +75,7 @@ set max( v ) { this.obj[ this.maxProp ] = v; - this.min = this.min; // this will call the min setter + this.min = this.obj[ this.minProp ]; // this will call the min setter } diff --git a/manual/examples/cameras-z-fighting.html b/manual/examples/cameras-z-fighting.html index 13164b6c..004d50f7 100644 --- a/manual/examples/cameras-z-fighting.html +++ b/manual/examples/cameras-z-fighting.html @@ -74,7 +74,7 @@ set max( v ) { this.obj[ this.maxProp ] = v; - this.min = this.min; // this will call the min setter + this.min = this.obj[ this.minProp ]; // this will call the min setter } diff --git a/manual/examples/load-gltf-shadows.html b/manual/examples/load-gltf-shadows.html index 59c525aa..13b7e8f8 100644 --- a/manual/examples/load-gltf-shadows.html +++ b/manual/examples/load-gltf-shadows.html @@ -196,7 +196,7 @@ set max( v ) { this.obj[ this.maxProp ] = v; - this.min = this.min; // this will call the min setter + this.min = this.obj[ this.minProp ]; // this will call the min setter } diff --git a/manual/examples/shadows-directional-light-shadow-acne.html b/manual/examples/shadows-directional-light-shadow-acne.html index c10348a5..8b954431 100644 --- a/manual/examples/shadows-directional-light-shadow-acne.html +++ b/manual/examples/shadows-directional-light-shadow-acne.html @@ -256,7 +256,7 @@ set max( v ) { this.obj[ this.maxProp ] = v; - this.min = this.min; // this will call the min setter + this.min = this.obj[ this.minProp ]; // this will call the min setter } diff --git a/manual/examples/shadows-directional-light-with-camera-gui.html b/manual/examples/shadows-directional-light-with-camera-gui.html index 56f57c34..0ebc6932 100644 --- a/manual/examples/shadows-directional-light-with-camera-gui.html +++ b/manual/examples/shadows-directional-light-with-camera-gui.html @@ -221,7 +221,7 @@ set max( v ) { this.obj[ this.maxProp ] = v; - this.min = this.min; // this will call the min setter + this.min = this.obj[ this.minProp ]; // this will call the min setter } diff --git a/manual/examples/shadows-point-light.html b/manual/examples/shadows-point-light.html index bbdff33d..4185ea8c 100644 --- a/manual/examples/shadows-point-light.html +++ b/manual/examples/shadows-point-light.html @@ -197,7 +197,7 @@ set max( v ) { this.obj[ this.maxProp ] = v; - this.min = this.min; // this will call the min setter + this.min = this.obj[ this.minProp ]; // this will call the min setter } diff --git a/manual/examples/shadows-spot-light-with-camera-gui.html b/manual/examples/shadows-spot-light-with-camera-gui.html index b569f71a..150a2a21 100644 --- a/manual/examples/shadows-spot-light-with-camera-gui.html +++ b/manual/examples/shadows-spot-light-with-camera-gui.html @@ -195,7 +195,7 @@ set max( v ) { this.obj[ this.maxProp ] = v; - this.min = this.min; // this will call the min setter + this.min = this.obj[ this.minProp ]; // this will call the min setter } diff --git a/manual/examples/shadows-spot-light-with-shadow-radius.html b/manual/examples/shadows-spot-light-with-shadow-radius.html index 2d1a32dd..5b6573f9 100644 --- a/manual/examples/shadows-spot-light-with-shadow-radius.html +++ b/manual/examples/shadows-spot-light-with-shadow-radius.html @@ -196,7 +196,7 @@ set max( v ) { this.obj[ this.maxProp ] = v; - this.min = this.min; // this will call the min setter + this.min = this.obj[ this.minProp ]; // this will call the min setter } diff --git a/manual/index.html b/manual/index.html index 34c3ffba..46f38a67 100644 --- a/manual/index.html +++ b/manual/index.html @@ -481,6 +481,9 @@

4.js

} + window.setUrl = setUrl; + window.setTitle = setTitle; + function createNewIframe() { // Change the content displayed in the iframe diff --git a/package.json b/package.json index beb8bfe7..dc6492b6 100644 --- a/package.json +++ b/package.json @@ -65,9 +65,12 @@ ], "scripts": { "start": "npm run dev", - "test": "npm run lint && npm run test-unit && npm run test-unit-addons", + "test": "npm run verify", + "verify": "npm run build && npm run lint && npm run test-rebranding && npm run test-brand-sync && npm run test-migrate && npm run test-server && npm run test-unit && npm run test-unit-addons && npm run test-treeshake && npm run test-e2e-cov", "test-rebranding": "node test/rebranding/dual-entrypoints.js", + "test-brand-sync": "node utils/rebrand/sync-project.js", "test-migrate": "node test/rebranding/migrate.tests.js", + "test-server": "node test/integration/server.tests.js", "build": "rollup -c utils/build/rollup.config.js", "build-docs": "jsdoc -c utils/docs/jsdoc.config.json && npm run build-llms", "build-llms": "node utils/llms/build.js", @@ -81,8 +84,8 @@ "lint-manual": "eslint manual", "lint-test": "eslint test", "lint-utils": "eslint utils", - "lint": "npm run lint-core", - "lint-fix": "npm run lint-core -- --fix && npm run lint-addons -- --fix && npm run lint-examples -- --fix && npm run lint-editor -- --fix && npm run lint-manual -- --fix && npm run lint-test -- --fix && npm run lint-utils -- --fix", + "lint": "eslint src examples editor manual test utils", + "lint-fix": "eslint src examples editor manual test utils --fix", "test-unit": "node test/unit/puppeteer.unit.js --testPage=UnitTests.html --mode=headless", "test-unit-headful": "node test/unit/puppeteer.unit.js --testPage=UnitTests.html --mode=headful", "test-unit-addons": "node test/unit/puppeteer.unit.js --testPage=UnitTestsAddons.html --mode=headless", diff --git a/test/e2e/puppeteer.js b/test/e2e/puppeteer.js index b1e31dee..a49bd186 100644 --- a/test/e2e/puppeteer.js +++ b/test/e2e/puppeteer.js @@ -100,27 +100,25 @@ console.green = msg => console.log( `\x1b[32m${msg}\x1b[39m` ); console.yellow = msg => console.log( `\x1b[33m${msg}\x1b[39m` ); let browser; +let closing = false; /* Launch server */ -server.listen( port, main ); +server.listen( port, () => { -process.on( 'SIGINT', async () => { + main().catch( async error => { - console.log( '\nInterrupted, cleaning up...' ); - - if ( browser ) { - - try { + console.red( error.stack || error.message || String( error ) ); + await close( 1 ); - await browser.close(); + } ); - } catch ( e ) {} +} ); - } +process.on( 'SIGINT', () => { - server.close(); - process.exit( 1 ); + console.log( '\nInterrupted, cleaning up...' ); + void close( 1 ); } ); @@ -310,7 +308,7 @@ async function main() { } - setTimeout( close, 300, failedScreenshots.length ); + setTimeout( () => void close( failedScreenshots.length ), 300 ); } @@ -600,12 +598,38 @@ async function checkFile( ctx, failedScreenshots, cleanPage, isMakeScreenshot, f } -function close( exitCode = 1 ) { +async function close( exitCode = 1 ) { + + if ( closing ) return; + closing = true; console.log( 'Closing...' ); - browser.close(); - server.close(); - process.exit( exitCode ); + try { + + if ( browser ) await browser.close(); + + } catch ( error ) { + + console.red( `Failed to close browser cleanly: ${error.message}` ); + exitCode = 1; + + } + + await new Promise( resolve => { + + if ( server.listening ) { + + server.close( resolve ); + + } else { + + resolve(); + + } + + } ); + + process.exitCode = exitCode; } diff --git a/test/integration/server.tests.js b/test/integration/server.tests.js new file mode 100644 index 00000000..ba62f5b8 --- /dev/null +++ b/test/integration/server.tests.js @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import { createServer } from '../../utils/server.js'; + +function listen( server, port = 0 ) { + + return new Promise( ( resolve, reject ) => { + + server.once( 'error', reject ); + server.listen( port, () => { + + server.off( 'error', reject ); + resolve( server.address().port ); + + } ); + + } ); + +} + +function closeServer( server ) { + + return new Promise( resolve => { + + if ( server.listening ) { + + server.close( resolve ); + + } else { + + resolve(); + + } + + } ); + +} + +async function reserveConsecutivePorts() { + + for ( let attempt = 0; attempt < 50; attempt ++ ) { + + const basePort = 20000 + Math.floor( Math.random() * 20000 ); + const blockers = [ net.createServer(), net.createServer() ]; + const probe = net.createServer(); + + try { + + await listen( blockers[ 0 ], basePort ); + await listen( blockers[ 1 ], basePort + 1 ); + await listen( probe, basePort + 2 ); + await closeServer( probe ); + + return { basePort, blockers }; + + } catch ( error ) { + + await Promise.all( [ ...blockers, probe ].map( closeServer ) ); + + } + + } + + throw new Error( 'Unable to reserve consecutive ports for the server fallback test.' ); + +} + +async function testRequestValidation() { + + const server = createServer(); + const port = await listen( server ); + + try { + + const traversal = await fetch( `http://127.0.0.1:${port}/..%2Foutside.txt` ); + const malformed = await fetch( `http://127.0.0.1:${port}/%E0%A4%A` ); + + assert.equal( traversal.status, 403 ); + assert.equal( malformed.status, 400 ); + + } finally { + + await closeServer( server ); + + } + +} + +async function testPortFallback() { + + const { basePort, blockers } = await reserveConsecutivePorts(); + const expectedPort = basePort + 2; + const child = spawn( process.execPath, [ 'utils/server.js', '-p', String( basePort ) ], { + cwd: process.cwd(), + stdio: [ 'ignore', 'pipe', 'pipe' ] + } ); + + let output = ''; + const capture = chunk => { + + output += chunk.toString(); + + }; + + child.stdout.on( 'data', capture ); + child.stderr.on( 'data', capture ); + + try { + + await new Promise( ( resolve, reject ) => { + + const timeout = setTimeout( () => reject( new Error( `Server fallback timed out.\n${output}` ) ), 10000 ); + + const checkOutput = () => { + + if ( output.includes( `localhost:${expectedPort}` ) ) { + + clearTimeout( timeout ); + resolve(); + + } + + }; + + child.stdout.on( 'data', checkOutput ); + child.stderr.on( 'data', checkOutput ); + child.once( 'exit', code => { + + clearTimeout( timeout ); + reject( new Error( `Server exited with code ${code}.\n${output}` ) ); + + } ); + + } ); + + const response = await fetch( `http://127.0.0.1:${expectedPort}/package.json` ); + assert.equal( response.status, 200 ); + + } finally { + + if ( child.exitCode === null ) child.kill( 'SIGINT' ); + + if ( child.exitCode === null ) { + + const exited = await Promise.race( [ + new Promise( resolve => child.once( 'exit', () => resolve( true ) ) ), + new Promise( resolve => setTimeout( () => resolve( false ), 5000 ) ) + ] ); + + if ( exited === false && child.exitCode === null ) child.kill( 'SIGKILL' ); + + } + + await Promise.all( blockers.map( closeServer ) ); + + } + +} + +await testRequestValidation(); +await testPortFallback(); + +console.log( 'Server integration tests passed.' ); diff --git a/test/unit/puppeteer.unit.js b/test/unit/puppeteer.unit.js index e0de114f..072be583 100644 --- a/test/unit/puppeteer.unit.js +++ b/test/unit/puppeteer.unit.js @@ -13,11 +13,11 @@ const networkTimeout = 5; // 5 minutes, set to 0 to disable const port = 1234; let browser; +let closing = false; import { createServer } from '../../utils/server.js'; const server = createServer(); -server.listen( port, main ); const color = code => msg => console.log( `\x1b[${code}m${msg}\x1b[39m` ); @@ -49,106 +49,144 @@ const captureConsole = ( page ) => { }; -function main() { +async function main() { - ( async () => { + const flags = [ + '--hide-scrollbars', + '--enable-unsafe-webgpu', + '--enable-features=Vulkan', + '--disable-vulkan-surface', + '--ignore-gpu-blocklist', + '--disable-gpu-driver-bug-workarounds', + '--no-sandbox' + ]; - const flags = [ - '--hide-scrollbars', - '--enable-unsafe-webgpu', - '--enable-features=Vulkan', - '--disable-vulkan-surface', - '--ignore-gpu-blocklist', - '--disable-gpu-driver-bug-workarounds', - '--no-sandbox' - ]; + let testPage = ''; + let testMode = ''; - let testPage = ''; - let testMode = ''; + let argvIndex = 2; + const testPageArgument = process.argv[ argvIndex ]; - let argvIndex = 2; + if ( testPageArgument?.startsWith( '--testPage=' ) ) { - if ( process.argv[ argvIndex ].startsWith( '--testPage' ) ) { + testPage = testPageArgument.split( '=' )[ 1 ]; + argvIndex ++; - testPage = process.argv[ argvIndex ].split( '=' )[ 1 ]; - argvIndex ++; + } - } + const testModeArgument = process.argv[ argvIndex ]; - if ( process.argv[ argvIndex ].startsWith( '--mode' ) ) { + if ( testModeArgument?.startsWith( '--mode=' ) ) { - testMode = process.argv[ argvIndex ].split( '=' )[ 1 ]; - argvIndex ++; + testMode = testModeArgument.split( '=' )[ 1 ]; - } + } - browser = await puppeteer.launch( { - headless: testMode === 'headless', - args: flags, - env: { ...process.env, VK_DRIVER_FILES: '/usr/share/vulkan/icd.d/lvp_icd.x86_64.json' }, - defaultViewport: null, - handleSIGINT: false, - protocolTimeout: 0, - userDataDir: './.puppeteer_profile' - } ); + if ( testPage === '' ) throw new Error( 'Missing required --testPage argument.' ); + if ( testMode !== 'headless' && testMode !== 'headful' ) throw new Error( 'The --mode argument must be headless or headful.' ); - if ( testMode === 'headful' ) { + browser = await puppeteer.launch( { + headless: testMode === 'headless', + args: flags, + env: { ...process.env, VK_DRIVER_FILES: '/usr/share/vulkan/icd.d/lvp_icd.x86_64.json' }, + defaultViewport: null, + handleSIGINT: false, + protocolTimeout: 0, + userDataDir: './.puppeteer_profile' + } ); - browser.on( 'targetdestroyed', target => { + if ( testMode === 'headful' ) { - // close the process when testing page is closed - if ( target.type() === 'page' ) close( 0 ); + browser.on( 'targetdestroyed', target => { - } ); + // Close the process when the test page is closed. + if ( target.type() === 'page' ) void close( 0 ); - } + } ); - const page = await browser.newPage(); + } - captureConsole( page ); + const page = await browser.newPage(); - const testUrl = `http://localhost:${port}/test/unit/${testPage}`; + captureConsole( page ); - // Load the test page - await page.goto( testUrl, { - waitUntil: 'networkidle0', - timeout: networkTimeout * 60000 - } ); + const testUrl = `http://localhost:${port}/test/unit/${testPage}`; - // Wait for the QUnit test results - await page.waitForFunction( () => { + // Load the test page + await page.goto( testUrl, { + waitUntil: 'networkidle0', + timeout: networkTimeout * 60000 + } ); - return window.QUnit && window.QUnit.done; + // Wait for the QUnit test results + await page.waitForFunction( () => { - } ); + return window.QUnit && window.QUnit.done; - // Get the test results - const stats = await page.evaluate( () => { + } ); - // these are set on window in the HTML test page - return window._QUnitStats; + // Get the test results + const stats = await page.evaluate( () => { - } ); + // these are set on window in the HTML test page + return window._QUnitStats; - white( `1..${stats.total}` ); - green( `# pass ${stats.passed}` ); - yellow( `# skip ${stats.skipped}` ); - cyan( `# todo ${stats.todo}` ); - red( `# fail ${stats.failed}` ); + } ); - // Keep the process running if testing in headful mode, otherwise close it. - testMode === 'headless' && close( stats.failed > 0 ? 1 : 0 ); + white( `1..${stats.total}` ); + green( `# pass ${stats.passed}` ); + yellow( `# skip ${stats.skipped}` ); + cyan( `# todo ${stats.todo}` ); + red( `# fail ${stats.failed}` ); - } )(); + // Keep the process running if testing in headful mode, otherwise close it. + if ( testMode === 'headless' ) await close( stats.failed > 0 ? 1 : 0 ); } -process.on( 'SIGINT', () => close() ); +server.listen( port, () => { + + main().catch( async error => { + + red( error.stack || error.message || String( error ) ); + await close( 1 ); + + } ); + +} ); -function close( exitCode = 1 ) { +process.on( 'SIGINT', () => void close() ); + +async function close( exitCode = 1 ) { + + if ( closing ) return; + closing = true; + + try { + + if ( browser ) await browser.close(); + + } catch ( error ) { + + red( `Failed to close browser cleanly: ${error.message}` ); + exitCode = 1; + + } + + await new Promise( resolve => { + + if ( server.listening ) { + + server.close( resolve ); + + } else { + + resolve(); + + } + + } ); - browser.close(); - server.close(); - process.exit( exitCode ); + process.exitCode = exitCode; } diff --git a/utils/build/preview.js b/utils/build/preview.js index d74a3acd..1b27d301 100644 --- a/utils/build/preview.js +++ b/utils/build/preview.js @@ -1,24 +1,27 @@ -import { spawn } from 'child_process'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; const __dirname = path.dirname( fileURLToPath( import.meta.url ) ); const rootDir = path.resolve( __dirname, '../..' ); +const require = createRequire( import.meta.url ); +const rollupPath = require.resolve( 'rollup/dist/bin/rollup' ); // Start rollup in watch mode -const rollup = spawn( 'npx', [ - 'rollup', +const rollup = spawn( process.execPath, [ + rollupPath, '-c', 'utils/build/rollup.config.js', '-w', '-m', 'inline' ], { cwd: rootDir, stdio: [ 'ignore', 'pipe', 'pipe' ], - shell: true + shell: false } ); // Start server -const server = spawn( 'node', [ 'utils/server.js', '-p', '8080' ], { +const server = spawn( process.execPath, [ 'utils/server.js', '-p', '8080' ], { cwd: rootDir, stdio: [ 'ignore', 'pipe', 'pipe' ], shell: false @@ -46,16 +49,58 @@ server.stdout.on( 'data', prefix( 'HTTP', '\x1b[42m\x1b[1m' ) ); server.stderr.on( 'data', prefix( 'HTTP', '\x1b[42m\x1b[1m' ) ); // Handle cleanup -const cleanup = () => { +let closing = false; + +const stopChild = child => new Promise( resolve => { + + if ( child.exitCode !== null || child.signalCode !== null ) { + + resolve(); + return; + + } + + child.once( 'close', resolve ); + + if ( child.kill() === false ) resolve(); + +} ); + +const cleanup = async ( exitCode = 0 ) => { + + if ( closing ) return; + closing = true; + + await Promise.all( [ + stopChild( rollup ), + stopChild( server ) + ] ); - rollup.kill(); - server.kill(); - process.exit( 0 ); + process.exitCode = exitCode; }; -process.on( 'SIGINT', cleanup ); -process.on( 'SIGTERM', cleanup ); +const fail = ( name, error ) => { -rollup.on( 'close', cleanup ); -server.on( 'close', cleanup ); + console.error( `[${name}] ${error.stack || error.message || String( error )}` ); + void cleanup( 1 ); + +}; + +process.on( 'SIGINT', () => void cleanup() ); +process.on( 'SIGTERM', () => void cleanup() ); + +rollup.on( 'error', error => fail( 'ROLLUP', error ) ); +server.on( 'error', error => fail( 'HTTP', error ) ); + +rollup.on( 'close', code => { + + if ( closing === false ) void cleanup( code ?? 1 ); + +} ); + +server.on( 'close', code => { + + if ( closing === false ) void cleanup( code ?? 1 ); + +} ); diff --git a/utils/server.js b/utils/server.js index 3b67ce9f..33541261 100644 --- a/utils/server.js +++ b/utils/server.js @@ -44,13 +44,30 @@ const mimeTypes = { function createHandler( rootDirectory ) { + rootDirectory = path.resolve( rootDirectory ); + return ( req, res ) => { - const pathname = decodeURIComponent( req.url.split( '?' )[ 0 ] ); - let filePath = path.join( rootDirectory, pathname ); + let pathname; + + try { + + pathname = decodeURIComponent( new URL( req.url || '/', 'http://localhost' ).pathname ); + + } catch ( error ) { + + res.writeHead( 400 ); + res.end( 'Bad request' ); + return; + + } + + const requestPath = pathname.replace( /^[/\\]+/, '' ); + let filePath = path.resolve( rootDirectory, requestPath ); + const relativePath = path.relative( rootDirectory, filePath ); // Prevent path traversal attacks - if ( ! filePath.startsWith( rootDirectory ) ) { + if ( relativePath === '..' || relativePath.startsWith( `..${path.sep}` ) || path.isAbsolute( relativePath ) ) { res.writeHead( 403 ); res.end( 'Forbidden' ); @@ -189,12 +206,19 @@ function tryListen( server, port, maxAttempts = 20 ) { let attempts = 0; + const listen = () => { + + server.once( 'error', onError ); + server.listen( port + attempts ); + + }; + const onError = ( err ) => { if ( err.code === 'EADDRINUSE' && attempts < maxAttempts ) { attempts ++; - server.listen( port + attempts ); + listen(); } else { @@ -211,9 +235,8 @@ function tryListen( server, port, maxAttempts = 20 ) { }; - server.once( 'error', onError ); server.once( 'listening', onListening ); - server.listen( port ); + listen(); } );