import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, writeFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; // Compiles the game's real GLSL with Khronos' reference compiler (glslangValidator). // This is the test that would have caught the vec4(vec3, float, float) bug: the shader // failed to compile in the browser and the 3D canvas rendered pure black. const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const retroSrc = readFileSync(join(root, 'js', 'retro.js'), 'utf8'); function glslangAvailable() { try { execFileSync('glslangValidator', ['--version'], { stdio: ['ignore', 'ignore', 'ignore'] }); return true; } catch { return false; } } // three.js prepends these declarations for a GLSL ES 1.00 ShaderMaterial. const VERTEX_PREFIX = `precision highp float; attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; uniform mat3 normalMatrix; `; const FRAGMENT_PREFIX = 'precision highp float;\n'; function extractShader(source, name) { const re = new RegExp(`${name}:\\s*\`([\\s\\S]*?)\``, 'm'); const m = source.match(re); if (!m) throw new Error(`could not extract ${name} from js/retro.js`); return m[1]; } function compile(source, stage, label) { // glslang infers the shader stage from the file extension, so the extension matters. const ext = stage === 'vert' ? 'vert' : 'frag'; const file = join(tmpdir(), `shader-test-${label}.${ext}`); writeFileSync(file, `#version 100\n${source}`); try { execFileSync('glslangValidator', [file], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); return { ok: true, log: '' }; } catch (e) { const log = `${e.stdout || ''}${e.stderr || ''}`; // A usage banner means glslang never read the file: that is a harness fault, not a // shader fault, and must not be mistaken for a compile error. if (/^Usage: glslang/m.test(log.trim())) { throw new Error(`glslang invoked incorrectly (no stage recognised) for ${label}.${ext}`); } return { ok: false, log }; } } describe('CRT post-processing shaders', () => { const vertex = extractShader(retroSrc, 'vertexShader'); const fragment = extractShader(retroSrc, 'fragmentShader'); test('both shaders are present in js/retro.js', () => { assert.ok(vertex.includes('gl_Position'), 'vertex shader has no gl_Position assignment'); assert.ok(fragment.includes('gl_FragColor'), 'fragment shader has no gl_FragColor assignment'); }); test('shaders compile as GLSL ES 1.00', { skip: glslangAvailable() ? false : 'glslangValidator not installed (apt: glslang-tools)' }, () => { const v = compile(VERTEX_PREFIX + vertex, 'vert', 'vertex'); assert.ok(v.ok, `vertex shader failed to compile:\n${v.log}`); const f = compile(FRAGMENT_PREFIX + fragment, 'frag', 'fragment'); assert.ok(f.ok, `fragment shader failed to compile:\n${f.log}`); }); // Canary: proves the harness can actually reject a bad shader, so the test above // cannot pass vacuously. test('harness rejects a known-bad vec4 construction', { skip: glslangAvailable() ? false : 'glslangValidator not installed (apt: glslang-tools)' }, () => { const bad = `${VERTEX_PREFIX}varying vec2 vUv;\nvoid main(){ vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }`; const r = compile(bad, 'vert', 'canary'); assert.equal(r.ok, false, 'canary shader unexpectedly compiled — the test harness is not validating anything'); assert.match(r.log, /constructor/, 'canary rejected for an unexpected reason'); }); test('vertex shader does not over-fill gl_Position', () => { // position is a vec3 in three.js, so vec4(position, x, y) is 5 components into a vec4. const offenders = vertex .split('\n') .map((line, i) => ({ line: line.trim(), i: i + 1 })) .filter(l => /vec4\(\s*position\s*,\s*[\d.]+\s*,\s*[\d.]+\s*\)/.test(l.line)); assert.deepEqual(offenders, [], `vec4(vec3, float, float) is invalid GLSL ES: ${JSON.stringify(offenders)}`); }); });